From 960631bb74ee37cedc90651cf82fe6c611699661 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 26 Jun 2025 20:47:51 +1000 Subject: [PATCH 01/47] Sanitising URLs --- package.json | 3 ++- pnpm-lock.yaml | 8 ++++++++ src/auth/browser-provider.ts | 18 ++++++++++++------ src/react/useMcp.ts | 9 +++++++-- wrangler.jsonc | 2 +- 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 46a585f..6d2165b 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "deploy:site": "npx wrangler deploy" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0" + "@modelcontextprotocol/sdk": "^1.11.0", + "strict-url-sanitise": "^0.0.1" }, "devDependencies": { "@axodotdev/oranda": "^0.6.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f6f618..b663c5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.11.0 version: 1.11.0 + strict-url-sanitise: + specifier: ^0.0.1 + version: 0.0.1 devDependencies: '@axodotdev/oranda': specifier: ^0.6.5 @@ -1346,6 +1349,9 @@ packages: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} + strict-url-sanitise@0.0.1: + resolution: {integrity: sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2668,6 +2674,8 @@ snapshots: stoppable@1.1.0: {} + strict-url-sanitise@0.0.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 diff --git a/src/auth/browser-provider.ts b/src/auth/browser-provider.ts index ee3da8d..f644a0e 100644 --- a/src/auth/browser-provider.ts +++ b/src/auth/browser-provider.ts @@ -1,6 +1,7 @@ // browser-provider.ts import { OAuthClientInformation, OAuthMetadata, OAuthTokens, OAuthClientMetadata } from '@modelcontextprotocol/sdk/shared/auth.js' import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { sanitizeUrl } from 'strict-url-sanitise' // Assuming StoredState is defined in ./types.js and includes fields for provider options import { StoredState } from './types.js' // Adjust path if necessary @@ -29,15 +30,16 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider { this.serverUrlHash = this.hashString(serverUrl) this.clientName = options.clientName || 'MCP Browser Client' this.clientUri = options.clientUri || (typeof window !== 'undefined' ? window.location.origin : '') - this.callbackUrl = + this.callbackUrl = sanitizeUrl( options.callbackUrl || - (typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback') + (typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback'), + ) } // --- SDK Interface Methods --- get redirectUrl(): string { - return this.callbackUrl + return sanitizeUrl(this.callbackUrl) } get clientMetadata(): OAuthClientMetadata { @@ -143,13 +145,16 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider { authorizationUrl.searchParams.set('state', state) const authUrlString = authorizationUrl.toString() + // Sanitize the authorization URL to prevent XSS attacks + const sanitizedAuthUrl = sanitizeUrl(authUrlString) + // Persist the exact auth URL in case the popup fails and manual navigation is needed - localStorage.setItem(this.getKey('last_auth_url'), authUrlString) + localStorage.setItem(this.getKey('last_auth_url'), sanitizedAuthUrl) // Attempt to open the popup const popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes' // Make configurable if needed try { - const popup = window.open(authUrlString, `mcp_auth_${this.serverUrlHash}`, popupFeatures) + const popup = window.open(sanitizedAuthUrl, `mcp_auth_${this.serverUrlHash}`, popupFeatures) if (!popup || popup.closed || typeof popup.closed === 'undefined') { console.warn( @@ -175,7 +180,8 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider { * Retrieves the last URL passed to `redirectToAuthorization`. Useful for manual fallback. */ getLastAttemptedAuthUrl(): string | null { - return localStorage.getItem(this.getKey('last_auth_url')) + const storedUrl = localStorage.getItem(this.getKey('last_auth_url')) + return storedUrl ? sanitizeUrl(storedUrl) : null } clearStorage(): number { diff --git a/src/react/useMcp.ts b/src/react/useMcp.ts index e755bb2..cd697e0 100644 --- a/src/react/useMcp.ts +++ b/src/react/useMcp.ts @@ -6,6 +6,7 @@ import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprot import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' // Added import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { auth, UnauthorizedError, OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { sanitizeUrl } from 'strict-url-sanitise' import { BrowserOAuthClientProvider } from '../auth/browser-provider.js' // Adjust path import { assert } from '../utils/assert.js' // Adjust path import type { UseMcpOptions, UseMcpResult } from './types.js' // Adjust path @@ -23,7 +24,9 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { url, clientName, clientUri, - callbackUrl = typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback', + callbackUrl = typeof window !== 'undefined' + ? sanitizeUrl(new URL('/oauth/callback', window.location.origin).toString()) + : '/oauth/callback', storageKeyPrefix = 'mcp:auth', clientConfig = {}, customHeaders = {}, @@ -189,7 +192,9 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { authProvider: authProviderRef.current, requestInit: { headers: customHeaders }, } - const targetUrl = new URL(url) + // Sanitize the URL to prevent XSS attacks from malicious server URLs + const sanitizedUrl = sanitizeUrl(url) + const targetUrl = new URL(sanitizedUrl) if (transportType === 'http') { transportInstance = new StreamableHTTPClientTransport(targetUrl, commonOptions) diff --git a/wrangler.jsonc b/wrangler.jsonc index c39cf7d..c5985f5 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -20,4 +20,4 @@ "observability": { "enabled": true } -} \ No newline at end of file +} From ef08e97a5f8608868f3335669686e0046c459fd9 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 26 Jun 2025 21:24:03 +1000 Subject: [PATCH 02/47] 0.0.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6d2165b..47fafdf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "use-mcp", "repository": "https://github.com/modelcontextprotocol/use-mcp", - "version": "0.0.9", + "version": "0.0.10", "type": "module", "files": [ "dist", From b326e36ccc6338dfbf35a1f1db6fa61259bb12fe Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Fri, 27 Jun 2025 16:08:56 +1000 Subject: [PATCH 03/47] Adding transportType to API --- .prettierignore | 4 ++- AGENT.md | 4 +++ package.json | 3 +- pnpm-lock.yaml | 42 ++++++++++++++++++++++++++-- src/react/types.ts | 2 ++ src/react/useMcp.ts | 68 +++++++++++++++++++++++++++++++++++++-------- 6 files changed, 106 insertions(+), 17 deletions(-) diff --git a/.prettierignore b/.prettierignore index b7a1ccc..c75da94 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,5 @@ pnpm-lock.yaml *.md -examples +.wrangler +.vscode +test-results diff --git a/AGENT.md b/AGENT.md index 759766b..a2cf9e8 100644 --- a/AGENT.md +++ b/AGENT.md @@ -27,6 +27,10 @@ - Log errors with appropriate levels (debug, info, warn, error) - 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 - Use React hooks with useRef for mutable values - Stable callbacks with useCallback and appropriate dependencies \ No newline at end of file diff --git a/package.json b/package.json index 47fafdf..3e5b132 100644 --- a/package.json +++ b/package.json @@ -24,16 +24,17 @@ "dev": "tsup --watch", "build": "tsup", "check": "prettier --check . && tsc", + "prettier:fix": "prettier --write .", "fix:oranda": "sed -i 's/```tsx/```ts/g' README.md", "build:site": "npx @axodotdev/oranda build", "deploy:site": "npx wrangler deploy" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0", "strict-url-sanitise": "^0.0.1" }, "devDependencies": { "@axodotdev/oranda": "^0.6.5", + "@modelcontextprotocol/sdk": "^1.13.1", "@types/react": "^19.0.12", "prettier": "^3.5.3", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b663c5b..381badd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@axodotdev/oranda': specifier: ^0.6.5 version: 0.6.5 + '@modelcontextprotocol/sdk': + specifier: ^1.13.1 + version: 1.13.1 '@types/react': specifier: ^19.0.12 version: 19.0.12 @@ -535,8 +538,8 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@modelcontextprotocol/sdk@1.11.0': - resolution: {integrity: sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==} + '@modelcontextprotocol/sdk@1.13.1': + resolution: {integrity: sha512-8q6+9aF0yA39/qWT/uaIj6zTpC+Qu07DnN/lb9mjoquCJsAh6l3HyYqc9O3t2j7GilseOQOQimLg7W3By6jqvg==} engines: {node: '>=18'} '@pkgjs/parseargs@0.11.0': @@ -657,6 +660,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -916,6 +922,12 @@ packages: exsolve@1.0.5: 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: resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==} peerDependencies: @@ -1041,6 +1053,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1459,6 +1474,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -1846,8 +1864,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@modelcontextprotocol/sdk@1.11.0': + '@modelcontextprotocol/sdk@1.13.1': dependencies: + ajv: 6.12.6 content-type: 1.0.5 cors: 2.8.5 cross-spawn: 7.0.6 @@ -1936,6 +1955,13 @@ snapshots: 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@6.1.0: {} @@ -2238,6 +2264,10 @@ snapshots: 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): optionalDependencies: picomatch: 4.0.2 @@ -2365,6 +2395,8 @@ snapshots: joycon@3.1.1: {} + json-schema-traverse@0.4.1: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -2802,6 +2834,10 @@ snapshots: unpipe@1.0.0: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + utils-merge@1.0.1: {} vary@1.1.2: {} diff --git a/src/react/types.ts b/src/react/types.ts index 165c0ab..5472b1f 100644 --- a/src/react/types.ts +++ b/src/react/types.ts @@ -26,6 +26,8 @@ export type UseMcpOptions = { autoReconnect?: boolean | number /** Popup window features string (dimensions and behavior) for OAuth */ popupFeatures?: string + /** Transport type preference: 'auto' (HTTP with SSE fallback), 'http' (HTTP only), 'sse' (SSE only) */ + transportType?: 'auto' | 'http' | 'sse' } export type UseMcpResult = { diff --git a/src/react/useMcp.ts b/src/react/useMcp.ts index cd697e0..ec24d8e 100644 --- a/src/react/useMcp.ts +++ b/src/react/useMcp.ts @@ -33,6 +33,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { debug = false, autoRetry = false, autoReconnect = DEFAULT_RECONNECT_DELAY, + transportType = 'auto', } = options const [state, setState] = useState('discovering') @@ -190,20 +191,36 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { const commonOptions: SSEClientTransportOptions = { 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 const sanitizedUrl = sanitizeUrl(url) 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') { + addLog('debug', 'Creating StreamableHTTPClientTransport...') transportInstance = new StreamableHTTPClientTransport(targetUrl, commonOptions) + addLog('debug', 'StreamableHTTPClientTransport created successfully') } else { // sse + addLog('debug', 'Creating SSEClientTransport...') transportInstance = new SSEClientTransport(targetUrl, commonOptions) + addLog('debug', 'SSEClientTransport created successfully') } 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) { // Use stable failConnection failConnection( @@ -222,6 +239,13 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { } transportInstance.onerror = (err: Error) => { // 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 failConnection(`Transport error (${transportType.toUpperCase()}): ${err.message}`, err) // 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() try { 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) // --- Success Path --- @@ -275,6 +302,12 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { } catch (connectErr) { // --- Error Handling Path --- 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)) // Check for 404/405 specifically for HTTP transport @@ -287,7 +320,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { errorMessage === 'Load failed' /* Safari */ 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 } @@ -344,7 +377,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { // Don't call failConnection here for HTTP transport - let orchestration handle it // so that SSE fallback can still be attempted if (transportType === 'http') { - addLog('warn', `HTTP transport failed: ${errorMessage}. Will attempt fallback to SSE.`) + addLog('warn', `HTTP transport failed: ${errorMessage}.`) return 'fallback' } else { // 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 --- let finalStatus: 'success' | 'auth_redirect' | 'failed' | 'fallback' = 'failed' // Default to failed - // 1. Try HTTP - const httpResult = await tryConnectWithTransport('http') - - // 2. 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 + if (transportType === 'sse') { + // SSE only - skip HTTP entirely + addLog('debug', 'Using SSE-only transport mode') + finalStatus = await tryConnectWithTransport('sse') + } else if (transportType === 'http') { + // HTTP only - no fallback + addLog('debug', 'Using HTTP-only transport mode') + finalStatus = await tryConnectWithTransport('http') } 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 From a225929ae2e12c3427f9932a45147e3ba34b6012 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Fri, 27 Jun 2025 16:09:36 +1000 Subject: [PATCH 04/47] Prettier changes --- examples/inspector/eslint.config.js | 7 +- examples/inspector/index.html | 25 ++- examples/inspector/src/App.tsx | 43 ++-- .../inspector/src/components/McpServers.tsx | 185 ++++++------------ examples/inspector/src/index.css | 2 +- examples/inspector/src/main.tsx | 2 +- examples/inspector/src/vite-env.d.ts | 4 +- examples/inspector/tailwind.config.js | 10 +- examples/inspector/tsconfig.json | 5 +- examples/inspector/vite.config.ts | 8 +- examples/inspector/wrangler.jsonc | 12 +- 11 files changed, 117 insertions(+), 186 deletions(-) diff --git a/examples/inspector/eslint.config.js b/examples/inspector/eslint.config.js index 092408a..91d3441 100644 --- a/examples/inspector/eslint.config.js +++ b/examples/inspector/eslint.config.js @@ -19,10 +19,7 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], }, - }, + } ) diff --git a/examples/inspector/index.html b/examples/inspector/index.html index 7216a94..7f54b78 100644 --- a/examples/inspector/index.html +++ b/examples/inspector/index.html @@ -5,34 +5,43 @@ - + MCP Inspector - use-mcp React Hook Demo - - + + - + - + - + - + - + diff --git a/examples/inspector/src/App.tsx b/examples/inspector/src/App.tsx index aa3ce2f..846ed89 100644 --- a/examples/inspector/src/App.tsx +++ b/examples/inspector/src/App.tsx @@ -7,28 +7,31 @@ function App() { } /> - -
-
-

MCP Inspector

-

- Minimal demo showcasing the{' '} - - use-mcp - {' '} - React hook -

+ +
+
+

MCP Inspector

+

+ Minimal demo showcasing the{' '} + + use-mcp + {' '} + React hook +

+
+
-
-
- } /> + } + />
) diff --git a/examples/inspector/src/components/McpServers.tsx b/examples/inspector/src/components/McpServers.tsx index c4cae8f..16780bd 100644 --- a/examples/inspector/src/components/McpServers.tsx +++ b/examples/inspector/src/components/McpServers.tsx @@ -2,15 +2,8 @@ import { useState, useRef, useEffect } from 'react' import { useMcp, type Tool } from 'use-mcp/react' import { Info, X, ChevronRight, ChevronDown } from 'lucide-react' - // MCP Connection wrapper that only renders when active -function McpConnection({ - serverUrl, - onConnectionUpdate, -}: { - serverUrl: string - onConnectionUpdate: (data: any) => void -}) { +function McpConnection({ serverUrl, onConnectionUpdate }: { serverUrl: string; onConnectionUpdate: (data: any) => void }) { // Use the MCP hook with the server URL const connection = useMcp({ url: serverUrl, @@ -22,23 +15,13 @@ function McpConnection({ // Update parent component with connection data useEffect(() => { onConnectionUpdate(connection) - }, [ - connection.state, - connection.tools, - connection.error, - connection.log.length, - connection.authUrl, - ]) + }, [connection.state, connection.tools, connection.error, connection.log.length, connection.authUrl]) // Return null as this is just a hook wrapper return null } -export function McpServers({ - onToolsUpdate, -}: { - onToolsUpdate?: (tools: Tool[]) => void -}) { +export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[]) => void }) { const [serverUrl, setServerUrl] = useState(() => { return sessionStorage.getItem('mcpServerUrl') || '' }) @@ -53,8 +36,7 @@ export function McpServers({ retry: () => {}, disconnect: () => {}, authenticate: () => Promise.resolve(undefined), - callTool: (_name: string, _args?: Record) => - Promise.resolve(undefined), + callTool: (_name: string, _args?: Record) => Promise.resolve(undefined), clearStorage: () => {}, }) const [toolForms, setToolForms] = useState>>({}) @@ -64,8 +46,7 @@ export function McpServers({ const executionLogRefs = useRef>({}) // Extract connection properties - const { state, tools, log, authUrl, disconnect, authenticate } = - connectionData + const { state, tools, log, authUrl, disconnect, authenticate } = connectionData // Notify parent component when tools change useEffect(() => { @@ -73,9 +54,8 @@ export function McpServers({ onToolsUpdate( tools.map((t: Tool) => ({ ...t, - callTool: (args: Record) => - connectionData.callTool(t.name, args), - })), + callTool: (args: Record) => connectionData.callTool(t.name, args), + })) ) } }, [tools, onToolsUpdate]) @@ -99,8 +79,7 @@ export function McpServers({ retry: () => {}, disconnect: () => {}, authenticate: () => Promise.resolve(undefined), - callTool: (_name: string, _args?: Record) => - Promise.resolve(undefined), + callTool: (_name: string, _args?: Record) => Promise.resolve(undefined), clearStorage: () => {}, }) } @@ -127,48 +106,20 @@ export function McpServers({ switch (state) { case 'discovering': - return ( - - Discovering - - ) + return Discovering case 'authenticating': - return ( - - Authenticating - - ) + return Authenticating case 'connecting': - return ( - - Connecting - - ) + return Connecting case 'loading': - return ( - - Loading - - ) + return Loading case 'ready': - return ( - - Connected - - ) + return Connected case 'failed': - return ( - - Failed - - ) + return Failed case 'not-connected': default: - return ( - - Not Connected - - ) + return Not Connected } } @@ -203,28 +154,28 @@ export function McpServers({ // Handle form input changes const handleFormChange = (toolName: string, fieldName: string, value: any) => { - setToolForms(prev => ({ + setToolForms((prev) => ({ ...prev, [toolName]: { ...prev[toolName], - [fieldName]: value - } + [fieldName]: value, + }, })) } // Helper function to clean and update execution log const updateExecutionLog = (toolName: string, newContent: string) => { - setToolExecutionLogs(prev => { + setToolExecutionLogs((prev) => { const currentLog = prev[toolName] || '' const updatedLog = currentLog + newContent // Remove blank lines and rejoin const cleanedLog = updatedLog .split('\n') - .filter(line => line.trim() !== '') + .filter((line) => line.trim() !== '') .join('\n') return { ...prev, - [toolName]: cleanedLog + '\n' + [toolName]: cleanedLog + '\n', } }) } @@ -233,11 +184,11 @@ export function McpServers({ const handleRunTool = async (tool: Tool) => { const args = toolForms[tool.name] || {} const argsStr = JSON.stringify(args) - + // Add execution start message const startMessage = `Calling ${tool.name}(${argsStr})\n` updateExecutionLog(tool.name, startMessage) - + try { const result = await connectionData.callTool(tool.name, args) const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2) @@ -249,7 +200,7 @@ export function McpServers({ // Auto-scroll execution logs to bottom when they change useEffect(() => { - Object.keys(toolExecutionLogs).forEach(toolName => { + Object.keys(toolExecutionLogs).forEach((toolName) => { const textarea = executionLogRefs.current[toolName] if (textarea) { textarea.scrollTop = textarea.scrollHeight @@ -259,24 +210,24 @@ export function McpServers({ // Clear execution log for specific tool const clearExecutionLog = (toolName: string) => { - setToolExecutionLogs(prev => ({ + setToolExecutionLogs((prev) => ({ ...prev, - [toolName]: '' + [toolName]: '', })) } // Toggle tool expanded state const toggleTool = (toolName: string) => { - setExpandedTools(prev => ({ + setExpandedTools((prev) => ({ ...prev, - [toolName]: !prev[toolName] + [toolName]: !prev[toolName], })) } // Render form field based on schema const renderFormField = (toolName: string, fieldName: string, schema: any, isRequired: boolean) => { const value = toolForms[toolName]?.[fieldName] || '' - + if (schema.type === 'number' || schema.type === 'integer') { return ( { - const newValue = e.target.value === '' ? '' : - (schema.type === 'integer' ? parseInt(e.target.value) || 0 : parseFloat(e.target.value) || 0) + const newValue = + e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) || 0 : parseFloat(e.target.value) || 0 handleFormChange(toolName, fieldName, newValue) }} /> @@ -324,8 +275,7 @@ export function McpServers({

- Connect to Model Context Protocol (MCP) servers to access additional AI - capabilities. + Connect to Model Context Protocol (MCP) servers to access additional AI capabilities.

@@ -342,9 +292,8 @@ export function McpServers({ }} disabled={isActive && state !== 'failed'} /> - - {state === 'ready' || - (isActive && state !== 'not-connected' && state !== 'failed') ? ( + + {state === 'ready' || (isActive && state !== 'not-connected' && state !== 'failed') ? ( - + {/* Per-tool execution log - only show if there's content */} {toolExecutionLogs[tool.name] && (
@@ -484,7 +415,9 @@ export function McpServers({