Adding transportType to API

This commit is contained in:
Glen Maddern 2025-06-27 16:08:56 +10:00
commit b326e36ccc
6 changed files with 106 additions and 17 deletions

View file

@ -1,3 +1,5 @@
pnpm-lock.yaml pnpm-lock.yaml
*.md *.md
examples .wrangler
.vscode
test-results

View file

@ -27,6 +27,10 @@
- Log errors with appropriate levels (debug, info, warn, error) - Log errors with appropriate levels (debug, info, warn, error)
- Defensive error handling with specific error types when available - Defensive error handling with specific error types when available
## Development Workflow
- **Commit changes frequently** after each logical change or debugging step
- Use descriptive commit messages that explain what was changed and why
### React Patterns ### React Patterns
- Use React hooks with useRef for mutable values - Use React hooks with useRef for mutable values
- Stable callbacks with useCallback and appropriate dependencies - Stable callbacks with useCallback and appropriate dependencies

View file

@ -24,16 +24,17 @@
"dev": "tsup --watch", "dev": "tsup --watch",
"build": "tsup", "build": "tsup",
"check": "prettier --check . && tsc", "check": "prettier --check . && tsc",
"prettier:fix": "prettier --write .",
"fix:oranda": "sed -i 's/```tsx/```ts/g' README.md", "fix:oranda": "sed -i 's/```tsx/```ts/g' README.md",
"build:site": "npx @axodotdev/oranda build", "build:site": "npx @axodotdev/oranda build",
"deploy:site": "npx wrangler deploy" "deploy:site": "npx wrangler deploy"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"strict-url-sanitise": "^0.0.1" "strict-url-sanitise": "^0.0.1"
}, },
"devDependencies": { "devDependencies": {
"@axodotdev/oranda": "^0.6.5", "@axodotdev/oranda": "^0.6.5",
"@modelcontextprotocol/sdk": "^1.13.1",
"@types/react": "^19.0.12", "@types/react": "^19.0.12",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"react": "^19.0.0", "react": "^19.0.0",

42
pnpm-lock.yaml generated
View file

@ -18,6 +18,9 @@ importers:
'@axodotdev/oranda': '@axodotdev/oranda':
specifier: ^0.6.5 specifier: ^0.6.5
version: 0.6.5 version: 0.6.5
'@modelcontextprotocol/sdk':
specifier: ^1.13.1
version: 1.13.1
'@types/react': '@types/react':
specifier: ^19.0.12 specifier: ^19.0.12
version: 19.0.12 version: 19.0.12
@ -535,8 +538,8 @@ packages:
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@modelcontextprotocol/sdk@1.11.0': '@modelcontextprotocol/sdk@1.13.1':
resolution: {integrity: sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==} resolution: {integrity: sha512-8q6+9aF0yA39/qWT/uaIj6zTpC+Qu07DnN/lb9mjoquCJsAh6l3HyYqc9O3t2j7GilseOQOQimLg7W3By6jqvg==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
@ -657,6 +660,9 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
ansi-regex@5.0.1: ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -916,6 +922,12 @@ packages:
exsolve@1.0.5: exsolve@1.0.5:
resolution: {integrity: sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==} resolution: {integrity: sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fdir@6.4.3: fdir@6.4.3:
resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==} resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==}
peerDependencies: peerDependencies:
@ -1041,6 +1053,9 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
lilconfig@3.1.3: lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -1459,6 +1474,9 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
utils-merge@1.0.1: utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
@ -1846,8 +1864,9 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
'@modelcontextprotocol/sdk@1.11.0': '@modelcontextprotocol/sdk@1.13.1':
dependencies: dependencies:
ajv: 6.12.6
content-type: 1.0.5 content-type: 1.0.5
cors: 2.8.5 cors: 2.8.5
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@ -1936,6 +1955,13 @@ snapshots:
acorn@8.14.0: {} acorn@8.14.0: {}
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
ansi-regex@6.1.0: {} ansi-regex@6.1.0: {}
@ -2238,6 +2264,10 @@ snapshots:
exsolve@1.0.5: {} exsolve@1.0.5: {}
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
fdir@6.4.3(picomatch@4.0.2): fdir@6.4.3(picomatch@4.0.2):
optionalDependencies: optionalDependencies:
picomatch: 4.0.2 picomatch: 4.0.2
@ -2365,6 +2395,8 @@ snapshots:
joycon@3.1.1: {} joycon@3.1.1: {}
json-schema-traverse@0.4.1: {}
lilconfig@3.1.3: {} lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
@ -2802,6 +2834,10 @@ snapshots:
unpipe@1.0.0: {} unpipe@1.0.0: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
utils-merge@1.0.1: {} utils-merge@1.0.1: {}
vary@1.1.2: {} vary@1.1.2: {}

View file

@ -26,6 +26,8 @@ export type UseMcpOptions = {
autoReconnect?: boolean | number autoReconnect?: boolean | number
/** Popup window features string (dimensions and behavior) for OAuth */ /** Popup window features string (dimensions and behavior) for OAuth */
popupFeatures?: string popupFeatures?: string
/** Transport type preference: 'auto' (HTTP with SSE fallback), 'http' (HTTP only), 'sse' (SSE only) */
transportType?: 'auto' | 'http' | 'sse'
} }
export type UseMcpResult = { export type UseMcpResult = {

View file

@ -33,6 +33,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
debug = false, debug = false,
autoRetry = false, autoRetry = false,
autoReconnect = DEFAULT_RECONNECT_DELAY, autoReconnect = DEFAULT_RECONNECT_DELAY,
transportType = 'auto',
} = options } = options
const [state, setState] = useState<UseMcpResult['state']>('discovering') const [state, setState] = useState<UseMcpResult['state']>('discovering')
@ -190,20 +191,36 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
const commonOptions: SSEClientTransportOptions = { const commonOptions: SSEClientTransportOptions = {
authProvider: authProviderRef.current, authProvider: authProviderRef.current,
requestInit: { headers: customHeaders }, requestInit: {
headers: {
Accept: 'application/json, text/event-stream',
...customHeaders,
},
},
} }
// Sanitize the URL to prevent XSS attacks from malicious server URLs // Sanitize the URL to prevent XSS attacks from malicious server URLs
const sanitizedUrl = sanitizeUrl(url) const sanitizedUrl = sanitizeUrl(url)
const targetUrl = new URL(sanitizedUrl) const targetUrl = new URL(sanitizedUrl)
addLog('debug', `Creating ${transportType.toUpperCase()} transport for URL: ${targetUrl.toString()}`)
addLog('debug', `Transport options:`, {
authProvider: !!authProviderRef.current,
headers: customHeaders,
url: targetUrl.toString(),
})
if (transportType === 'http') { if (transportType === 'http') {
addLog('debug', 'Creating StreamableHTTPClientTransport...')
transportInstance = new StreamableHTTPClientTransport(targetUrl, commonOptions) transportInstance = new StreamableHTTPClientTransport(targetUrl, commonOptions)
addLog('debug', 'StreamableHTTPClientTransport created successfully')
} else { } else {
// sse // sse
addLog('debug', 'Creating SSEClientTransport...')
transportInstance = new SSEClientTransport(targetUrl, commonOptions) transportInstance = new SSEClientTransport(targetUrl, commonOptions)
addLog('debug', 'SSEClientTransport created successfully')
} }
transportRef.current = transportInstance // Assign to ref immediately transportRef.current = transportInstance // Assign to ref immediately
addLog('debug', `${transportType.toUpperCase()} transport created.`) addLog('debug', `${transportType.toUpperCase()} transport created and assigned to ref.`)
} catch (err) { } catch (err) {
// Use stable failConnection // Use stable failConnection
failConnection( failConnection(
@ -222,6 +239,13 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
} }
transportInstance.onerror = (err: Error) => { transportInstance.onerror = (err: Error) => {
// Transport errors usually mean connection is lost/failed definitively for this transport // Transport errors usually mean connection is lost/failed definitively for this transport
addLog('warn', `Transport error event (${transportType.toUpperCase()}):`, err)
addLog('debug', `Error details:`, {
message: err.message,
stack: err.stack,
name: err.name,
cause: err.cause,
})
// Use stable failConnection // Use stable failConnection
failConnection(`Transport error (${transportType.toUpperCase()}): ${err.message}`, err) failConnection(`Transport error (${transportType.toUpperCase()}): ${err.message}`, err)
// Should we return 'failed' here? failConnection sets state, maybe that's enough. // Should we return 'failed' here? failConnection sets state, maybe that's enough.
@ -252,6 +276,9 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
// 3. Attempt client.connect() // 3. Attempt client.connect()
try { try {
addLog('info', `Connecting client via ${transportType.toUpperCase()}...`) addLog('info', `Connecting client via ${transportType.toUpperCase()}...`)
addLog('debug', `About to call client.connect() with transport instance`)
addLog('debug', `Transport instance type: ${transportInstance.constructor.name}`)
await clientRef.current!.connect(transportInstance) await clientRef.current!.connect(transportInstance)
// --- Success Path --- // --- Success Path ---
@ -275,6 +302,12 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
} catch (connectErr) { } catch (connectErr) {
// --- Error Handling Path --- // --- Error Handling Path ---
addLog('debug', `Client connect error via ${transportType.toUpperCase()}:`, connectErr) addLog('debug', `Client connect error via ${transportType.toUpperCase()}:`, connectErr)
addLog('debug', `Connect error details:`, {
message: connectErr instanceof Error ? connectErr.message : String(connectErr),
stack: connectErr instanceof Error ? connectErr.stack : 'N/A',
name: connectErr instanceof Error ? connectErr.name : 'Unknown',
cause: connectErr instanceof Error ? connectErr.cause : undefined,
})
const errorInstance = connectErr instanceof Error ? connectErr : new Error(String(connectErr)) const errorInstance = connectErr instanceof Error ? connectErr : new Error(String(connectErr))
// Check for 404/405 specifically for HTTP transport // Check for 404/405 specifically for HTTP transport
@ -287,7 +320,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
errorMessage === 'Load failed' /* Safari */ errorMessage === 'Load failed' /* Safari */
if (transportType === 'http' && (is404 || is405 || isLikelyCors)) { if (transportType === 'http' && (is404 || is405 || isLikelyCors)) {
addLog('warn', `HTTP transport failed (${isLikelyCors ? 'CORS' : is404 ? '404' : '405'}). Will attempt fallback to SSE.`) addLog('warn', `HTTP transport failed (${isLikelyCors ? 'CORS' : is404 ? '404' : '405'}).`)
return 'fallback' // Signal that fallback should be attempted return 'fallback' // Signal that fallback should be attempted
} }
@ -344,7 +377,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
// Don't call failConnection here for HTTP transport - let orchestration handle it // Don't call failConnection here for HTTP transport - let orchestration handle it
// so that SSE fallback can still be attempted // so that SSE fallback can still be attempted
if (transportType === 'http') { if (transportType === 'http') {
addLog('warn', `HTTP transport failed: ${errorMessage}. Will attempt fallback to SSE.`) addLog('warn', `HTTP transport failed: ${errorMessage}.`)
return 'fallback' return 'fallback'
} else { } else {
// For SSE transport, we can fail immediately since there's no further fallback // For SSE transport, we can fail immediately since there's no further fallback
@ -357,15 +390,26 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
// --- Orchestrate Connection Attempts --- // --- Orchestrate Connection Attempts ---
let finalStatus: 'success' | 'auth_redirect' | 'failed' | 'fallback' = 'failed' // Default to failed let finalStatus: 'success' | 'auth_redirect' | 'failed' | 'fallback' = 'failed' // Default to failed
// 1. Try HTTP if (transportType === 'sse') {
const httpResult = await tryConnectWithTransport('http') // SSE only - skip HTTP entirely
addLog('debug', 'Using SSE-only transport mode')
// 2. Try SSE only if HTTP requested fallback and we haven't redirected finalStatus = await tryConnectWithTransport('sse')
if (httpResult === 'fallback' && isMountedRef.current && stateRef.current !== 'authenticating') { } else if (transportType === 'http') {
const sseResult = await tryConnectWithTransport('sse') // HTTP only - no fallback
finalStatus = sseResult // Use SSE result as final status addLog('debug', 'Using HTTP-only transport mode')
finalStatus = await tryConnectWithTransport('http')
} else { } else {
finalStatus = httpResult // Use HTTP result if no fallback was needed/possible // Auto mode - try HTTP first, fallback to SSE
addLog('debug', 'Using auto transport mode (HTTP with SSE fallback)')
const httpResult = await tryConnectWithTransport('http')
// Try SSE only if HTTP requested fallback and we haven't redirected
if (httpResult === 'fallback' && isMountedRef.current && stateRef.current !== 'authenticating') {
const sseResult = await tryConnectWithTransport('sse')
finalStatus = sseResult // Use SSE result as final status
} else {
finalStatus = httpResult // Use HTTP result if no fallback was needed/possible
}
} }
// If we still have 'fallback' status, convert to 'failed' since no more transports to try // If we still have 'fallback' status, convert to 'failed' since no more transports to try