diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dd32058..005fbc3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,12 @@ name: Publish Any Commit on: + workflow_dispatch: pull_request: push: branches: - "**" tags: - - "!**" + - "v*" jobs: build: @@ -14,17 +15,19 @@ jobs: 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 }}" - - run: corepack enable - - uses: actions/setup-node@v4 + - name: Setup pnpm & install + uses: https://github.com/wyvox/action-setup-pnpm@v3 with: - node-version: 20 - cache: "pnpm" - - - name: Install dependencies - run: pnpm install + node-version: 22 - name: Build run: pnpm build - - run: pnpm dlx pkg-pr-new publish --compact --bin + - run: pnpm dlx publish --compact --bin diff --git a/README.md b/README.md index c36424c..bc0a4a7 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 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: +**Note:** Cursor and Claude Desktop (Windows) have a bug where spaces inside `args` aren't escaped when it invokes `npx`, which ends up mangling these values. You can work around it using: ```jsonc { @@ -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 diff --git a/package.json b/package.json index 5ee38e5..bf4892f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { - "name": "mcp-remote", - "version": "0.1.2", - "packageManager": "pnpm@8.15.1", + "name": "@kvant/mcp-remote", + "version": "0.1.5", "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", "keywords": [ "mcp", @@ -32,13 +31,12 @@ "express": "^4.21.2", "open": "^10.1.0" }, + "packageManager": "pnpm@10.11.0", "devDependencies": { - "@modelcontextprotocol/sdk": "^1.10.2", + "@modelcontextprotocol/sdk": "^1.11.2", "@types/express": "^5.0.0", "@types/node": "^22.13.10", - "@types/react": "^19.0.12", "prettier": "^3.5.3", - "react": "^19.0.0", "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.8.2" @@ -54,8 +52,6 @@ "dts": true, "clean": true, "outDir": "dist", - "external": [ - "react" - ] + "external": [] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48d8491..d0720bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,23 +16,17 @@ importers: version: 10.1.0 devDependencies: '@modelcontextprotocol/sdk': - specifier: ^1.10.2 - version: 1.10.2 + specifier: ^1.11.2 + version: 1.11.2 '@types/express': specifier: ^5.0.0 version: 5.0.0 '@types/node': specifier: ^22.13.10 version: 22.13.10 - '@types/react': - specifier: ^19.0.12 - version: 19.0.12 prettier: specifier: ^3.5.3 version: 3.5.3 - react: - specifier: ^19.0.0 - version: 19.0.0 tsup: specifier: ^8.4.0 version: 8.4.0(tsx@4.19.3)(typescript@5.8.2) @@ -217,8 +211,8 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@modelcontextprotocol/sdk@1.10.2': - resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==} + '@modelcontextprotocol/sdk@1.11.2': + resolution: {integrity: sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==} engines: {node: '>=18'} '@pkgjs/parseargs@0.11.0': @@ -350,9 +344,6 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react@19.0.12': - resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==} - '@types/send@0.17.4': resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} @@ -479,9 +470,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -899,10 +887,6 @@ packages: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} - react@19.0.0: - resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} - engines: {node: '>=0.10.0'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -1222,7 +1206,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@modelcontextprotocol/sdk@1.10.2': + '@modelcontextprotocol/sdk@1.11.2': dependencies: content-type: 1.0.5 cors: 2.8.5 @@ -1334,10 +1318,6 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/react@19.0.12': - dependencies: - csstype: 3.1.3 - '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 @@ -1474,8 +1454,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - csstype@3.1.3: {} - debug@2.6.9: dependencies: ms: 2.0.0 @@ -1896,8 +1874,6 @@ snapshots: iconv-lite: 0.6.3 unpipe: 1.0.0 - react@19.0.0: {} - readdirp@4.1.2: {} resolve-from@5.0.0: {} diff --git a/src/client.ts b/src/client.ts index 4e0c14c..d87599c 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), 3333, 'Usage: npx tsx client.ts [callback-port]') +parseCommandLineArgs(process.argv.slice(2), '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 1e58b7e..0826844 100644 --- a/src/lib/node-oauth-client-provider.ts +++ b/src/lib/node-oauth-client-provider.ts @@ -1,9 +1,8 @@ import open from 'open' import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' import { - OAuthClientInformation, OAuthClientInformationFull, - OAuthClientInformationSchema, + OAuthClientInformationFullSchema, OAuthTokens, OAuthTokensSchema, } from '@modelcontextprotocol/sdk/shared/auth.js' @@ -37,7 +36,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { } get redirectUrl(): string { - return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}` + return `http://localhost:${this.options.callbackPort}${this.callbackPath}` } get clientMetadata() { @@ -57,9 +56,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', OAuthClientInformationSchema) + return readJsonFile(this.serverUrlHash, 'client_info.json', OAuthClientInformationFullSchema) } /** diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c6238b2..a0a60dc 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -3,6 +3,13 @@ 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' @@ -10,10 +17,6 @@ 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 @@ -32,14 +35,21 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo let transportToClientClosed = false let transportToServerClosed = false - transportToClient.onmessage = (message) => { - // @ts-expect-error TODO + transportToClient.onmessage = (_message) => { + // TODO: fix types + const message = _message as any log('[Local→Remote]', message.method || message.id) + if (message.method === 'initialize') { + const { clientInfo } = message.params + if (clientInfo) clientInfo.name = `${clientInfo.name} (via mcp-remote ${MCP_REMOTE_VERSION})` + log(JSON.stringify(message, null, 2)) + } transportToServer.send(message).catch(onServerError) } - transportToServer.onmessage = (message) => { - // @ts-expect-error TODO: fix this type + transportToServer.onmessage = (_message) => { + // TODO: fix types + const message = _message as any log('[Remote→Local]', message.method || message.id) transportToClient.send(message).catch(onClientError) } @@ -130,14 +140,14 @@ export async function connectToRemoteServer( const sseTransport = transportStrategy === 'sse-only' || transportStrategy === 'sse-first' const transport = sseTransport ? new SSEClientTransport(url, { - authProvider, - requestInit: { headers }, - eventSourceInit, - }) + authProvider, + requestInit: { headers }, + eventSourceInit, + }) : new StreamableHTTPClientTransport(url, { - authProvider, - requestInit: { headers }, - }) + authProvider, + requestInit: { headers }, + }) try { if (client) { @@ -300,7 +310,16 @@ export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServe log('Auth code received, resolving promise') authCompletedResolve(code) - res.send('Authorization successful! You may close this window and return to the CLI.') + res.send(` + Authorization successful! + You may close this window and return to the CLI. + + `) // Notify main flow that auth code is available options.events.emit('auth-code-received', code) @@ -336,6 +355,27 @@ 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 @@ -369,14 +409,13 @@ 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[], defaultPort: number, usage: string) { +export async function parseCommandLineArgs(args: string[], usage: string) { // Process headers const headers: Record = {} - let i = 0; + let i = 0 while (i < args.length) { if (args[i] === '--header' && i < args.length - 1) { const value = args[i + 1] @@ -423,14 +462,28 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, log(usage) process.exit(1) } + const serverUrlHash = getServerUrlHash(serverUrl) + const defaultPort = calculateDefaultPort(serverUrlHash) - // Use the specified port, or find an available one - const callbackPort = specifiedPort || (await findAvailablePort(defaultPort)) + // Use the specified port, or the existing client port or fallback to find an available one + const [existingClientPort, availablePort] = await Promise.all([findExistingClientPort(serverUrlHash), findAvailablePort(defaultPort)]) + let callbackPort: number if (specifiedPort) { - log(`Using specified callback port: ${callbackPort}`) + if (existingClientPort && specifiedPort !== existingClientPort) { + log( + `Warning! Specified callback port of ${specifiedPort}, which conflicts with existing client registration port ${existingClientPort}. Deleting existing client data to force reregistration.`, + ) + await fs.rm(getConfigFilePath(serverUrlHash, 'client_info.json')) + } + log(`Using specified callback port: ${specifiedPort}`) + callbackPort = specifiedPort + } else if (existingClientPort) { + log(`Using existing client port: ${existingClientPort}`) + callbackPort = existingClientPort } else { - log(`Using automatically selected callback port: ${callbackPort}`) + log(`Using automatically selected callback port: ${availablePort}`) + callbackPort = availablePort } if (Object.keys(headers).length > 0) { @@ -468,6 +521,11 @@ 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 7263a95..535bfe2 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), 3334, 'Usage: npx tsx proxy.ts [callback-port]') +parseCommandLineArgs(process.argv.slice(2), '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 cd9cfa1..9bfece1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "esModuleInterop": true, "noEmit": true, "lib": ["ES2022", "DOM"], - "types": ["node", "react"], + "types": ["node"], "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }