diff --git a/package.json b/package.json index 47cfa99..317eca6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-remote", - "version": "0.0.22", + "version": "0.1.0-2", "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", "keywords": [ "mcp", @@ -28,11 +28,11 @@ "check": "prettier --check . && tsc" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.9.0", "express": "^4.21.2", "open": "^10.1.0" }, "devDependencies": { + "@modelcontextprotocol/sdk": "^1.10.2", "@types/express": "^5.0.0", "@types/node": "^22.13.10", "@types/react": "^19.0.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e550c3e..48d8491 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@modelcontextprotocol/sdk': - specifier: ^1.9.0 - version: 1.9.0 express: specifier: ^4.21.2 version: 4.21.2 @@ -18,6 +15,9 @@ importers: specifier: ^10.1.0 version: 10.1.0 devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.10.2 + version: 1.10.2 '@types/express': specifier: ^5.0.0 version: 5.0.0 @@ -217,8 +217,8 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@modelcontextprotocol/sdk@1.9.0': - resolution: {integrity: sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==} + '@modelcontextprotocol/sdk@1.10.2': + resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==} engines: {node: '>=18'} '@pkgjs/parseargs@0.11.0': @@ -396,8 +396,8 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - body-parser@2.1.0: - resolution: {integrity: sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==} + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} brace-expansion@2.0.1: @@ -490,15 +490,6 @@ packages: supports-color: optional: true - debug@4.3.6: - resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -576,12 +567,12 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventsource-parser@3.0.0: - resolution: {integrity: sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==} + eventsource-parser@3.0.1: + resolution: {integrity: sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==} engines: {node: '>=18.0.0'} - eventsource@3.0.5: - resolution: {integrity: sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw==} + eventsource@3.0.6: + resolution: {integrity: sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==} engines: {node: '>=18.0.0'} express-rate-limit@7.5.0: @@ -594,8 +585,8 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} - express@5.0.1: - resolution: {integrity: sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==} + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} fdir@6.4.3: @@ -673,10 +664,6 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - iconv-lite@0.5.2: - resolution: {integrity: sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==} - engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -771,8 +758,8 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime-types@3.0.0: - resolution: {integrity: sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==} + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} mime@1.6.0: @@ -791,9 +778,6 @@ packages: ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -935,8 +919,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - router@2.1.0: - resolution: {integrity: sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} run-applescript@7.0.0: @@ -953,16 +937,16 @@ packages: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} - send@1.1.0: - resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==} + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} serve-static@1.16.2: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} - serve-static@2.1.0: - resolution: {integrity: sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==} + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} setprototypeof@1.2.0: @@ -1081,8 +1065,8 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} - type-is@2.0.0: - resolution: {integrity: sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} typescript@5.8.2: @@ -1132,8 +1116,8 @@ packages: peerDependencies: zod: ^3.24.1 - zod@3.24.2: - resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zod@3.24.3: + resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} snapshots: @@ -1238,18 +1222,18 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@modelcontextprotocol/sdk@1.9.0': + '@modelcontextprotocol/sdk@1.10.2': dependencies: content-type: 1.0.5 cors: 2.8.5 cross-spawn: 7.0.6 - eventsource: 3.0.5 - express: 5.0.1 - express-rate-limit: 7.5.0(express@5.0.1) + eventsource: 3.0.6 + express: 5.1.0 + express-rate-limit: 7.5.0(express@5.1.0) pkce-challenge: 5.0.0 raw-body: 3.0.0 - zod: 3.24.2 - zod-to-json-schema: 3.24.5(zod@3.24.2) + zod: 3.24.3 + zod-to-json-schema: 3.24.5(zod@3.24.3) transitivePeerDependencies: - supports-color @@ -1372,7 +1356,7 @@ snapshots: accepts@2.0.0: dependencies: - mime-types: 3.0.0 + mime-types: 3.0.1 negotiator: 1.0.0 ansi-regex@5.0.1: {} @@ -1408,17 +1392,17 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.1.0: + body-parser@2.2.0: dependencies: bytes: 3.1.2 content-type: 1.0.5 debug: 4.4.0 http-errors: 2.0.0 - iconv-lite: 0.5.2 + iconv-lite: 0.6.3 on-finished: 2.4.1 qs: 6.14.0 raw-body: 3.0.0 - type-is: 2.0.0 + type-is: 2.0.1 transitivePeerDependencies: - supports-color @@ -1496,10 +1480,6 @@ snapshots: dependencies: ms: 2.0.0 - debug@4.3.6: - dependencies: - ms: 2.1.2 - debug@4.4.0: dependencies: ms: 2.1.3 @@ -1575,15 +1555,15 @@ snapshots: etag@1.8.1: {} - eventsource-parser@3.0.0: {} + eventsource-parser@3.0.1: {} - eventsource@3.0.5: + eventsource@3.0.6: dependencies: - eventsource-parser: 3.0.0 + eventsource-parser: 3.0.1 - express-rate-limit@7.5.0(express@5.0.1): + express-rate-limit@7.5.0(express@5.1.0): dependencies: - express: 5.0.1 + express: 5.1.0 express@4.21.2: dependencies: @@ -1621,16 +1601,15 @@ snapshots: transitivePeerDependencies: - supports-color - express@5.0.1: + express@5.1.0: dependencies: accepts: 2.0.0 - body-parser: 2.1.0 + body-parser: 2.2.0 content-disposition: 1.0.0 content-type: 1.0.5 cookie: 0.7.1 cookie-signature: 1.2.2 - debug: 4.3.6 - depd: 2.0.0 + debug: 4.4.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -1638,22 +1617,18 @@ snapshots: fresh: 2.0.0 http-errors: 2.0.0 merge-descriptors: 2.0.0 - methods: 1.1.2 - mime-types: 3.0.0 + mime-types: 3.0.1 on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.13.0 + qs: 6.14.0 range-parser: 1.2.1 - router: 2.1.0 - safe-buffer: 5.2.1 - send: 1.1.0 - serve-static: 2.1.0 - setprototypeof: 1.2.0 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 statuses: 2.0.1 - type-is: 2.0.0 - utils-merge: 1.0.1 + type-is: 2.0.1 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -1752,10 +1727,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.5.2: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -1818,7 +1789,7 @@ snapshots: dependencies: mime-db: 1.52.0 - mime-types@3.0.0: + mime-types@3.0.1: dependencies: mime-db: 1.54.0 @@ -1832,8 +1803,6 @@ snapshots: ms@2.0.0: {} - ms@2.1.2: {} - ms@2.1.3: {} mz@2.7.0: @@ -1960,11 +1929,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.35.0 fsevents: 2.3.3 - router@2.1.0: + router@2.2.0: dependencies: + debug: 4.4.0 + depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 path-to-regexp: 8.2.0 + transitivePeerDependencies: + - supports-color run-applescript@7.0.0: {} @@ -1990,16 +1963,15 @@ snapshots: transitivePeerDependencies: - supports-color - send@1.1.0: + send@1.2.0: dependencies: debug: 4.4.0 - destroy: 1.2.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - fresh: 0.5.2 + fresh: 2.0.0 http-errors: 2.0.0 - mime-types: 2.1.35 + mime-types: 3.0.1 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 @@ -2016,12 +1988,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.1.0: + serve-static@2.2.0: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.1.0 + send: 1.2.0 transitivePeerDependencies: - supports-color @@ -2162,11 +2134,11 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 - type-is@2.0.0: + type-is@2.0.1: dependencies: content-type: 1.0.5 media-typer: 1.1.0 - mime-types: 3.0.0 + mime-types: 3.0.1 typescript@5.8.2: {} @@ -2204,8 +2176,8 @@ snapshots: wrappy@1.0.2: {} - zod-to-json-schema@3.24.5(zod@3.24.2): + zod-to-json-schema@3.24.5(zod@3.24.3): dependencies: - zod: 3.24.2 + zod: 3.24.3 - zod@3.24.2: {} + zod@3.24.3: {} diff --git a/src/client.ts b/src/client.ts index d620884..4e0c14c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,25 +11,36 @@ import { EventEmitter } from 'events' import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' -import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' -import { parseCommandLineArgs, setupSignalHandlers, log, MCP_REMOTE_VERSION, getServerUrlHash } from './lib/utils' -import { coordinateAuth } from './lib/coordination' +import { + parseCommandLineArgs, + setupSignalHandlers, + log, + MCP_REMOTE_VERSION, + getServerUrlHash, + connectToRemoteServer, + TransportStrategy, +} from './lib/utils' +import { createLazyAuthCoordinator } from './lib/coordination' /** * Main function to run the client */ -async function runClient(serverUrl: string, callbackPort: number, headers: Record) { +async function runClient( + serverUrl: string, + callbackPort: number, + headers: Record, + transportStrategy: TransportStrategy = 'http-first', +) { // Set up event emitter for auth flow const events = new EventEmitter() // Get the server URL hash for lockfile operations const serverUrlHash = getServerUrlHash(serverUrl) - // Coordinate authentication with other instances - const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events) + // Create a lazy auth coordinator + const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events) // Create the OAuth client provider const authProvider = new NodeOAuthClientProvider({ @@ -38,14 +49,6 @@ async function runClient(serverUrl: string, callbackPort: number, headers: Recor clientName: 'MCP CLI Client', }) - // 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 const client = new Client( { @@ -57,10 +60,33 @@ async function runClient(serverUrl: string, callbackPort: number, headers: Recor }, ) - // Create the transport factory - const url = new URL(serverUrl) - function initTransport() { - const transport = new SSEClientTransport(url, { authProvider, requestInit: { headers } }) + // Keep track of the server instance for cleanup + let server: any = null + + // Define an auth initializer function + const authInitializer = async () => { + const authState = await authCoordinator.initializeAuth() + + // Store server in outer scope for cleanup + server = authState.server + + // If auth was completed by another instance, just log that we'll use the auth from disk + if (authState.skipBrowserAuth) { + log('Authentication was completed by another instance - will use tokens from disk...') + // TODO: remove, the callback is happening before the tokens are exchanged + // so we're slightly too early + await new Promise((res) => setTimeout(res, 1_000)) + } + + return { + waitForAuthCode: authState.waitForAuthCode, + skipBrowserAuth: authState.skipBrowserAuth, + } + } + + try { + // Connect to remote server with lazy authentication + const transport = await connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy) // Set up message and error handlers transport.onmessage = (message) => { @@ -75,89 +101,59 @@ async function runClient(serverUrl: string, callbackPort: number, headers: Recor log('Connection closed.') process.exit(0) } - return transport - } - const transport = initTransport() - - // Set up cleanup handler - const cleanup = async () => { - log('\nClosing connection...') - await client.close() - server.close() - } - setupSignalHandlers(cleanup) - - // Try to connect - try { - log('Connecting to server...') - await client.connect(transport) - log('Connected successfully!') - } catch (error) { - if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { - log('Authentication required. Waiting for authorization...') - - // Wait for the authorization code from the callback or another instance - const code = await waitForAuthCode() - - try { - log('Completing authorization...') - await transport.finishAuth(code) - - // Reconnect after authorization with a new transport - log('Connecting after authorization...') - await client.connect(initTransport()) - - log('Connected successfully!') - - // Request tools list after auth - log('Requesting tools list...') - const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) - log('Tools:', JSON.stringify(tools, null, 2)) - - // Request resources list after auth - log('Requesting resource list...') - const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) - log('Resources:', JSON.stringify(resources, null, 2)) - - log('Listening for messages. Press Ctrl+C to exit.') - } catch (authError) { - log('Authorization error:', authError) + // Set up cleanup handler + const cleanup = async () => { + log('\nClosing connection...') + await client.close() + // If auth was initialized and server was created, close it + if (server) { server.close() - process.exit(1) } - } else { - log('Connection error:', error) - server.close() - process.exit(1) } - } + setupSignalHandlers(cleanup) - try { - // Request tools list - log('Requesting tools list...') - const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) - log('Tools:', JSON.stringify(tools, null, 2)) - } catch (e) { - log('Error requesting tools list:', e) - } + log('Connected successfully!') - try { - // Request resources list - log('Requesting resource list...') - const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) - log('Resources:', JSON.stringify(resources, null, 2)) - } catch (e) { - log('Error requesting resources list:', e) - } + try { + // Request tools list + log('Requesting tools list...') + const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) + log('Tools:', JSON.stringify(tools, null, 2)) + } catch (e) { + log('Error requesting tools list:', e) + } - log('Listening for messages. Press Ctrl+C to exit.') + try { + // Request resources list + log('Requesting resource list...') + const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) + log('Resources:', JSON.stringify(resources, null, 2)) + } catch (e) { + log('Error requesting resources list:', e) + } + + // log('Listening for messages. Press Ctrl+C to exit.') + log('Exiting OK...') + // Only close the server if it was initialized + if (server) { + server.close() + } + process.exit(0) + } catch (error) { + log('Fatal error:', error) + // Only close the server if it was initialized + if (server) { + server.close() + } + process.exit(1) + } } // Parse command-line arguments and run the client parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts [callback-port]') - .then(({ serverUrl, callbackPort, headers }) => { - return runClient(serverUrl, callbackPort, headers) + .then(({ serverUrl, callbackPort, headers, transportStrategy }) => { + return runClient(serverUrl, callbackPort, headers, transportStrategy) }) .catch((error) => { console.error('Fatal error:', error) diff --git a/src/lib/coordination.ts b/src/lib/coordination.ts index ad1d2f6..ffe0c5b 100644 --- a/src/lib/coordination.ts +++ b/src/lib/coordination.ts @@ -5,6 +5,10 @@ import express from 'express' import { AddressInfo } from 'net' import { log, setupOAuthCallbackServerWithLongPoll } from './utils' +export type AuthCoordinator = { + initializeAuth: () => Promise<{ server: Server; waitForAuthCode: () => Promise; skipBrowserAuth: boolean }> +} + /** * Checks if a process with the given PID is running * @param pid The process ID to check @@ -88,6 +92,36 @@ export async function waitForAuthentication(port: number): Promise { } } +/** + * Creates a lazy auth coordinator that will only initiate auth when needed + * @param serverUrlHash The hash of the server URL + * @param callbackPort The port to use for the callback server + * @param events The event emitter to use for signaling + * @returns An AuthCoordinator object with an initializeAuth method + */ +export function createLazyAuthCoordinator( + serverUrlHash: string, + callbackPort: number, + events: EventEmitter +): AuthCoordinator { + let authState: { server: Server; waitForAuthCode: () => Promise; skipBrowserAuth: boolean } | null = null + + return { + initializeAuth: async () => { + // If auth has already been initialized, return the existing state + if (authState) { + return authState + } + + log('Initializing auth coordination on-demand') + + // Initialize auth using the existing coordinateAuth logic + authState = await coordinateAuth(serverUrlHash, callbackPort, events) + return authState + } + } +} + /** * Coordinates authentication between multiple instances of the client/proxy * @param serverUrlHash The hash of the server URL diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 40c744d..1b68bef 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,15 @@ import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' + +// Connection constants +export const REASON_AUTH_NEEDED = 'authentication-needed' +export const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport' + +// Transport strategy types +export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first' import { OAuthCallbackServerOptions } from './types' import express from 'express' import net from 'net' @@ -65,21 +74,33 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo } /** - * Creates and connects to a remote SSE server with OAuth authentication + * Type for the auth initialization function + */ +export type AuthInitializer = () => Promise<{ + waitForAuthCode: () => Promise + skipBrowserAuth: boolean +}> + +/** + * Creates and connects to a remote server with OAuth authentication + * @param client The client to connect with * @param serverUrl The URL of the remote server * @param authProvider The OAuth client provider * @param headers Additional headers to send with the request - * @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 + * @param authInitializer Function to initialize authentication when needed + * @param transportStrategy Strategy for selecting transport type ('sse-only', 'http-only', 'sse-first', 'http-first') + * @param recursionReasons Set of reasons for recursive calls (internal use) + * @returns The connected transport */ export async function connectToRemoteServer( + client: Client | null, serverUrl: string, authProvider: OAuthClientProvider, headers: Record, - waitForAuthCode: () => Promise, - skipBrowserAuth: boolean = false, -): Promise { + authInitializer: AuthInitializer, + transportStrategy: TransportStrategy = 'http-first', + recursionReasons: Set = new Set(), +): Promise { log(`[${pid}] Connecting to remote server: ${serverUrl}`) const url = new URL(serverUrl) @@ -93,25 +114,88 @@ export async function connectToRemoteServer( ...(init?.headers as Record | undefined), ...headers, ...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}), - Accept: "text/event-stream", + Accept: 'text/event-stream', } as Record, - }) - ); + }), + ) }, - }; + } - const transport = new SSEClientTransport(url, { - authProvider, - requestInit: { headers }, - eventSourceInit, - }) + log(`Using transport strategy: ${transportStrategy}`) + // Determine if we should attempt to fallback on error + // Choose transport based on user strategy and recursion history + const shouldAttemptFallback = transportStrategy === 'http-first' || transportStrategy === 'sse-first' + + // Create transport instance based on the strategy + const sseTransport = transportStrategy === 'sse-only' || transportStrategy === 'sse-first' + const transport = sseTransport + ? new SSEClientTransport(url, { + authProvider, + requestInit: { headers }, + eventSourceInit, + }) + : new StreamableHTTPClientTransport(url, { + authProvider, + requestInit: { headers }, + }) try { - await transport.start() - log('Connected to remote server') + if (client) { + await client.connect(transport) + } else { + await transport.start() + if (!sseTransport) { + // Extremely hacky, but we didn't actually send a request when calling transport.start() above, so we don't + // know if we're even talking to an HTTP server. But if we forced that now we'd get an error later saying that + // the client is already connected. So let's just create a one-off client to make a single request and figure + // out if we're actually talking to an HTTP server or not. + const testTransport = new StreamableHTTPClientTransport(url, { authProvider, requestInit: { headers } }) + const testClient = new Client({ name: 'mcp-remote-fallback-test', version: '0.0.0' }, { capabilities: {} }) + await testClient.connect(testTransport) + } + } + log(`Connected to remote server using ${transport.constructor.name}`) + return transport } catch (error) { - if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { + // Check if it's a protocol error and we should attempt fallback + if ( + error instanceof Error && + shouldAttemptFallback && + (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}`) + + // If we've already tried falling back once, throw an error + if (recursionReasons.has(REASON_TRANSPORT_FALLBACK)) { + const errorMessage = `Already attempted transport fallback. Giving up.` + log(errorMessage) + throw new Error(errorMessage) + } + + log(`Recursively reconnecting for reason: ${REASON_TRANSPORT_FALLBACK}`) + + // Add to recursion reasons set + recursionReasons.add(REASON_TRANSPORT_FALLBACK) + + // Recursively call connectToRemoteServer with the updated recursion tracking + return connectToRemoteServer( + client, + serverUrl, + authProvider, + headers, + authInitializer, + sseTransport ? 'http-only' : 'sse-only', + recursionReasons, + ) + } else if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { + log('Authentication required. Initializing auth...') + + // Initialize authentication on-demand + const { waitForAuthCode, skipBrowserAuth } = await authInitializer() + if (skipBrowserAuth) { log('Authentication required but skipping browser auth - using shared auth') } else { @@ -125,11 +209,18 @@ export async function connectToRemoteServer( log('Completing authorization...') await transport.finishAuth(code) - // Create a new transport after auth - const newTransport = new SSEClientTransport(url, { authProvider, requestInit: { headers } }) - await newTransport.start() - log('Connected to remote server after authentication') - return newTransport + if (recursionReasons.has(REASON_AUTH_NEEDED)) { + const errorMessage = `Already attempted reconnection for reason: ${REASON_AUTH_NEEDED}. Giving up.` + log(errorMessage) + throw new Error(errorMessage) + } + + // Track this reason for recursion + recursionReasons.add(REASON_AUTH_NEEDED) + log(`Recursively reconnecting for reason: ${REASON_AUTH_NEEDED}`) + + // Recursively call connectToRemoteServer with the updated recursion tracking + return connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy, recursionReasons) } catch (authError) { log('Authorization error:', authError) throw authError @@ -301,6 +392,19 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, const specifiedPort = args[1] ? parseInt(args[1]) : undefined const allowHttp = args.includes('--allow-http') + // Parse transport strategy + let transportStrategy: TransportStrategy = 'http-first' // Default + const transportIndex = args.indexOf('--transport') + if (transportIndex !== -1 && transportIndex < args.length - 1) { + const strategy = args[transportIndex + 1] + if (strategy === 'sse-only' || strategy === 'http-only' || strategy === 'sse-first' || strategy === 'http-first') { + transportStrategy = strategy as TransportStrategy + log(`Using transport strategy: ${transportStrategy}`) + } else { + log(`Warning: Ignoring invalid transport strategy: ${strategy}. Valid values are: sse-only, http-only, sse-first, http-first`) + } + } + if (!serverUrl) { log(usage) process.exit(1) @@ -343,7 +447,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, }) } - return { serverUrl, callbackPort, headers } + return { serverUrl, callbackPort, headers, transportStrategy } } /** diff --git a/src/proxy.ts b/src/proxy.ts index 9fd87d1..7263a95 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -11,22 +11,36 @@ import { EventEmitter } from 'events' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupSignalHandlers, getServerUrlHash } from './lib/utils' +import { + connectToRemoteServer, + log, + mcpProxy, + parseCommandLineArgs, + setupSignalHandlers, + getServerUrlHash, + MCP_REMOTE_VERSION, + TransportStrategy, +} from './lib/utils' import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' -import { coordinateAuth } from './lib/coordination' +import { createLazyAuthCoordinator } from './lib/coordination' /** * Main function to run the proxy */ -async function runProxy(serverUrl: string, callbackPort: number, headers: Record) { +async function runProxy( + serverUrl: string, + callbackPort: number, + headers: Record, + transportStrategy: TransportStrategy = 'http-first', +) { // Set up event emitter for auth flow const events = new EventEmitter() // Get the server URL hash for lockfile operations const serverUrlHash = getServerUrlHash(serverUrl) - // Coordinate authentication with other instances - const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events) + // Create a lazy auth coordinator + const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events) // Create the OAuth client provider const authProvider = new NodeOAuthClientProvider({ @@ -35,20 +49,36 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record clientName: 'MCP CLI Proxy', }) - // 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 const localTransport = new StdioServerTransport() + // Keep track of the server instance for cleanup + let server: any = null + + // Define an auth initializer function + const authInitializer = async () => { + const authState = await authCoordinator.initializeAuth() + + // Store server in outer scope for cleanup + server = authState.server + + // If auth was completed by another instance, just log that we'll use the auth from disk + if (authState.skipBrowserAuth) { + log('Authentication was completed by another instance - will use tokens from disk') + // TODO: remove, the callback is happening before the tokens are exchanged + // so we're slightly too early + await new Promise((res) => setTimeout(res, 1_000)) + } + + return { + waitForAuthCode: authState.waitForAuthCode, + skipBrowserAuth: authState.skipBrowserAuth, + } + } + try { - // Connect to remote server with authentication - const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, headers, waitForAuthCode, skipBrowserAuth) + // Connect to remote server with lazy authentication + const remoteTransport = await connectToRemoteServer(null, serverUrl, authProvider, headers, authInitializer, transportStrategy) // Set up bidirectional proxy between local and remote transports mcpProxy({ @@ -59,14 +89,17 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record // Start the local STDIO server await localTransport.start() log('Local STDIO server running') - log('Proxy established successfully between local STDIO and remote SSE') + log(`Proxy established successfully between local STDIO and remote ${remoteTransport.constructor.name}`) log('Press Ctrl+C to exit') // Setup cleanup handler const cleanup = async () => { await remoteTransport.close() await localTransport.close() - server.close() + // Only close the server if it was initialized + if (server) { + server.close() + } } setupSignalHandlers(cleanup) } catch (error) { @@ -93,15 +126,18 @@ to the CA certificate file. If using claude_desktop_config.json, this might look } `) } - server.close() + // Only close the server if it was initialized + if (server) { + server.close() + } process.exit(1) } } // Parse command-line arguments and run the proxy parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [callback-port]') - .then(({ serverUrl, callbackPort, headers }) => { - return runProxy(serverUrl, callbackPort, headers) + .then(({ serverUrl, callbackPort, headers, transportStrategy }) => { + return runProxy(serverUrl, callbackPort, headers, transportStrategy) }) .catch((error) => { log('Fatal error:', error)