diff --git a/README.md b/README.md index ca9d961..d95aaaa 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,37 @@ To bypass authentication, or to emit custom headers on all requests to your remo }, ``` +### Multiple Instances + +To run multiple instances of the same remote server with different configurations (e.g., different Atlassian tenants), use the `--resource` flag to isolate OAuth sessions: + +```json +{ + "mcpServers": { + "atlassian_tenant1": { + "command": "npx", + "args": [ + "mcp-remote", + "https://mcp.atlassian.com/v1/sse", + "--resource", + "https://tenant1.atlassian.net/" + ] + }, + "atlassian_tenant2": { + "command": "npx", + "args": [ + "mcp-remote", + "https://mcp.atlassian.com/v1/sse", + "--resource", + "https://tenant2.atlassian.net/" + ] + } + } +} +``` + +Each unique combination of server URL, resource, and custom headers will maintain separate OAuth sessions and token storage. + ### Flags * If `npx` is producing errors, consider adding `-y` as the first argument to auto-accept the installation of the `mcp-remote` package. diff --git a/src/client.ts b/src/client.ts index 0421729..961cb22 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,15 +13,7 @@ import { EventEmitter } from 'events' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' -import { - parseCommandLineArgs, - setupSignalHandlers, - log, - MCP_REMOTE_VERSION, - getServerUrlHash, - connectToRemoteServer, - TransportStrategy, -} from './lib/utils' +import { parseCommandLineArgs, setupSignalHandlers, log, MCP_REMOTE_VERSION, connectToRemoteServer, TransportStrategy } from './lib/utils' import { StaticOAuthClientInformationFull, StaticOAuthClientMetadata } from './lib/types' import { createLazyAuthCoordinator } from './lib/coordination' @@ -37,13 +29,11 @@ async function runClient( staticOAuthClientMetadata: StaticOAuthClientMetadata, staticOAuthClientInfo: StaticOAuthClientInformationFull, authTimeoutMs: number, + serverUrlHash: string, ) { // Set up event emitter for auth flow const events = new EventEmitter() - // Get the server URL hash for lockfile operations - const serverUrlHash = getServerUrlHash(serverUrl) - // Create a lazy auth coordinator const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events, authTimeoutMs) @@ -55,6 +45,7 @@ async function runClient( clientName: 'MCP CLI Client', staticOAuthClientMetadata, staticOAuthClientInfo, + serverUrlHash, }) // Create the client @@ -161,7 +152,17 @@ async function runClient( // Parse command-line arguments and run the client parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx client.ts [callback-port] [--debug]') .then( - ({ serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo, authTimeoutMs }) => { + ({ + serverUrl, + callbackPort, + headers, + transportStrategy, + host, + staticOAuthClientMetadata, + staticOAuthClientInfo, + authTimeoutMs, + serverUrlHash, + }) => { return runClient( serverUrl, callbackPort, @@ -171,6 +172,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx client.ts { expect(typeof result.waitForAuthCode).toBe('function') }) }) + +describe('Feature: Server URL Hash Generation', () => { + it('Scenario: Generate consistent hash for same config', () => { + const hash1 = getServerUrlHash('https://example.com', 'resource1', { Auth: 'token' }) + const hash2 = getServerUrlHash('https://example.com', 'resource1', { Auth: 'token' }) + expect(hash1).toBe(hash2) + }) + + it('Scenario: Generate different hash for different resources', () => { + const hash1 = getServerUrlHash('https://example.com', 'resource1') + const hash2 = getServerUrlHash('https://example.com', 'resource2') + expect(hash1).not.toBe(hash2) + }) + + it('Scenario: Generate different hash for different headers', () => { + const hash1 = getServerUrlHash('https://example.com', '', { Auth: 'token1' }) + const hash2 = getServerUrlHash('https://example.com', '', { Auth: 'token2' }) + expect(hash1).not.toBe(hash2) + }) + + it('Scenario: Handle header key ordering consistently', () => { + const hash1 = getServerUrlHash('https://example.com', '', { B: '2', A: '1' }) + const hash2 = getServerUrlHash('https://example.com', '', { A: '1', B: '2' }) + expect(hash1).toBe(hash2) + }) + + it('Scenario: Backward compatible with no resource or headers', () => { + const hash1 = getServerUrlHash('https://example.com') + const hash2 = getServerUrlHash('https://example.com', '', {}) + expect(hash1).toBe(hash2) + }) + + it('Scenario: Empty string resource same as undefined', () => { + const hash1 = getServerUrlHash('https://example.com', '') + const hash2 = getServerUrlHash('https://example.com') + expect(hash1).toBe(hash2) + }) +}) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 3824903..e48fb0c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -739,7 +739,8 @@ export async function parseCommandLineArgs(args: string[], usage: string) { log(usage) process.exit(1) } - const serverUrlHash = getServerUrlHash(serverUrl) + // Calculate hash with all parsed parameters for cache isolation + const serverUrlHash = getServerUrlHash(serverUrl, authorizeResource, headers) // Set server hash globally for debug logging global.currentServerUrlHash = serverUrlHash @@ -800,6 +801,7 @@ export async function parseCommandLineArgs(args: string[], usage: string) { authorizeResource, ignoredTools, authTimeoutMs, + serverUrlHash, } } @@ -824,12 +826,24 @@ export function setupSignalHandlers(cleanup: () => Promise) { } /** - * Generates a hash for the server URL to use in filenames - * @param serverUrl The server URL to hash - * @returns The hashed server URL + * Generates a hash for the server URL configuration + * Includes resource and headers to isolate OAuth sessions per unique + * server configuration (fixes #25: multi-instance support) + * @param serverUrl The server URL + * @param authorizeResource Optional resource parameter for OAuth + * @param headers Optional custom headers + * @returns MD5 hash of the configuration */ -export function getServerUrlHash(serverUrl: string): string { - return crypto.createHash('md5').update(serverUrl).digest('hex') +export function getServerUrlHash(serverUrl: string, authorizeResource?: string, headers?: Record): string { + // Include resource and headers in hash to isolate OAuth sessions + // per unique server configuration (fixes #25) + const parts = [serverUrl] + if (authorizeResource) parts.push(authorizeResource) + if (headers && Object.keys(headers).length > 0) { + const sortedKeys = Object.keys(headers).sort() + parts.push(JSON.stringify(headers, sortedKeys)) + } + return crypto.createHash('md5').update(parts.join('|')).digest('hex') } /** diff --git a/src/proxy.ts b/src/proxy.ts index a5972eb..6a6ad5b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -11,15 +11,7 @@ import { EventEmitter } from 'events' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { - connectToRemoteServer, - log, - mcpProxy, - parseCommandLineArgs, - setupSignalHandlers, - getServerUrlHash, - TransportStrategy, -} from './lib/utils' +import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupSignalHandlers, TransportStrategy } from './lib/utils' import { StaticOAuthClientInformationFull, StaticOAuthClientMetadata } from './lib/types' import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' import { createLazyAuthCoordinator } from './lib/coordination' @@ -38,13 +30,11 @@ async function runProxy( authorizeResource: string, ignoredTools: string[], authTimeoutMs: number, + serverUrlHash: string, ) { // Set up event emitter for auth flow const events = new EventEmitter() - // Get the server URL hash for lockfile operations - const serverUrlHash = getServerUrlHash(serverUrl) - // Create a lazy auth coordinator const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events, authTimeoutMs) @@ -57,6 +47,7 @@ async function runProxy( staticOAuthClientMetadata, staticOAuthClientInfo, authorizeResource, + serverUrlHash, }) // Create the STDIO transport for local connections @@ -160,6 +151,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts { return runProxy( serverUrl, @@ -172,6 +164,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts