diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c58f197..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,14 +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 }}" - name: Setup pnpm & install - uses: wyvox/action-setup-pnpm@v3 + uses: https://github.com/wyvox/action-setup-pnpm@v3 with: node-version: 22 - pnpm-version: 10 - 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 bc70b15..bc0a4a7 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,11 @@ 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": "..." - } } } ``` @@ -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 ba21bf7..bf4892f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "mcp-remote", - "version": "0.1.2", + "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", @@ -31,8 +31,9 @@ "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", "prettier": "^3.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a987cf4..d0720bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ 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 @@ -211,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': @@ -1206,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 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 e86aaac..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 @@ -352,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 @@ -385,11 +409,10 @@ 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 @@ -439,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) { @@ -484,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) })