Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 15 additions & 13 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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)

Expand All @@ -55,6 +45,7 @@ async function runClient(
clientName: 'MCP CLI Client',
staticOAuthClientMetadata,
staticOAuthClientInfo,
serverUrlHash,
})

// Create the client
Expand Down Expand Up @@ -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 <https://server-url> [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,
Expand All @@ -171,6 +172,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx client.ts <https://s
staticOAuthClientMetadata,
staticOAuthClientInfo,
authTimeoutMs,
serverUrlHash,
)
},
)
Expand Down
4 changes: 2 additions & 2 deletions src/lib/node-oauth-client-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import type { OAuthProviderOptions, StaticOAuthClientMetadata } from './types'
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile, deleteConfigFile } from './mcp-auth-config'
import { StaticOAuthClientInformationFull } from './types'
import { getServerUrlHash, log, debugLog, MCP_REMOTE_VERSION } from './utils'
import { log, debugLog, MCP_REMOTE_VERSION } from './utils'
import { sanitizeUrl } from 'strict-url-sanitise'
import { randomUUID } from 'node:crypto'

Expand All @@ -34,7 +34,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
* @param options Configuration options for the provider
*/
constructor(readonly options: OAuthProviderOptions) {
this.serverUrlHash = getServerUrlHash(options.serverUrl)
this.serverUrlHash = options.serverUrlHash
this.callbackPath = options.callbackPath || '/oauth/callback'
this.clientName = options.clientName || 'MCP CLI Client'
this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli'
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface OAuthProviderOptions {
staticOAuthClientInfo?: StaticOAuthClientInformationFull
/** Resource parameter to send to the authorization server */
authorizeResource?: string
/** Pre-calculated server URL hash for cache isolation */
serverUrlHash: string
}

/**
Expand Down
40 changes: 39 additions & 1 deletion src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { parseCommandLineArgs, shouldIncludeTool, mcpProxy, setupOAuthCallbackServerWithLongPoll } from './utils'
import { parseCommandLineArgs, shouldIncludeTool, mcpProxy, setupOAuthCallbackServerWithLongPoll, getServerUrlHash } from './utils'
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { EventEmitter } from 'events'
import express from 'express'
Expand Down Expand Up @@ -915,3 +915,41 @@ describe('setupOAuthCallbackServerWithLongPoll', () => {
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)
})
})
26 changes: 20 additions & 6 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -800,6 +801,7 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
authorizeResource,
ignoredTools,
authTimeoutMs,
serverUrlHash,
}
}

Expand All @@ -824,12 +826,24 @@ export function setupSignalHandlers(cleanup: () => Promise<void>) {
}

/**
* 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, string>): 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')
}

/**
Expand Down
17 changes: 5 additions & 12 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)

Expand All @@ -57,6 +47,7 @@ async function runProxy(
staticOAuthClientMetadata,
staticOAuthClientInfo,
authorizeResource,
serverUrlHash,
})

// Create the STDIO transport for local connections
Expand Down Expand Up @@ -160,6 +151,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts <https://se
authorizeResource,
ignoredTools,
authTimeoutMs,
serverUrlHash,
}) => {
return runProxy(
serverUrl,
Expand All @@ -172,6 +164,7 @@ parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts <https://se
authorizeResource,
ignoredTools,
authTimeoutMs,
serverUrlHash,
)
},
)
Expand Down
Loading