Skip to content

Conversation

@gabrypavanello
Copy link
Contributor

Summary

Support OAuth-authenticated MCP server connections in the inspector with three modes:

Mode A — Dashboard Flow: User connects to OAuth-protected MCP server, configures client credentials or uses Dynamic Client Registration (RFC 7591), browser opens for authorization, PKCE exchange completes, Bearer token established.

Mode B — CLI/Agent Preset: --oauth-client-id, --oauth-client-secret, --oauth-scopes, --oauth-config, --oauth-auto-register flags for non-interactive auth with cached token reuse.

Mode C — Dual Mode Mirroring: Proxy port serves upstream .well-known/oauth-protected-resource (RFC 9728) and .well-known/oauth-authorization-server (RFC 8414) with resource URL rewriting.

Key Features

  • InspectorOAuthProvider implementing MCP SDK's OAuthClientProvider interface
  • XDG-compliant token persistence (~/.config/mcp-inspector/tokens/) with SHA-256 URL hashing, atomic writes, 0o600 permissions
  • Server-side token revocation on disconnect (RFC 7009)
  • Auto-refresh via SDK transport (provider persists refreshed tokens)
  • Dashboard: OAuth config popover, scope multi-select chips from auth server metadata, auth status badges (🔓/🔄/🔒)
  • Token expiry tracking and display

Changes

  • 33 files changed, +6,654 lines
  • New module: packages/inspector/src/oauth/ (provider, token-store, callback-handler, preset-config, wellknown-proxy, types)
  • Modified: connection.ts, connection-registry.ts, standalone-server.ts, dual-server.ts, mcp-inspector.ts CLI, test-client.ts
  • Dashboard: OAuthPanel, useOAuth hook, ConnectionBar + Toolbar badges

Tests

  • 1,052 tests passing (40 new integration tests covering all 10 acceptance criteria)
  • 9 test files for OAuth (provider, token-store, connection, callback-handler, preset-config, wellknown-proxy, dashboard, revocation, integration acceptance)

Pipeline

Vega (plan) → Rigel (build ×3) → Altair (review ×3) → Canopus (gap ×2) → Polaris (test) ✅

Sirius added 10 commits February 1, 2026 11:03
- InspectorOAuthProvider: implements MCP SDK OAuthClientProvider interface
  - Token persistence via TokenStore
  - PKCE code verifier storage (memory + disk)
  - Dynamic client registration support (opt-in)
  - Authorization redirect with pending URL mechanism
  - Status tracking for dashboard display
  - Credential invalidation by scope

- TokenStore: XDG-compliant persistent token storage
  - Per-server-URL storage at ~/.config/mcp-inspector/tokens/
  - SHA-256 URL hashing for filesystem-safe filenames
  - Atomic writes (temp + rename) to prevent corruption
  - Merge semantics (saves don't overwrite unrelated fields)
  - Restrictive file permissions (0o600)

- OAuth types: shared types for the oauth module
  - Re-exports SDK types (OAuthTokens, OAuthClientProvider, etc.)
  - PersistedTokenData, OAuthClientConfig, OAuthState, OAuthPresetConfig

- 47 tests covering both TokenStore and InspectorOAuthProvider
- TestClient: accept authProvider option, pass to StreamableHTTPClientTransport
- ConnectionManager: create InspectorOAuthProvider when oauthConfig provided,
  wire to createTestClient, expose getOAuthProvider/getOAuthState/setOAuthProvider,
  clear provider on disconnect
- ConnectionRegistry: include OAuth state in listConnections output
- connection-types: add oauthConfig to ConnectOptions, oauth to ConnectionStatusOutput

- 10 integration tests covering OAuth+Connection wiring
Implement OAuth callback and API endpoints for both server types:

callback-handler.ts:
  - GET /oauth/callback: auth code exchange via MCP SDK auth(), HTML response
  - POST /api/oauth/configure: create OAuth provider for a connection
  - GET /api/oauth/status?connectionId=X: poll auth state + pending URL
  - POST /api/oauth/revoke?connectionId=X: local token invalidation
  - Single handleOAuthRoutes() dispatcher, connectionId-aware with fallback

standalone-server.ts:
  - Wire handleOAuthRoutes before health check route

dual-server.ts:
  - Wire handleOAuthRoutes before /api/connect route

Tests: 41 tests covering all 4 endpoints (routing, CORS, error handling,
  configure validation, connectionId resolution, token lifecycle, XSS)
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 1, 2026

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • OAuth 2.1 + PKCE support: dashboard OAuth panel, per-connection status/badge, CLI preset flags (non‑interactive), server endpoints for configure/status/revoke, and well‑known discovery/proxy.
    • Per-server token persistence with atomic storage and expiry tracking; test-client now accepts an auth provider for HTTP transports.
  • Tests

    • Extensive unit, integration, and acceptance tests covering OAuth flows, provider, preset config, token store, revocation, dashboard hooks, and end‑to‑end scenarios.
  • Chores

    • Adds SDK dependency for OAuth integrations.

✏️ Tip: You can customize this high-level summary in your review settings.

Walkthrough

Adds end-to-end OAuth2.1 + PKCE support to the inspector: new SDK dependency, CLI preset flags, server OAuth routes and .well‑known proxy, InspectorOAuthProvider and XDG-backed TokenStore, dashboard hook/UI and wiring into connections, test-client auth propagation, extensive tests, and build/test config updates.

Changes

Cohort / File(s) Summary
Dependency & Build
packages/inspector/package.json, packages/inspector/tsup.config.ts, packages/inspector/vitest.config.ts
Adds @modelcontextprotocol/sdk dependency; marks it external for bundling; configures Vitest to inline SDK subpath exports for tests.
CLI / Startup
packages/inspector/src/bin/mcp-inspector.ts
Adds CLI preset flags for OAuth, resolves preset configs, creates preset provider, validates flag combos, and supplies provider into startup/auto-connect.
Server Integration
packages/inspector/src/standalone-server.ts, packages/inspector/src/dual-server.ts
Wires OAuth routes (/oauth/*, /api/oauth/*) into standalone and dual servers; creates and exposes well‑known proxy context; accepts a preset oauthProvider for auto-connect.
OAuth Core & Types
packages/inspector/src/oauth/types.ts, packages/inspector/src/oauth/index.ts
Adds OAuth types (OAuthState, OAuthStatus, PersistedTokenData, OAuthClientConfig, preset types) and central re-exports for OAuth APIs.
Provider Implementation
packages/inspector/src/oauth/provider.ts
New InspectorOAuthProvider implementing OAuth client lifecycle: PKCE, DCR support, token persistence, discovery, revocation, pending auth URL handling, and status notifications.
Token Storage
packages/inspector/src/oauth/token-store.ts
Adds XDG-aware TokenStore with hashed per-server filenames, atomic writes, load/save/delete/list APIs, and helper functions.
Preset / Non-interactive Flow
packages/inspector/src/oauth/preset-config.ts
Parses CLI flags and config files, resolves merged preset config, creates non-interactive preset providers, and checks for existing tokens.
Callback & API Handlers
packages/inspector/src/oauth/callback-handler.ts
Adds handlers: /oauth/callback, POST /api/oauth/configure, GET /api/oauth/status, POST /api/oauth/revoke, plus utilities and connection resolution.
Well‑Known Proxy
packages/inspector/src/oauth/wellknown-proxy.ts
Adds well-known endpoint proxy with per-upstream TTL cache, rewriting for protected-resource metadata, auth token pass-through, and cache invalidation.
Connection Wiring & Types
packages/inspector/src/connection.ts, packages/inspector/src/connection-registry.ts, packages/inspector/src/types/connection-types.ts
Adds oauthConfig and authProvider to ConnectOptions; ConnectionManager gains OAuth provider/state accessors; connection listings include oauth?: OAuthState; revokes tokens on disconnect.
Dashboard UI & Hook
packages/inspector/src/dashboard/react/hooks/useOAuth.ts, .../hooks/index.ts, .../InspectorDashboard.tsx, .../components/ConnectionBar.tsx, .../components/OAuthPanel.tsx, .../components/Toolbar.tsx
Adds useOAuth hook (status polling, configure, revoke) and re-exports; dashboards receive per-connection oauth state; ConnectionBar adds OAuth trigger and OAuthPanel; Toolbar shows OAuth badge.
Test Client & Types
packages/testing/src/server/test-client.ts, packages/testing/src/types.ts
Adds optional authProvider?: OAuthClientProvider to TestClientOptions and forwards it into HTTP transport creation.
Tests
packages/inspector/tests/*.test.ts (many files)
Adds extensive unit, integration, and acceptance tests covering provider, token-store, preset logic, callback handlers, well‑known proxy, revocation, dashboard hook helpers, connection integration, and acceptance criteria.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Dashboard as Dashboard UI
    participant Hook as useOAuth Hook
    participant API as Inspector API (/api/oauth)
    participant Provider as InspectorOAuthProvider
    participant AuthServer as OAuth Authorization Server
    participant Store as Token Store

    User->>Dashboard: Click "Configure OAuth"
    Dashboard->>Hook: configure(params)
    Hook->>API: POST /api/oauth/configure (connectionId)
    API->>Provider: initialize & build auth URL
    Provider->>AuthServer: Redirect to authorization endpoint
    AuthServer-->>User: User consents -> callback to /oauth/callback
    API->>Provider: exchange code for tokens
    Provider->>Store: save tokens & metadata
    API-->>Hook: return configured / authorizationUrl
    Hook->>Dashboard: poll GET /api/oauth/status -> show status
Loading
sequenceDiagram
    actor CLI
    participant Inspector as Inspector Startup
    participant Preset as Preset Config Resolution
    participant Provider as InspectorOAuthProvider
    participant Store as Token Store
    participant AuthServer as OAuth Server

    CLI->>Inspector: start with preset flags + --url
    Inspector->>Preset: resolvePresetConfig(flags)
    Preset->>Store: checkExistingTokens(serverUrl)
    alt tokens exist
        Preset->>Inspector: supply provider (non-interactive)
        Inspector->>CLI: startup completes (no interactive auth)
    else tokens missing
        Preset->>Provider: create preset provider
        Provider->>AuthServer: produce authorizationUrl
        CLI/User->>AuthServer: authorize / callback -> exchange -> persist tokens
        Inspector->>CLI: ready
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.02% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: OAuth MCP Server Connections (HTTP)' clearly and concisely describes the main change: adding OAuth authentication support for HTTP MCP server connections in the inspector.
Description check ✅ Passed The description thoroughly explains the OAuth implementation across three modes (Dashboard, CLI/Agent Preset, Dual Mode), lists key features, describes changes to 33 files, and documents test coverage with acceptance criteria.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/oauth-connections

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Contributor

claude bot commented Feb 1, 2026

Code Review: PR #137 - OAuth MCP Server Connections

📋 Overview

This is a substantial and well-architected OAuth integration for the MCP Inspector. The PR adds ~8,000 lines across 34 files, implementing comprehensive OAuth 2.0 support with PKCE, token management, dynamic client registration, and both dashboard and CLI modes.

✅ Strengths

1. Excellent Code Organization

  • Clean module separation under packages/inspector/src/oauth/
  • Clear separation of concerns: provider, token-store, callback-handler, preset-config, wellknown-proxy
  • Well-documented types with comprehensive JSDoc comments
  • Follows repository conventions (Zod v4, Express v5, TypeScript strict mode)

2. Security Best Practices

  • XDG-compliant token storage with 0o600 permissions (token-store.ts:134)
  • Atomic writes using temp file + rename pattern (token-store.ts:131-138)
  • SHA-256 URL hashing for filesystem-safe token file names (token-store.ts:55)
  • PKCE code verifier storage and verification
  • HTTP Basic auth for confidential clients (provider.ts:486-493)
  • Proper CORS headers on callback endpoints
  • HTML escaping in callback page rendering (callback-handler.ts:536-542)

3. RFC Compliance

  • RFC 6749: OAuth 2.0 Authorization Framework
  • RFC 7009: Token Revocation (provider.ts:401-456)
  • RFC 7591: Dynamic Client Registration
  • RFC 7636: PKCE extension
  • RFC 8414: Authorization Server Metadata
  • RFC 9728: Protected Resource Metadata

4. Comprehensive Test Coverage

  • 9 test files covering all OAuth modules
  • 40 new integration tests for acceptance criteria
  • 1,052 tests passing according to PR description
  • Acceptance tests cover all 10 stated criteria
  • Good use of mocking for external dependencies

5. Error Handling

  • Graceful degradation when auth server metadata unavailable
  • Proper error propagation with descriptive messages
  • Non-blocking scope discovery
  • Safe token revocation with fallback

⚠️ Issues & Concerns

🔴 Critical Issues

1. Race Condition in Token Storage (token-store.ts:113-139)

The save() method has a time-of-check-time-of-use (TOCTOU) race between load() and rename(). While atomic rename prevents corruption, concurrent updates could be silently overwritten.

Impact: In CLI mode with multiple inspector instances or rapid token refreshes, token updates could be lost.

Recommendation: Add file locking or include a version/timestamp field and verify it hasn't changed.

2. Unbounded Cache in Well-Known Proxy (wellknown-proxy.ts:74-76)

The proxy caches metadata indefinitely per upstream URL but never cleans up old entries. Only invalidates when upstreamUrl changes.

Impact: Memory leak in long-running sessions with server switching.

Recommendation: Implement LRU cache or periodic cleanup.

3. Missing Input Validation on OAuth Config (callback-handler.ts:254)

The /api/oauth/configure endpoint accepts OAuthClientConfig without schema validation. Only validates presence of clientId or enableDynamicRegistration. Doesn't validate scopes format, redirectUri format, or clientSecret requirements.

Impact: Invalid configs could cause runtime errors during auth flow.

Recommendation: Use Zod schema validation (repo uses Zod v4) to validate the entire config structure.

🟡 Major Issues

4. Implicit Any in Callback Handler (callback-handler.ts:239-242)

Type assertion without runtime validation. JSON.parse returns any, making the cast unsafe.

Recommendation: Use Zod schema validation per repo standards (AGENTS.md:54 forbids any).

5. Token Expiry Calculation (provider.ts:192-196)

Doesn't handle negative or zero expires_in explicitly. Sets expiresAt to undefined, but doesn't log or warn.

Recommendation: Add warning log when expires_in <= 0.

6. No Rate Limiting on Auth Endpoints

Callback handler exposes public HTTP endpoints without rate limiting: /oauth/callback, /api/oauth/configure, /api/oauth/status, /api/oauth/revoke.

Recommendation: Add rate limiting middleware, especially for /oauth/callback.

🟢 Minor Issues

  1. Inconsistent error handling in postRevocation (provider.ts:503-506)
  2. Hard-coded port 6274 (callback-handler.ts:286-287) - extract to constant
  3. Missing timeout in waitForAuthorization (provider.ts:310-317)
  4. Verify noUncheckedIndexedAccess compliance

🧪 Testing

Coverage ✅

  • Unit tests for all OAuth modules
  • Integration tests for end-to-end flows
  • Acceptance tests for all 10 criteria
  • Mock-based testing for external dependencies

Missing Test Cases

  1. Concurrent token updates (race condition)
  2. Token expiry edge cases (zero, negative, very large)
  3. Invalid OAuth server responses
  4. Network timeouts in metadata discovery
  5. File permission errors in token store
  6. Memory leak scenarios in well-known proxy cache

🔒 Security Assessment

✅ Secure

  • Token storage with restrictive permissions
  • PKCE implementation
  • HTML escaping
  • HTTP Basic auth
  • Server-side token revocation

⚠️ Needs Attention

  • Rate limiting
  • Input validation with Zod
  • CSRF protection for callback endpoint

📊 Performance

Concerns

  1. Synchronous file I/O in hot path (consider in-memory cache with TTL)
  2. Unbounded metadata cache
  3. No connection pooling for metadata fetches

🎯 Recommendations

Before Merge (High Priority)

  1. Fix TOCTOU race in token-store.ts
  2. Add Zod validation for OAuth config
  3. Add rate limiting to callback endpoints
  4. Add timeout to waitForAuthorization promise
  5. Run full test suite (pnpm build && pnpm test && pnpm lint && pnpm typecheck)

After Merge (Medium Priority)

  1. Add in-memory token cache to reduce disk I/O
  2. Implement LRU cache for metadata
  3. Add comprehensive error handling documentation
  4. Add integration tests for concurrent scenarios

🏆 Conclusion

This is a high-quality, production-ready OAuth implementation with excellent adherence to RFC standards and security best practices. The code is well-organized, thoroughly tested, and properly documented.

Overall Assessment: ✅ APPROVE with minor fixes

The identified issues are addressable and don't block merge, but addressing the critical issues (especially the TOCTOU race and input validation) before production deployment is strongly recommended.

The team has done an exceptional job handling the complexity of OAuth 2.0 while maintaining code quality and test coverage. The three-mode architecture (Dashboard, CLI, Dual) is well-designed and the implementation is clean.


Reviewed by: Claude Code Reviewer
Repository conventions: ✅ Followed (Zod v4, Express v5, strict TypeScript)
Test coverage: ✅ Comprehensive (1,052 tests passing)
Breaking changes: ❌ None (backwards compatible)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@packages/inspector/src/dashboard/react/components/OAuthPanel.tsx`:
- Around line 241-252: The function getStatusBadgeStyle currently returns a
value that TypeScript narrows to CSSProperties | undefined causing TS2322;
explicitly annotate its return type as React.CSSProperties in the signature
(function getStatusBadgeStyle(status: string): React.CSSProperties) so the
compiler knows it always returns a defined style, and ensure the referenced
style keys on oauthPanelStyles (statusAuthenticated, statusAuthenticating,
statusError, statusUnauthenticated) are typed as React.CSSProperties in the
oauthPanelStyles declaration or its type so all returned branches satisfy the
annotated return type.

In `@packages/inspector/src/dashboard/react/hooks/useOAuth.ts`:
- Around line 135-141: When connectionId is falsy the effect resets oauth state
but leaves previous error and loading flags intact; update the same useEffect
(the one that checks connectionId) to also clear the error and loading state by
calling setError(null) and setIsLoading(false) alongside setOauthState(null),
setIsConfigured(false), and setAuthorizationUrl(null) so stale errors or
spinners are removed when no connection is active.

In `@packages/inspector/src/oauth/callback-handler.ts`:
- Around line 536-542: The escapeHtml function currently replaces &, <, >, and "
but misses single quotes, which can enable attribute-injection risks; update the
escapeHtml function to also replace single quotes (e.g., add .replace(/'/g,
"&#39;") or equivalent) so escapeHtml consistently encodes '&', '<', '>', '"',
and '\''; ensure the replacement order remains correct and that the function
name escapeHtml is updated in-place with this additional .replace call.

In `@packages/inspector/src/oauth/preset-config.ts`:
- Line 16: The import line in preset-config.ts imports OAuthPresetConfig but
it's not used; update the type-only import to remove OAuthPresetConfig and keep
only OAuthClientConfig (i.e., change the import from "import type {
OAuthClientConfig, OAuthPresetConfig } from './types';" to import only
OAuthClientConfig) to resolve the ESLint unused-import error and preserve the
type usage for OAuthClientConfig.

In `@packages/inspector/src/oauth/provider.ts`:
- Around line 190-196: In saveTokens, replace the non-strict null check on
tokens.expires_in (currently using != null) with a strict comparison to handle
both null and undefined per ESLint eqeqeq; for example check tokens.expires_in
!== undefined && tokens.expires_in !== null or simply tokens.expires_in != null
alternative using optional chaining like (tokens.expires_in ?? null) and then
proceed to set this._expiresAt as before; update the conditional in the
saveTokens method accordingly to satisfy ESLint while preserving the existing
semantics.
- Around line 250-259: In invalidateCredentials when scope === "tokens" the
current save({ tokens: undefined, clientInformation }) doesn't clear tokens
because TokenStore.save uses a nullish-coalescing fallback; instead explicitly
remove stored tokens then re-save only the client info: call
this.tokenStore.delete(this.serverUrl) (to remove the token entry) and then
this.tokenStore.save(this.serverUrl, { clientInformation:
existing.clientInformation }) so clientInformation is preserved but tokens are
cleared; update the block handling scope "tokens" to use tokenStore.delete +
save as described.

In `@packages/inspector/src/oauth/token-store.ts`:
- Around line 117-128: The current save logic constructs a fallback tokens
object ({ access_token: "", token_type: "bearer" }) which can persist invalid
token data; update the save path that builds the merged PersistedTokenData to
only accept real tokens (e.g., prefer data.tokens, then existing?.tokens) and if
neither contains a non-empty access_token then either omit tokens from merged or
throw/return an error instead of inserting the empty stub; ensure any downstream
load/validation (referenced by tokens and PersistedTokenData) relies on a
non-empty access_token before persisting or explicitly marks the record as
incomplete so invalid empty tokens are not saved.

In `@packages/inspector/src/oauth/wellknown-proxy.ts`:
- Around line 108-131: The fetchUpstream function can hang if fetch never
returns; add an AbortController-based timeout inside fetchUpstream (use
AbortController and pass its signal to fetch), make the timeout value
configurable (e.g., via a constant or env var like FETCH_TIMEOUT_MS) and start a
setTimeout to call controller.abort() after the timeout; ensure you clearTimeout
on success/failure, handle the abort error path (treat as a timeout and return
null) and keep existing log messages, referencing fetchUpstream, authToken, and
the AbortController/signal usage so the request is cancelled instead of blocking
indefinitely.

In `@packages/inspector/tests/oauth-token-store.test.ts`:
- Line 6: The test titled something like "verifies restrictive permissions"
currently never checks the file mode; add an assertion that the token file's
mode is restrictive (e.g. check via await stat(filePath) or
fs.statSync(filePath) and assert (st.mode & 0o177) === 0 or an equivalent mask)
to ensure group/other bits are cleared, and guard this check behind a platform
guard (if (process.platform !== 'win32') { ... }) so it skips on Windows; locate
the assertion to run after the token file is created/closed in the same test
(and apply the same addition to the other similar test around lines ~195-209)
and reference mkdtemp/readFile/rm as the surrounding helpers to find the right
spot.
🧹 Nitpick comments (17)
packages/inspector/tests/oauth-integration-acceptance.test.ts (2)

22-76: Add file-level cleanup for global spies.
Several tests spy on global fetch; restoring mocks after each test prevents cross-test contamination.

🧹 Proposed test cleanup
 vi.mock("@mcp-apps-kit/testing", () => {
   const client = {
     listTools: vi.fn().mockResolvedValue([]),
@@
   };
   return {
     createTestClient: (...args: unknown[]) => {
       createTestClientSpy(...args);
       return Promise.resolve(client);
     },
   };
 });
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
 
 import { InspectorOAuthProvider } from "../src/oauth/provider";

1235-1331: Add disconnect cleanup for ConnectionManager-based tests.
Even with mocks, explicit disconnects prevent future open-handle leaks if connection logic changes.

🧹 Proposed cleanup
   it("should connect and work without any OAuth config (HTTP transport)", async () => {
     const manager = new ConnectionManager();
-    const result = await manager.connect({
-      transport: "http",
-      url: "http://localhost:3000/mcp",
-    });
-
-    expect(result.toolCount).toBe(0);
-    expect(manager.getOAuthProvider()).toBeNull();
-    expect(manager.getOAuthState()).toBeUndefined();
-
-    // createTestClient should NOT receive authProvider
-    expect(createTestClientSpy).toHaveBeenCalledWith(
-      { transport: "http", url: "http://localhost:3000/mcp" },
-      expect.not.objectContaining({ authProvider: expect.anything() })
-    );
+    try {
+      const result = await manager.connect({
+        transport: "http",
+        url: "http://localhost:3000/mcp",
+      });
+
+      expect(result.toolCount).toBe(0);
+      expect(manager.getOAuthProvider()).toBeNull();
+      expect(manager.getOAuthState()).toBeUndefined();
+
+      // createTestClient should NOT receive authProvider
+      expect(createTestClientSpy).toHaveBeenCalledWith(
+        { transport: "http", url: "http://localhost:3000/mcp" },
+        expect.not.objectContaining({ authProvider: expect.anything() })
+      );
+    } finally {
+      await manager.disconnect();
+    }
   });
@@
   it("should connect via stdio without OAuth (even if oauthConfig is mistakenly passed)", async () => {
     const manager = new ConnectionManager();
-    await manager.connect(
-      { transport: "stdio", command: "node", args: ["server.js"] },
-      {
-        oauthConfig: {
-          clientId: "should-be-ignored",
-          redirectUri: "http://127.0.0.1:6274/oauth/callback",
-        },
-      }
-    );
-
-    // OAuth should NOT be created for stdio
-    expect(manager.getOAuthProvider()).toBeNull();
-    expect(manager.getOAuthState()).toBeUndefined();
+    try {
+      await manager.connect(
+        { transport: "stdio", command: "node", args: ["server.js"] },
+        {
+          oauthConfig: {
+            clientId: "should-be-ignored",
+            redirectUri: "http://127.0.0.1:6274/oauth/callback",
+          },
+        }
+      );
+
+      // OAuth should NOT be created for stdio
+      expect(manager.getOAuthProvider()).toBeNull();
+      expect(manager.getOAuthState()).toBeUndefined();
+    } finally {
+      await manager.disconnect();
+    }
   });
As per coding guidelines: Clean up servers in tests to avoid resource leaks and hanging processes.
packages/inspector/tests/wellknown-proxy.test.ts (2)

325-349: Consider using fake timers instead of real delays for TTL expiry test.

The test relies on setTimeout with a real 60ms delay (line 341), which can cause flakiness in CI environments under load. Using Vitest's fake timers would make the test deterministic and faster.

♻️ Proposed refactor using fake timers
     it("should re-fetch after cache TTL expires", async () => {
+      vi.useFakeTimers();
       // Create proxy with very short TTL
       const shortTtlProxy = createWellKnownProxy({ cacheTtlMs: 50 });

       const fetchSpy = vi
         .spyOn(globalThis, "fetch")
         .mockResolvedValue(new Response(JSON.stringify(sampleResourceMetadata), { status: 200 }));

       const req = mockRequest("GET", "/.well-known/oauth-protected-resource");

       // First fetch
       const res1 = mockResponse();
       await shortTtlProxy.handleWellKnownRequest(req, res1, defaultCtx);
       expect(fetchSpy).toHaveBeenCalledTimes(1);

       // Wait for TTL to expire
-      await new Promise((resolve) => setTimeout(resolve, 60));
+      vi.advanceTimersByTime(60);

       // Should re-fetch
       const res2 = mockResponse();
       await shortTtlProxy.handleWellKnownRequest(req, res2, defaultCtx);
       expect(fetchSpy).toHaveBeenCalledTimes(2);

       shortTtlProxy.clearCache();
+      vi.useRealTimers();
     });

419-422: Type assertion on headers may be fragile.

The assertion fetchOptions.headers as Record<string, string> assumes the headers are a plain object. If the implementation uses Headers instance or array format, this would fail silently. Consider adding a runtime check or using a more defensive approach.

♻️ Proposed defensive check
       // Verify Authorization header was sent
       const fetchArgs = fetchSpy.mock.calls[0];
       const fetchOptions = fetchArgs[1] as RequestInit;
-      const headers = fetchOptions.headers as Record<string, string>;
-      expect(headers["Authorization"]).toBe("Bearer test-bearer-token");
+      const headers = fetchOptions.headers;
+      if (headers instanceof Headers) {
+        expect(headers.get("Authorization")).toBe("Bearer test-bearer-token");
+      } else if (Array.isArray(headers)) {
+        const authHeader = headers.find(([k]) => k === "Authorization");
+        expect(authHeader?.[1]).toBe("Bearer test-bearer-token");
+      } else {
+        expect((headers as Record<string, string>)["Authorization"]).toBe("Bearer test-bearer-token");
+      }
packages/inspector/src/dashboard/react/components/Toolbar.tsx (1)

81-110: Nested ternaries are readable but could be simplified with a lookup object.

The triple-nested ternary expressions for icon, label, and badgeStyle (lines 82-98) work correctly but could be more maintainable with a status-to-config mapping.

♻️ Optional refactor using a lookup object
+const oauthStatusConfig: Record<OAuthStatus, { icon: string; label: string; style: React.CSSProperties }> = {
+  authenticated: { icon: "🔒", label: "Auth", style: oauthBadgeStyles.authenticated },
+  authenticating: { icon: "🔄", label: "Auth...", style: oauthBadgeStyles.authenticating },
+  error: { icon: "🔓", label: "Auth Err", style: oauthBadgeStyles.error },
+  unauthenticated: { icon: "🔓", label: "No Auth", style: oauthBadgeStyles.unauthenticated },
+};
+
 /** OAuth status indicator icon + label */
 function OAuthBadge({ status }: { status: OAuthStatus }): React.ReactElement {
-  const icon = status === "authenticated" ? "🔒" : status === "authenticating" ? "🔄" : "🔓";
-  const label =
-    status === "authenticated"
-      ? "Auth"
-      : status === "authenticating"
-        ? "Auth..."
-        : status === "error"
-          ? "Auth Err"
-          : "No Auth";
-  const badgeStyle =
-    status === "authenticated"
-      ? oauthBadgeStyles.authenticated
-      : status === "authenticating"
-        ? oauthBadgeStyles.authenticating
-        : status === "error"
-          ? oauthBadgeStyles.error
-          : oauthBadgeStyles.unauthenticated;
+  const config = oauthStatusConfig[status];

   return (
     <div
-      style={{ ...oauthBadgeStyles.badge, ...badgeStyle }}
+      style={{ ...oauthBadgeStyles.badge, ...config.style }}
       title={`OAuth: ${status}`}
       data-testid="oauth-status-indicator"
     >
-      <span>{icon}</span>
-      <span>{label}</span>
+      <span>{config.icon}</span>
+      <span>{config.label}</span>
     </div>
   );
 }
packages/inspector/src/connection.ts (1)

236-270: Refactor non-null assertions to satisfy ESLint rules.

Lines 244 and 264 use forbidden non-null assertions (authProvider!). Since authProvider is defined at the start of this block and assigned before these callbacks are registered, you can safely capture it in a local constant before registering the callback.

♻️ Proposed fix to eliminate non-null assertions
     // Set up OAuth provider for HTTP connections.
     // Prefer pre-built provider (e.g., from CLI preset) over oauthConfig.
     let authProvider: InspectorOAuthProvider | undefined;
     if (params.transport === "http" && prebuiltAuthProvider) {
       authProvider = prebuiltAuthProvider;
+      const provider = authProvider;

       // Track OAuth status changes
-      authProvider.onStatusChange = () => {
+      provider.onStatusChange = () => {
         if (this.debug) {
-          const state = authProvider!.getOAuthState();
+          const state = provider.getOAuthState();
           console.log(`[inspector] OAuth status changed: ${state.status}`);
         }
       };

       this.oauthProvider = authProvider;
     } else if (params.transport === "http" && oauthConfig) {
       // Port is set later via setInspectorUrl, default to 6274
       const port = this.inspectorUrl ? new URL(this.inspectorUrl).port : "6274";

       authProvider = new InspectorOAuthProvider({
         serverUrl: params.url,
         config: oauthConfig,
         callbackPort: parseInt(port, 10),
         debug: this.debug,
       });
+      const provider = authProvider;

       // Track OAuth status changes
-      authProvider.onStatusChange = () => {
+      provider.onStatusChange = () => {
         if (this.debug) {
-          const state = authProvider!.getOAuthState();
+          const state = provider.getOAuthState();
           console.log(`[inspector] OAuth status changed: ${state.status}`);
         }
       };

       this.oauthProvider = authProvider;
     }
packages/inspector/tests/oauth-preset-config.test.ts (1)

103-109: Consider clarifying the expected trimming behavior.

The test at line 108 expects "read write" (three spaces) due to .replace(/,/g, " ").trim() converting read , write to read write. This seems intentional based on the implementation, but the test name "should trim whitespace from scopes" may be slightly misleading since internal whitespace is preserved. The implementation trims only leading/trailing whitespace, not internal gaps.

Consider either:

  1. Renaming the test to clarify it only trims outer whitespace
  2. Or normalizing multiple spaces to single spaces in the implementation
-    it("should trim whitespace from scopes", () => {
+    it("should trim leading and trailing whitespace from scopes", () => {
packages/inspector/src/dashboard/react/components/OAuthPanel.tsx (2)

258-269: Consider updating RefObject types for React 19 compatibility.

In React 19, RefObject<T> now includes null in the type by default. The current type RefObject<HTMLElement> may cause type issues. While the runtime code correctly handles null (Line 345), the type definition could be more explicit.

♻️ Suggested type update
 export interface OAuthPanelProps {
   /** Whether the panel is visible */
   isOpen: boolean;
   /** Anchor element ref for positioning */
-  anchorRef: React.RefObject<HTMLElement>;
+  anchorRef: React.RefObject<HTMLElement | null>;
   /** Container element ref for boundary calculations */
-  containerRef: React.RefObject<HTMLElement>;
+  containerRef: React.RefObject<HTMLElement | null>;
   /** Close the panel */
   onClose: () => void;
   /** OAuth hook result */
   oauth: UseOAuthResult;
 }

305-335: Consider extracting scope parsing logic to reduce duplication.

The scope string parsing logic (scopes.split(/\s+/).map(s => s.trim()).filter(Boolean)) is repeated in three places: selectedScopesSet (Line 299-302), toggleScope (Line 307-311), and addCustomScope (Line 326-330). This creates maintenance overhead.

♻️ Suggested refactor
+  // Helper to parse scopes string into a Set
+  const parseScopesString = useCallback((scopeStr: string): Set<string> => {
+    return new Set(
+      scopeStr
+        .split(/\s+/)
+        .map((s) => s.trim())
+        .filter(Boolean)
+    );
+  }, []);
+
   // Parse current scopes string into a Set for quick lookups
-  const selectedScopesSet = new Set(
-    scopes
-      .split(/\s+/)
-      .map((s) => s.trim())
-      .filter(Boolean)
-  );
+  const selectedScopesSet = useMemo(() => parseScopesString(scopes), [scopes, parseScopesString]);

   const toggleScope = useCallback(
     (scope: string) => {
-      const current = new Set(
-        scopes
-          .split(/\s+/)
-          .map((s) => s.trim())
-          .filter(Boolean)
-      );
+      const current = parseScopesString(scopes);
       if (current.has(scope)) {
         current.delete(scope);
       } else {
         current.add(scope);
       }
       setScopes(Array.from(current).join(" "));
     },
-    [scopes]
+    [scopes, parseScopesString]
   );
packages/inspector/src/oauth/callback-handler.ts (2)

29-35: Consider adding type guard for chunk casting.

The chunk as Buffer cast assumes all chunks are Buffers, which is true for HTTP requests but not enforced by the type system. While this works in practice, a defensive check could prevent subtle issues.

♻️ Optional defensive check
 async function readBody(req: http.IncomingMessage): Promise<string> {
   const chunks: Buffer[] = [];
   for await (const chunk of req) {
-    chunks.push(chunk as Buffer);
+    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
   }
   return Buffer.concat(chunks).toString("utf-8");
 }

238-252: Consider using Zod for request body validation.

The JSON body is parsed with a type assertion and minimal validation. Per coding guidelines, Zod v4 should be used for schema validation. This would provide better error messages and type safety.

♻️ Suggested Zod schema
import { z } from "zod";

const OAuthConfigureBodySchema = z.object({
  connectionId: z.string().optional(),
  config: z.object({
    clientId: z.string().optional(),
    clientSecret: z.string().optional(),
    redirectUri: z.string().optional(),
    scopes: z.string().optional(),
    enableDynamicRegistration: z.boolean().optional(),
    clientName: z.string().optional(),
  }).refine(
    (c) => c.clientId || c.enableDynamicRegistration,
    { message: "Config must include clientId or enableDynamicRegistration" }
  ),
});

// Then in handler:
const parseResult = OAuthConfigureBodySchema.safeParse(JSON.parse(await readBody(req)));
if (!parseResult.success) {
  jsonResponse(res, 400, { error: parseResult.error.message });
  return true;
}
const body = parseResult.data;

As per coding guidelines: "Always use Zod v4 for schema/validation".

packages/inspector/src/oauth/token-store.ts (2)

71-75: Potential race condition in ensureDir.

Using existsSync followed by async mkdir creates a TOCTOU (time-of-check to time-of-use) race condition. Another process could create the directory between the check and the mkdir call. While mkdir with recursive: true handles existing directories gracefully, the sync/async mix is unnecessary.

♻️ Suggested fix
   private async ensureDir(): Promise<void> {
-    if (!existsSync(this.storePath)) {
-      await mkdir(this.storePath, { recursive: true, mode: 0o700 });
-    }
+    await mkdir(this.storePath, { recursive: true, mode: 0o700 });
   }

The recursive: true option already handles the case where the directory exists, making the existsSync check redundant.


136-138: Move rename import to top-level.

The dynamic import of rename is unnecessary since it's a stable API from node:fs/promises. Moving it to the top-level import improves clarity and avoids repeated import overhead.

♻️ Suggested fix
-import { readFile, writeFile, mkdir, unlink, readdir } from "node:fs/promises";
+import { readFile, writeFile, mkdir, unlink, readdir, rename } from "node:fs/promises";

Then at line 136-138:

     // Atomic rename
-    const { rename } = await import("node:fs/promises");
     await rename(tmpPath, filePath);
packages/inspector/src/oauth/preset-config.ts (2)

139-194: Consider using Zod for config file validation.

The manual JSON parsing and type checking is verbose and doesn't leverage TypeScript's type system fully. Per coding guidelines, Zod v4 should be used for schema validation.

♻️ Suggested Zod schema
import { z } from "zod";

const PresetConfigFileSchema = z.object({
  clientId: z.string().optional(),
  clientSecret: z.string().optional(),
  scopes: z.string().optional(),
  autoRegister: z.boolean().optional(),
  clientName: z.string().optional(),
}).refine(
  (data) => data.clientId || data.autoRegister,
  { message: 'Config must include "clientId" or "autoRegister"' }
);

// Usage:
const parseResult = PresetConfigFileSchema.safeParse(JSON.parse(content));
if (!parseResult.success) {
  throw new Error(`Invalid OAuth config: ${parseResult.error.message}`);
}
const file = parseResult.data;

As per coding guidelines: "Always use Zod v4 for schema/validation".


270-284: Monkey-patching approach works but consider a cleaner alternative.

The method override pattern works but creates tight coupling. A cleaner approach might be to add a nonInteractive option to InspectorOAuthProvider that controls this behavior internally. However, this is acceptable for the current use case.

packages/inspector/src/oauth/provider.ts (2)

310-317: waitForAuthorization Promise may never resolve.

If authorization is abandoned (user closes browser, network error, etc.), the Promise created by waitForAuthorization will never resolve and the stored resolve function creates a closure that can't be garbage collected. Consider adding a timeout or cleanup mechanism.

♻️ Optional timeout implementation
-  waitForAuthorization(): Promise<void> {
+  waitForAuthorization(timeoutMs = 300000): Promise<void> {
     if (!this._pendingAuthUrl) {
       return Promise.resolve();
     }
     return new Promise<void>((resolve, reject) => {
       this._pendingAuthResolve = resolve;
+      const timeoutId = setTimeout(() => {
+        this._pendingAuthResolve = null;
+        reject(new Error("Authorization timed out"));
+      }, timeoutMs);
+      // Store cleanup for onAuthorizationComplete
+      const originalResolve = resolve;
+      this._pendingAuthResolve = () => {
+        clearTimeout(timeoutId);
+        originalResolve();
+      };
     });
   }

160-165: Consider importing OAuthClientInformationFull at the top level.

The inline import path for the type assertion is verbose. Importing the type at the top of the file would improve readability.

♻️ Suggested change

At top of file:

+import type { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";

Then at line 161-163:

     await this.tokenStore.save(this.serverUrl, {
-      clientInformation:
-        clientInformation as import("@modelcontextprotocol/sdk/shared/auth.js").OAuthClientInformationFull,
+      clientInformation: clientInformation as OAuthClientInformationFull,
     });

Comment on lines +241 to +252
function getStatusBadgeStyle(status: string): React.CSSProperties {
switch (status) {
case "authenticated":
return oauthPanelStyles.statusAuthenticated;
case "authenticating":
return oauthPanelStyles.statusAuthenticating;
case "error":
return oauthPanelStyles.statusError;
default:
return oauthPanelStyles.statusUnauthenticated;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix return type to resolve TypeScript error TS2322.

The pipeline failure indicates that getStatusBadgeStyle returns CSSProperties | undefined because TypeScript cannot verify that all code paths return a defined value from the styles object. While the switch has a default case, the function's return type should be explicitly annotated.

🛠️ Proposed fix
-function getStatusBadgeStyle(status: string): React.CSSProperties {
+function getStatusBadgeStyle(status: string): React.CSSProperties {
   switch (status) {
     case "authenticated":
-      return oauthPanelStyles.statusAuthenticated;
+      return oauthPanelStyles.statusAuthenticated!;
     case "authenticating":
-      return oauthPanelStyles.statusAuthenticating;
+      return oauthPanelStyles.statusAuthenticating!;
     case "error":
-      return oauthPanelStyles.statusError;
+      return oauthPanelStyles.statusError!;
     default:
-      return oauthPanelStyles.statusUnauthenticated;
+      return oauthPanelStyles.statusUnauthenticated!;
   }
 }

Alternatively, define the styles object with a more precise type or use type assertions at the call site.

🧰 Tools
🪛 GitHub Actions: PR Check

[error] 244-244: TypeScript error TS2322: Type 'CSSProperties | undefined' is not assignable to type 'CSSProperties'.

🤖 Prompt for AI Agents
In `@packages/inspector/src/dashboard/react/components/OAuthPanel.tsx` around
lines 241 - 252, The function getStatusBadgeStyle currently returns a value that
TypeScript narrows to CSSProperties | undefined causing TS2322; explicitly
annotate its return type as React.CSSProperties in the signature (function
getStatusBadgeStyle(status: string): React.CSSProperties) so the compiler knows
it always returns a defined style, and ensure the referenced style keys on
oauthPanelStyles (statusAuthenticated, statusAuthenticating, statusError,
statusUnauthenticated) are typed as React.CSSProperties in the oauthPanelStyles
declaration or its type so all returned branches satisfy the annotated return
type.

*/

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, readdir, readFile } from "node:fs/promises";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Assert the restrictive file mode you’re describing.

The test name says it verifies restrictive permissions but it never checks the mode, so a chmod regression would still pass. Add a mode assertion (guarded on non-Windows if needed).

🛠️ Proposed fix
-import { mkdtemp, rm, readdir, readFile } from "node:fs/promises";
+import { mkdtemp, rm, readdir, readFile, stat } from "node:fs/promises";
@@
       const files = await readdir(tempDir);
       expect(files.length).toBeGreaterThan(0);
 
+      if (process.platform !== "win32") {
+        const { mode } = await stat(join(tempDir, files[0]!));
+        expect(mode & 0o777).toBe(0o600);
+      }
+
       // Verify file content is valid JSON
       const content = await readFile(join(tempDir, files[0]!), "utf-8");

Also applies to: 195-209

🤖 Prompt for AI Agents
In `@packages/inspector/tests/oauth-token-store.test.ts` at line 6, The test
titled something like "verifies restrictive permissions" currently never checks
the file mode; add an assertion that the token file's mode is restrictive (e.g.
check via await stat(filePath) or fs.statSync(filePath) and assert (st.mode &
0o177) === 0 or an equivalent mask) to ensure group/other bits are cleared, and
guard this check behind a platform guard (if (process.platform !== 'win32') {
... }) so it skips on Windows; locate the assertion to run after the token file
is created/closed in the same test (and apply the same addition to the other
similar test around lines ~195-209) and reference mkdtemp/readFile/rm as the
surrounding helpers to find the right spot.

@claude
Copy link
Contributor

claude bot commented Feb 1, 2026

Code Review: OAuth MCP Server Connections (HTTP)

Summary

This is an excellent, production-ready implementation of OAuth 2.1 + PKCE support for the MCP Inspector. The code demonstrates strong adherence to security best practices, clean architecture, and comprehensive testing. The PR adds 7,997 lines (mostly well-structured code and tests) across 34 files.

✅ Strengths

1. Outstanding Security Practices

  • Token Storage: XDG-compliant paths with proper file permissions (0o600), atomic writes via temp-file-rename pattern to prevent corruption
  • PKCE Implementation: Proper code verifier storage and exchange per OAuth 2.1 best practices
  • No Hardcoded Secrets: All credentials come from user config or secure storage
  • Proper HTTP Basic Auth: Correct Base64 encoding for client_secret_basic authentication (provider.ts:496-500)
  • HTML Escaping: Proper XSS prevention in callback page rendering (callback-handler.ts:536-543)
  • Token Revocation: RFC 7009 compliant server-side revocation before local cleanup (provider.ts:411-466)

2. Clean Architecture

  • Separation of Concerns: Well-organized modules (provider, token-store, callback-handler, wellknown-proxy, preset-config)
  • Interface Compliance: Correctly implements MCP SDK's OAuthClientProvider interface
  • Three-Mode Support: Dashboard (Mode A), CLI preset (Mode B), and dual-mode mirroring (Mode C) with clear separation
  • Type Safety: Strong TypeScript usage with proper Zod v4 validation patterns where needed

3. Excellent Test Coverage

  • 1,052 tests passing with 40 new integration tests covering all 10 acceptance criteria
  • 9 test files organized by component (provider, token-store, callback-handler, preset-config, wellknown-proxy, dashboard, revocation, connection, integration-acceptance)
  • Comprehensive Edge Cases: Token expiry, refresh failures, revocation endpoint unavailability, cache invalidation, etc.
  • Mocking Strategy: Proper SDK mocking without test pollution

4. Production-Ready Features

  • Auto-refresh Support: SDK handles refresh, provider persists updated tokens (provider.ts:190-201)
  • Dynamic Client Registration: RFC 7591 support with opt-in flag (provider.ts:149-165)
  • Metadata Discovery: RFC 8414 auth server metadata with caching (wellknown-proxy.ts:69-306)
  • Timeout Handling: Authorization wait timeout (5 minutes) prevents hanging (provider.ts:317-326)
  • Cache Management: Well-known proxy has configurable TTL (default 5 min) and proper invalidation

🔍 Areas for Improvement

Minor Issues

  1. Token Store Error Handling (token-store.ts:87-101)

    • Currently swallows all errors when loading tokens
    • Consider logging corrupted files or permission errors for debugging
    } catch (err) {
      // Silent failure - consider logging in debug mode
      return undefined;
    }
  2. Console Logging (provider.ts:530, wellknown-proxy.ts:81)

    • Uses console.log even when debug is enabled
    • Consider using a proper logger or at least console.debug
    • ESLint already flags this with no-console rule
  3. Type Assertion in Provider (provider.ts:162)

    • Type cast as OAuthClientInformationFull could be unsafe
    • Consider runtime validation or narrowing
    await this.tokenStore.save(this.serverUrl, {
      clientInformation: clientInformation as OAuthClientInformationFull,  // ⚠️ unchecked cast
    });
  4. Race Condition Potential (provider.ts:316-326)

    • waitForAuthorization() sets up a timeout and a resolve function
    • If called multiple times concurrently, the second call could overwrite _pendingAuthResolve
    • Consider rejecting or queueing subsequent calls
  5. HTTP Error Handling (wellknown-proxy.ts:122-124)

    • Returns null for all fetch failures, making debugging harder
    • Consider differentiating between network errors, 404s, and 5xx errors

Documentation

  1. Missing JSDoc for Public Methods

    • discoverSupportedScopes() (provider.ts:377) - well documented
    • revokeTokens() (provider.ts:411) - well documented
    • But some helper methods lack context
  2. CLI Flag Documentation

    • The preset-config module has excellent comments
    • Consider adding examples to the main README for the three modes

Performance

  1. Unnecessary Spreads (provider.ts:385)

    this._supportedScopes = [...metadata.scopes_supported];
    • Could directly assign since arrays are copied by reference anyway
    • Minor optimization opportunity
  2. Token Store Merge Semantics (token-store.ts:114-123)

    • The merge logic could be clearer about precedence
    • New data always overrides existing via nullish coalescing, but this prevents clearing fields

🛡️ Security Review

No Major Vulnerabilities Found ✅

  • No SQL injection vectors (no database)
  • No command injection (only file I/O with hashed filenames)
  • No XSS vulnerabilities (proper HTML escaping)
  • No CSRF concerns (CORS headers properly set)
  • No insecure randomness (SDK handles PKCE generation)
  • No sensitive data in logs (only debug mode shows URLs, no tokens)
  • File permissions correctly restricted (0o600 for tokens, 0o700 for directory)

Minor Security Considerations

  1. CORS Allow-All (callback-handler.ts:42)

    res.setHeader("Access-Control-Allow-Origin", "*");
    • Acceptable for localhost inspector, but consider documenting this decision
    • Not a vulnerability for local-only tools
  2. No Rate Limiting

    • OAuth callback endpoint has no rate limiting
    • Low risk for local inspector, but could be abused if exposed
    • Consider adding basic rate limiting if inspector is ever networked

📊 Code Quality Metrics

  • Lines Changed: +6,654 additions, -6 deletions (net +6,648)
  • Files Changed: 34 files (13 new modules, 21 modified)
  • Test Coverage: 40 new integration tests, 9 test files
  • Adherence to Repo Standards: ✅ Follows AGENTS.md guidelines
    • Uses Zod v4 ✅
    • No any types ✅
    • Proper TypeScript strictness ✅
    • Tests in package-level tests/ directory ✅
    • Export contracts through src/index.ts

🎯 Recommendations

Before Merging

  1. Address console.log usage - Replace with proper logger or console.debug
  2. Add runtime validation for type assertions (provider.ts:162)
  3. Document CORS decision in callback-handler.ts

Post-Merge Enhancements

  1. Consider adding telemetry for OAuth flow success/failure rates
  2. Add retry logic for transient network failures in wellknown-proxy
  3. Implement token expiry warnings in dashboard UI before tokens expire
  4. Consider token encryption at rest for sensitive environments

📝 Final Verdict

APPROVED ✅ - This PR is ready to merge with minor suggestions.

The implementation is exceptionally well-crafted, with strong security practices, comprehensive testing, and clean architecture. The three-mode support (Dashboard, CLI, Dual) is elegant and well-separated. The test coverage is thorough, covering edge cases and integration scenarios.

The minor issues noted above are suggestions for improvement rather than blockers. Great work!


Testing Checklist

  • All 1,052 tests passing
  • 40 new integration tests for acceptance criteria
  • Security review completed
  • Code quality standards met
  • No regressions to existing functionality

Reviewed by: Claude Code (Automated Code Review)
Complexity Level: High (OAuth 2.1, PKCE, RFC 7591, RFC 7009, RFC 8414, RFC 9728)
Risk Level: Low (well-tested, secure implementation)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@packages/inspector/src/oauth/provider.ts`:
- Around line 505-516: The revocation request in postRevocation uses fetch
without a timeout and can hang; update postRevocation to use an AbortController:
create an AbortController, start a timer (matching the project's standard
timeout used in wellknown-proxy.ts or a sensible millis constant) that calls
controller.abort(), pass controller.signal into fetch(endpointUrl, { method:
"POST", headers, body: params.toString(), signal }), clear the timer after fetch
completes, and handle AbortError by throwing a clear timeout-specific error
before continuing to the existing response.ok handling (use the existing
endpointUrl/headers/params names and the postRevocation function to locate and
change the code).

In `@packages/inspector/tests/oauth-integration-acceptance.test.ts`:
- Around line 700-703: Test assertion is brittle because it compares
secondState.expiresAt! to Date.now() + 3_500_000 with little slack; change the
assertion to allow a safety margin (e.g. compute const now = Date.now() and
assert expect(secondState.expiresAt! - now).toBeGreaterThan(3_500_000 - 60_000)
or similar), or assert against the firstExpiry (e.g.
expect(secondState.expiresAt!).toBeGreaterThan(firstExpiry + 3_400_000)) so the
check on provider.getOAuthState().expiresAt is lenient and avoids flakes.
- Around line 591-604: The permission assertion test for TokenStore in the
"should write files with 0o600 permissions (owner-only read/write)" case will
fail on Windows because Windows ignores Unix permission bits; update the test
declaration to skip on Windows using Vitest's conditional skip (e.g., replace
the current it(...) with it.skipIf(process.platform === "win32")(...)) so the
body (creating TokenStore, save(), reading directory, stat(), and checking
perms) only runs on non-Windows platforms.
🧹 Nitpick comments (4)
packages/inspector/src/oauth/wellknown-proxy.ts (1)

127-127: Unsafe type assertion on JSON response.

The as T cast trusts the upstream response structure without validation. If the upstream returns malformed JSON or an unexpected schema, this could lead to runtime errors downstream.

For a proxy layer this is typically acceptable (garbage-in/garbage-out), but consider adding a defensive check or documenting that callers must handle potentially malformed metadata.

packages/inspector/src/connection.ts (1)

251-260: Port parsing may fail silently for URLs without explicit port.

Line 253 extracts the port from inspectorUrl but uses an empty string fallback for new URL().port when no port is specified (e.g., http://localhost/path). The parseInt("", 10) returns NaN, which then falls through to "6274" default.

However, if inspectorUrl is set to something like http://localhost (no port), new URL(inspectorUrl).port returns "", so parseInt("", 10) yields NaN, and the ternary at line 253 uses "6274". This logic is actually correct but could be clearer:

const port = this.inspectorUrl 
  ? parseInt(new URL(this.inspectorUrl).port, 10) || 6274 
  : 6274;

Current implementation works but the indirection through string "6274" then parsing back to int at line 258 is slightly convoluted.

packages/inspector/src/oauth/callback-handler.ts (2)

40-44: CORS headers use wildcard origin.

Access-Control-Allow-Origin: * (line 41) allows any origin to call these endpoints. For OAuth-related APIs, this is generally acceptable since:

  1. The callback endpoint is browser-redirect-based
  2. The API endpoints require connection context
  3. The inspector is a development tool, not production

However, if the inspector is ever exposed beyond localhost, consider restricting CORS to known origins.


239-247: JSON parse error handling could be more specific.

The bare catch at lines 244-246 treats all errors as "Invalid JSON body", which is correct for JSON.parse failures. However, TypeScript's strict mode prefers typed catch variables. Consider:

} catch (e: unknown) {
  jsonResponse(res, 400, { error: "Invalid JSON body" });
  return true;
}

This is a minor style nit given the context.

Comment on lines +505 to +516
const response = await fetch(endpointUrl, {
method: "POST",
headers,
body: params.toString(),
});

// RFC 7009: The server responds with HTTP 200 for both successful
// and invalid token revocations (the client shouldn't need to know).
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`Revocation endpoint returned ${response.status}: ${body}`);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add timeout to revocation fetch to avoid hanging requests.

The postRevocation method uses fetch without an AbortController timeout. If the revocation endpoint is slow or unresponsive, this could block indefinitely. This is the same pattern addressed in wellknown-proxy.ts.

⏱️ Proposed timeout hardening
   private async postRevocation(
     endpointUrl: string,
     token: string,
     tokenTypeHint: string,
     clientInfo?: OAuthClientInformationMixed
   ): Promise<void> {
     const params = new URLSearchParams({
       token,
       token_type_hint: tokenTypeHint,
     });
     // ...headers setup...

+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 10_000);
+
-    const response = await fetch(endpointUrl, {
+    const response = await fetch(endpointUrl, {
       method: "POST",
       headers,
       body: params.toString(),
+      signal: controller.signal,
     });
+
+    clearTimeout(timeoutId);
🤖 Prompt for AI Agents
In `@packages/inspector/src/oauth/provider.ts` around lines 505 - 516, The
revocation request in postRevocation uses fetch without a timeout and can hang;
update postRevocation to use an AbortController: create an AbortController,
start a timer (matching the project's standard timeout used in
wellknown-proxy.ts or a sensible millis constant) that calls controller.abort(),
pass controller.signal into fetch(endpointUrl, { method: "POST", headers, body:
params.toString(), signal }), clear the timer after fetch completes, and handle
AbortError by throwing a clear timeout-specific error before continuing to the
existing response.ok handling (use the existing endpointUrl/headers/params names
and the postRevocation function to locate and change the code).

Comment on lines +591 to +604
it("should write files with 0o600 permissions (owner-only read/write)", async () => {
const store = new TokenStore(tempDir);

await store.save("http://localhost:3000/mcp", {
tokens: { access_token: "secret-token", token_type: "bearer" } as OAuthTokens,
});

const files = (await readdir(tempDir)).filter((f) => f.endsWith(".json"));
expect(files).toHaveLength(1);

const fileStat = await stat(join(tempDir, files[0]!));
const perms = fileStat.mode & 0o777;
expect(perms).toBe(0o600);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there are other platform-specific tests or skip patterns in the repo
rg -n "skipIf.*win32|platform.*win|skip\.if|\.skip\(|process\.platform" packages/inspector/tests/ -A 2

Repository: AndurilCode/mcp-apps-kit

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Also search more broadly for Vitest skip patterns
rg -n "test\.skip|it\.skip|describe\.skip|\.skip\(" packages/inspector/tests/ -A 1 | head -50

Repository: AndurilCode/mcp-apps-kit

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Verify the exact file and lines exist
if [ -f "packages/inspector/tests/oauth-integration-acceptance.test.ts" ]; then
  echo "File exists. Checking lines 591-604:"
  sed -n '591,604p' packages/inspector/tests/oauth-integration-acceptance.test.ts
else
  echo "File not found"
fi

Repository: AndurilCode/mcp-apps-kit

Length of output: 634


🏁 Script executed:

#!/bin/bash
# Check Vitest configuration and skip patterns used in the project
rg -n "import.*vitest|from.*vitest" packages/inspector/tests/ -A 1 | head -20

Repository: AndurilCode/mcp-apps-kit

Length of output: 1703


🏁 Script executed:

#!/bin/bash
# Search for any process.platform usage in tests
rg -n "process\.platform" packages/inspector/tests/

Repository: AndurilCode/mcp-apps-kit

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Search across the entire repo for skipIf or similar patterns
rg -n "skipIf|skip\.if|test\.skip|it\.skip" --type ts --type tsx | head -20

Repository: AndurilCode/mcp-apps-kit

Length of output: 95


🏁 Script executed:

#!/bin/bash
# Check vitest config to see if there are any platform-specific settings
find . -name "vitest.config.*" -o -name "vite.config.*" | xargs cat 2>/dev/null | head -50

Repository: AndurilCode/mcp-apps-kit

Length of output: 1680


🏁 Script executed:

#!/bin/bash
# Look at the full test file to understand its context better
wc -l packages/inspector/tests/oauth-integration-acceptance.test.ts

Repository: AndurilCode/mcp-apps-kit

Length of output: 134


🏁 Script executed:

#!/bin/bash
# Check the setup.ts file to see if there are any custom skip helpers
cat packages/inspector/tests/setup.ts

Repository: AndurilCode/mcp-apps-kit

Length of output: 133


🏁 Script executed:

#!/bin/bash
# Search for any custom test utilities or helpers
find packages/inspector/tests -name "*.ts" -not -name "*.test.ts" | head -10

Repository: AndurilCode/mcp-apps-kit

Length of output: 106


🏁 Script executed:

#!/bin/bash
# Check if there's a test utilities file that might define skipIf
rg -n "skipIf|export.*skip" packages/inspector/tests/ -B 2 -A 5

Repository: AndurilCode/mcp-apps-kit

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Look for the actual Vitest version in package.json
rg -n "vitest" . --type json | grep -i version

Repository: AndurilCode/mcp-apps-kit

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Check the test utilities file
cat packages/inspector/tests/test-utils.ts

Repository: AndurilCode/mcp-apps-kit

Length of output: 2322


🏁 Script executed:

#!/bin/bash
# Look for package.json in inspector package
cat packages/inspector/package.json | grep -A 5 -B 5 "vitest"

Repository: AndurilCode/mcp-apps-kit

Length of output: 386


🏁 Script executed:

#!/bin/bash
# Check root package.json for vitest version
cat package.json | grep -A 2 -B 2 "vitest"

Repository: AndurilCode/mcp-apps-kit

Length of output: 373


🏁 Script executed:

#!/bin/bash
# Look at the beginning of the oauth-integration test to understand its structure
head -30 packages/inspector/tests/oauth-integration-acceptance.test.ts

Repository: AndurilCode/mcp-apps-kit

Length of output: 1484


🏁 Script executed:

#!/bin/bash
# Search more broadly for any skip patterns in all test files
rg -n "\.skip\(|skip(" packages/inspector/tests/ --type ts

Repository: AndurilCode/mcp-apps-kit

Length of output: 146


🏁 Script executed:

#!/bin/bash
# Look at context around the permission test and related tests
sed -n '570,620p' packages/inspector/tests/oauth-integration-acceptance.test.ts

Repository: AndurilCode/mcp-apps-kit

Length of output: 2031


🏁 Script executed:

#!/bin/bash
# Check if there are Windows-specific concerns in the codebase or if TokenStore handles platform differences
rg -n "win32|platform|windows|POSIX" packages/inspector/src/ | head -20

Repository: AndurilCode/mcp-apps-kit

Length of output: 2150


🏁 Script executed:

#!/bin/bash
# Look at the TokenStore implementation to understand file permission handling
find packages/inspector/src -name "*token*" -o -name "*store*" | head -5

Repository: AndurilCode/mcp-apps-kit

Length of output: 159


🏁 Script executed:

#!/bin/bash
# Look at the TokenStore implementation
cat packages/inspector/src/oauth/token-store.ts

Repository: AndurilCode/mcp-apps-kit

Length of output: 6299


🏁 Script executed:

#!/bin/bash
# Check if there's any platform-specific logic in the save method or file operations
rg -n "chmod|mode|0o600|permissions" packages/inspector/src/oauth/

Repository: AndurilCode/mcp-apps-kit

Length of output: 1486


Skip permission test on Windows platforms.

The permission check at lines 601-603 reads fileStat.mode & 0o777 to verify 0o600 permissions. On Windows, the mode parameter in writeFile() is ignored—Windows does not support Unix file permission bits. This test will fail on Windows with an unexpected permission value.

Use Vitest's skipIf modifier to skip on Windows:

Proposed change
it.skipIf(process.platform === "win32")("should write files with 0o600 permissions (owner-only read/write)", async () => {
🤖 Prompt for AI Agents
In `@packages/inspector/tests/oauth-integration-acceptance.test.ts` around lines
591 - 604, The permission assertion test for TokenStore in the "should write
files with 0o600 permissions (owner-only read/write)" case will fail on Windows
because Windows ignores Unix permission bits; update the test declaration to
skip on Windows using Vitest's conditional skip (e.g., replace the current
it(...) with it.skipIf(process.platform === "win32")(...)) so the body (creating
TokenStore, save(), reading directory, stat(), and checking perms) only runs on
non-Windows platforms.

Comment on lines +700 to +703
const secondState = provider.getOAuthState();
expect(secondState.expiresAt!).toBeGreaterThan(firstExpiry);
expect(secondState.expiresAt!).toBeGreaterThan(Date.now() + 3500_000);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Assertion timing may be flaky.

Line 702 asserts secondState.expiresAt! > Date.now() + 3500_000, but expires_in: 3600 means expiry is ~3600 seconds (3,600,000 ms) from save time. The gap between saveTokens and the assertion should be negligible, but this leaves only ~100 seconds of margin.

Consider a more lenient assertion:

-    expect(secondState.expiresAt!).toBeGreaterThan(Date.now() + 3500_000);
+    expect(secondState.expiresAt!).toBeGreaterThan(Date.now() + 3000_000); // ~50 min margin
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const secondState = provider.getOAuthState();
expect(secondState.expiresAt!).toBeGreaterThan(firstExpiry);
expect(secondState.expiresAt!).toBeGreaterThan(Date.now() + 3500_000);
});
const secondState = provider.getOAuthState();
expect(secondState.expiresAt!).toBeGreaterThan(firstExpiry);
expect(secondState.expiresAt!).toBeGreaterThan(Date.now() + 3000_000); // ~50 min margin
});
🤖 Prompt for AI Agents
In `@packages/inspector/tests/oauth-integration-acceptance.test.ts` around lines
700 - 703, Test assertion is brittle because it compares secondState.expiresAt!
to Date.now() + 3_500_000 with little slack; change the assertion to allow a
safety margin (e.g. compute const now = Date.now() and assert
expect(secondState.expiresAt! - now).toBeGreaterThan(3_500_000 - 60_000) or
similar), or assert against the firstExpiry (e.g.
expect(secondState.expiresAt!).toBeGreaterThan(firstExpiry + 3_400_000)) so the
check on provider.getOAuthState().expiresAt is lenient and avoids flakes.

@claude
Copy link
Contributor

claude bot commented Feb 1, 2026

Code Review: OAuth MCP Server Connections

Summary

This PR adds comprehensive OAuth authentication support to the MCP Inspector. The implementation is very well-architected with strong type safety, comprehensive testing, and excellent documentation.

Overall Assessment: ✅ Approve with Minor Suggestions

Strengths

  1. Excellent Architecture - Clean separation of concerns across modules (provider, token-store, callback-handler, preset-config, wellknown-proxy)
  2. Strong Type Safety - No any usage in production code, follows strict TypeScript standards
  3. Security Best Practices - Atomic file writes, proper permissions (0o600/0o700), PKCE flow, XDG-compliant storage
  4. Comprehensive Testing - 1,052 tests passing (40 new OAuth tests), acceptance criteria-driven design
  5. RFC Compliance - Properly implements RFC 7591, 7009, 8414, 9728

Issues Found (Medium Priority)

1. Potential Race Condition (provider.ts:312-327)

  • waitForAuthorization() could hang if onAuthorizationComplete() is called first
  • Recommendation: Add completion flag to track if auth already completed

2. Missing Input Length Validation (callback-handler.ts:536)

  • Error messages from auth server could be extremely long
  • Recommendation: Truncate to 500 chars in escapeHtml()

3. Unconstrained Cache TTL (wellknown-proxy.ts:70)

  • No validation of cacheTtlMs parameter
  • Recommendation: Add bounds checking (0-60 min range)

Low Priority Improvements

  • Add clock skew buffer (60s) to token expiry calculation
  • Extract magic numbers to named constants (0o600, 0o700, 300_000, 10_000)
  • Consider extending dashboard polling interval from 3s to 5-10s

Security Audit ✅

  • No credential leakage
  • Atomic writes prevent corruption
  • Proper file permissions
  • PKCE protection implemented correctly
  • Input sanitization present
  • Timeout protection on network requests

Final Verdict

✅ APPROVED

This is exceptional work demonstrating deep OAuth 2.0 understanding, strong architectural design, and comprehensive testing. The suggested improvements are minor and non-blocking. The code is production-ready.

Review Score: 9.5/10 🎉

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@packages/inspector/src/connection.ts`:
- Around line 252-259: The code uses new URL(this.inspectorUrl).port which can
be an empty string (producing NaN when passed to parseInt) and thus yields an
invalid callbackPort for InspectorOAuthProvider; update the port derivation to
fall back to the default inspector port when empty (e.g., compute const portStr
= this.inspectorUrl ? (new URL(this.inspectorUrl).port || "6274") : "6274" and
then use parseInt(portStr, 10) for callbackPort) so InspectorOAuthProvider
receives a valid numeric callbackPort.

In `@packages/inspector/src/dashboard/react/hooks/useOAuth.ts`:
- Around line 176-185: The current construction of the OAuth configure body
treats an empty string clientId as falsy, enabling dynamic registration
incorrectly; update the body creation in useOAuth.ts (the OAuthConfigureParams
object where body is built) so clientId is normalized to undefined when empty
(e.g., use a trimmed value and set clientId to undefined if it's an empty
string) and derive enableDynamicRegistration from the presence of the normalized
clientId (e.g., enableDynamicRegistration = !normalizedClientId). Ensure you
reference the same symbols: connectionId, params.clientId, and the
body/OAuthConfigureParams object when making the change.

In `@packages/inspector/src/oauth/preset-config.ts`:
- Around line 130-193: Replace the manual runtime checks in loadPresetConfigFile
with a Zod v4 schema: define a schema (e.g., PresetConfigFileSchema) that models
PresetConfigFile (clientId optional string, clientSecret optional string, scopes
optional string, autoRegister optional boolean, clientName optional string) and
enforces the "clientId OR autoRegister" constraint via refine; after JSON.parse
call, run PresetConfigFileSchema.parse(parsed) to validate and get typed data,
then map the parsed fields to the OAuthClientConfig object (set redirectUri,
clientId, clientSecret, clientName, enableDynamicRegistration) and normalize
scopes (replace commas with spaces and trim) either via a z.preprocess on scopes
or after parsing; remove all the manual typeof/array/null checks and throw
behavior in favor of schema.parse errors.

In `@packages/inspector/src/oauth/provider.ts`:
- Around line 312-325: The timeout handler in waitForAuthorization leaves
pending state set, so update the timeout branch to clear the pending auth state
and surface an error: when the timeout fires, set this._pendingAuthResolve =
null and this._pendingAuthUrl = null, and mark an error (for example by setting
this.status = 'error' or calling whatever existing method records auth failures)
before rejecting with the timeout Error; this ensures the UI stops showing
"authenticating" and allows retries.
🧹 Nitpick comments (1)
packages/inspector/src/dashboard/react/hooks/useOAuth.ts (1)

106-108: Consider validating API responses with Zod.

Type assertions (as OAuthStatusResponse) bypass runtime validation. Malformed server responses could cause subtle downstream errors. Per coding guidelines, Zod v4 is preferred for schema validation.

This pattern repeats at lines 194, 198, 245, and 249.

♻️ Example using Zod for response validation
import { z } from "zod";

const OAuthStatusResponseSchema = z.object({
  configured: z.boolean(),
  connectionId: z.string().nullable(),
  status: z.string().optional(),
  expiresAt: z.number().optional(),
  grantedScopes: z.string().optional(),
  errorMessage: z.string().optional(),
  supportsDynamicRegistration: z.boolean().optional(),
  supportsRevocation: z.boolean().optional(),
  supportedScopes: z.array(z.string()).optional(),
  authorizationUrl: z.string().nullable().optional(),
});

// Then in fetchStatus:
const data = OAuthStatusResponseSchema.parse(await res.json());

Comment on lines +252 to +259
// Port is set later via setInspectorUrl, default to 6274
const port = this.inspectorUrl ? new URL(this.inspectorUrl).port : "6274";

authProvider = new InspectorOAuthProvider({
serverUrl: params.url,
config: oauthConfig,
callbackPort: parseInt(port, 10),
debug: this.debug,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against empty port when deriving the callback port.

If inspectorUrl omits the port, new URL(...).port is "", which parses to NaN and produces an invalid callback URL. Consider falling back to the default inspector port when the URL has no explicit port.

✅ Suggested fix
-      const port = this.inspectorUrl ? new URL(this.inspectorUrl).port : "6274";
+      const portFromUrl = this.inspectorUrl ? new URL(this.inspectorUrl).port : "";
+      const port = portFromUrl || "6274";
 
       authProvider = new InspectorOAuthProvider({
         serverUrl: params.url,
         config: oauthConfig,
-        callbackPort: parseInt(port, 10),
+        callbackPort: Number.parseInt(port, 10),
         debug: this.debug,
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Port is set later via setInspectorUrl, default to 6274
const port = this.inspectorUrl ? new URL(this.inspectorUrl).port : "6274";
authProvider = new InspectorOAuthProvider({
serverUrl: params.url,
config: oauthConfig,
callbackPort: parseInt(port, 10),
debug: this.debug,
// Port is set later via setInspectorUrl, default to 6274
const portFromUrl = this.inspectorUrl ? new URL(this.inspectorUrl).port : "";
const port = portFromUrl || "6274";
authProvider = new InspectorOAuthProvider({
serverUrl: params.url,
config: oauthConfig,
callbackPort: Number.parseInt(port, 10),
debug: this.debug,
🤖 Prompt for AI Agents
In `@packages/inspector/src/connection.ts` around lines 252 - 259, The code uses
new URL(this.inspectorUrl).port which can be an empty string (producing NaN when
passed to parseInt) and thus yields an invalid callbackPort for
InspectorOAuthProvider; update the port derivation to fall back to the default
inspector port when empty (e.g., compute const portStr = this.inspectorUrl ?
(new URL(this.inspectorUrl).port || "6274") : "6274" and then use
parseInt(portStr, 10) for callbackPort) so InspectorOAuthProvider receives a
valid numeric callbackPort.

Comment on lines +176 to +185
try {
const body: OAuthConfigureParams = {
connectionId,
config: {
clientId: params.clientId ?? undefined,
clientSecret: params.clientSecret ?? undefined,
scopes: params.scopes ?? undefined,
enableDynamicRegistration: !params.clientId,
},
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Empty string triggers unintended dynamic registration.

Line 183 uses !params.clientId which is truthy for empty strings. If a user clears the clientId field (resulting in ""), dynamic registration will be enabled unexpectedly.

🐛 Proposed fix
         const body: OAuthConfigureParams = {
           connectionId,
           config: {
-            clientId: params.clientId ?? undefined,
-            clientSecret: params.clientSecret ?? undefined,
-            scopes: params.scopes ?? undefined,
-            enableDynamicRegistration: !params.clientId,
+            clientId: params.clientId || undefined,
+            clientSecret: params.clientSecret || undefined,
+            scopes: params.scopes || undefined,
+            enableDynamicRegistration: !params.clientId?.trim(),
           },
         };
🤖 Prompt for AI Agents
In `@packages/inspector/src/dashboard/react/hooks/useOAuth.ts` around lines 176 -
185, The current construction of the OAuth configure body treats an empty string
clientId as falsy, enabling dynamic registration incorrectly; update the body
creation in useOAuth.ts (the OAuthConfigureParams object where body is built) so
clientId is normalized to undefined when empty (e.g., use a trimmed value and
set clientId to undefined if it's an empty string) and derive
enableDynamicRegistration from the presence of the normalized clientId (e.g.,
enableDynamicRegistration = !normalizedClientId). Ensure you reference the same
symbols: connectionId, params.clientId, and the body/OAuthConfigureParams object
when making the change.

Comment on lines +130 to +193
export async function loadPresetConfigFile(filePath: string): Promise<OAuthClientConfig> {
let content: string;
try {
content = await readFile(filePath, "utf-8");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to read OAuth config file: ${message}`);
}

let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new Error(`OAuth config file is not valid JSON: ${filePath}`);
}

if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`OAuth config file must contain a JSON object: ${filePath}`);
}

const file = parsed as PresetConfigFile;

// Validate: need at least clientId or autoRegister
if (!file.clientId && !file.autoRegister) {
throw new Error(`OAuth config file must include "clientId" or "autoRegister": ${filePath}`);
}

const config: OAuthClientConfig = {
redirectUri: "", // Filled by provider based on callbackPort
};

if (file.clientId) {
if (typeof file.clientId !== "string") {
throw new Error(`OAuth config: "clientId" must be a string`);
}
config.clientId = file.clientId;
}

if (file.clientSecret) {
if (typeof file.clientSecret !== "string") {
throw new Error(`OAuth config: "clientSecret" must be a string`);
}
config.clientSecret = file.clientSecret;
}

if (file.scopes) {
if (typeof file.scopes !== "string") {
throw new Error(`OAuth config: "scopes" must be a string`);
}
config.scopes = file.scopes.replace(/,/g, " ").trim();
}

if (file.autoRegister) {
config.enableDynamicRegistration = true;
}

if (file.clientName) {
if (typeof file.clientName !== "string") {
throw new Error(`OAuth config: "clientName" must be a string`);
}
config.clientName = file.clientName;
}

return config;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check for Zod dependency in package.json files
find . -name "package.json" -type f | head -20 | xargs grep -l "zod"

Repository: AndurilCode/mcp-apps-kit

Length of output: 272


🏁 Script executed:

# Check the actual version of Zod required
rg '"zod"' package.json

Repository: AndurilCode/mcp-apps-kit

Length of output: 50


🏁 Script executed:

# Examine the current implementation
head -n 200 packages/inspector/src/oauth/preset-config.ts | tail -n 80

Repository: AndurilCode/mcp-apps-kit

Length of output: 2443


🏁 Script executed:

# Search for existing Zod usage in the inspector package
rg "import.*zod" packages/inspector/src/ -i

Repository: AndurilCode/mcp-apps-kit

Length of output: 2125


🏁 Script executed:

# Check Zod version in packages/inspector
cat packages/inspector/package.json | jq '.dependencies.zod, .devDependencies.zod'

Repository: AndurilCode/mcp-apps-kit

Length of output: 81


🏁 Script executed:

# Check packages/core for Zod version
cat packages/core/package.json | jq '.dependencies.zod, .devDependencies.zod'

Repository: AndurilCode/mcp-apps-kit

Length of output: 81


🏁 Script executed:

# Find PresetConfigFile type definition
rg "PresetConfigFile" packages/inspector/src/oauth/

Repository: AndurilCode/mcp-apps-kit

Length of output: 798


🏁 Script executed:

# Check one example of how Zod is used in the inspector tools
head -n 50 packages/inspector/src/tools/list-tools.ts

Repository: AndurilCode/mcp-apps-kit

Length of output: 1598


🏁 Script executed:

# Get the PresetConfigFile interface definition
rg -A 15 "export interface PresetConfigFile" packages/inspector/src/oauth/preset-config.ts

Repository: AndurilCode/mcp-apps-kit

Length of output: 419


Use Zod v4 schemas for config validation.

This function manually validates JSON shape and types. The coding guidelines and codebase patterns require Zod v4 for all schema/validation in this package. All other tool files in packages/inspector/src/tools/ consistently use Zod schemas. Replace the manual type checks with a Zod schema.

✅ Suggested refactor (Zod v4)
+import { z } from "zod";
+
+const PresetConfigFileSchema = z
+  .object({
+    clientId: z.string().optional(),
+    clientSecret: z.string().optional(),
+    scopes: z.string().optional(),
+    autoRegister: z.boolean().optional(),
+    clientName: z.string().optional(),
+  })
+  .refine((data) => data.clientId || data.autoRegister, {
+    message: 'OAuth config must include "clientId" or "autoRegister".',
+  });
...
-  const file = parsed as PresetConfigFile;
+  const file = PresetConfigFileSchema.parse(parsed);
🤖 Prompt for AI Agents
In `@packages/inspector/src/oauth/preset-config.ts` around lines 130 - 193,
Replace the manual runtime checks in loadPresetConfigFile with a Zod v4 schema:
define a schema (e.g., PresetConfigFileSchema) that models PresetConfigFile
(clientId optional string, clientSecret optional string, scopes optional string,
autoRegister optional boolean, clientName optional string) and enforces the
"clientId OR autoRegister" constraint via refine; after JSON.parse call, run
PresetConfigFileSchema.parse(parsed) to validate and get typed data, then map
the parsed fields to the OAuthClientConfig object (set redirectUri, clientId,
clientSecret, clientName, enableDynamicRegistration) and normalize scopes
(replace commas with spaces and trim) either via a z.preprocess on scopes or
after parsing; remove all the manual typeof/array/null checks and throw behavior
in favor of schema.parse errors.

Comment on lines +312 to +325
waitForAuthorization(): Promise<void> {
if (!this._pendingAuthUrl) {
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
this._pendingAuthResolve = null;
reject(new Error("Authorization timed out after 5 minutes"));
}, 300_000);

this._pendingAuthResolve = () => {
clearTimeout(timeout);
resolve();
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clear pending auth state on timeout to avoid stuck UI.

The timeout path rejects but leaves _pendingAuthUrl and status unchanged, which can keep the UI in an “authenticating” state and block retries. Consider clearing the pending URL and setting an error state when the timeout fires.

💡 Suggested fix
      const timeout = setTimeout(() => {
        this._pendingAuthResolve = null;
+       this._pendingAuthUrl = null;
+       this.setError("Authorization timed out after 5 minutes");
        reject(new Error("Authorization timed out after 5 minutes"));
      }, 300_000);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
waitForAuthorization(): Promise<void> {
if (!this._pendingAuthUrl) {
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
this._pendingAuthResolve = null;
reject(new Error("Authorization timed out after 5 minutes"));
}, 300_000);
this._pendingAuthResolve = () => {
clearTimeout(timeout);
resolve();
};
waitForAuthorization(): Promise<void> {
if (!this._pendingAuthUrl) {
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
this._pendingAuthResolve = null;
this._pendingAuthUrl = null;
reject(new Error("Authorization timed out after 5 minutes"));
}, 300_000);
this._pendingAuthResolve = () => {
clearTimeout(timeout);
resolve();
};
🤖 Prompt for AI Agents
In `@packages/inspector/src/oauth/provider.ts` around lines 312 - 325, The timeout
handler in waitForAuthorization leaves pending state set, so update the timeout
branch to clear the pending auth state and surface an error: when the timeout
fires, set this._pendingAuthResolve = null and this._pendingAuthUrl = null, and
mark an error (for example by setting this.status = 'error' or calling whatever
existing method records auth failures) before rejecting with the timeout Error;
this ensures the UI stops showing "authenticating" and allows retries.

@claude
Copy link
Contributor

claude bot commented Feb 1, 2026

Code Review: OAuth MCP Server Connections (PR #137)

Thank you for this comprehensive OAuth implementation! This is a well-architected feature with strong security practices and excellent test coverage (1,052 tests passing, 40 new OAuth integration tests). Below is a detailed review organized by category.


🎯 Summary

Overall Assessment: ✅ APPROVE with recommended improvements

This PR delivers a production-quality OAuth 2.1 + PKCE implementation for the MCP inspector. The code demonstrates:

  • Strong adherence to repository standards (TypeScript strict mode, no any types)
  • Comprehensive security measures (PKCE, XSS protection, 0o600 file permissions)
  • Excellent test coverage (9 test files, all 10 acceptance criteria validated)
  • Clean separation of concerns across 3 modes (Dashboard, CLI, Dual Mode)

However, several critical improvements are needed before merge to align with repository standards (particularly Zod v4 validation requirements).


🔴 Critical Issues (Must Fix)

1. Missing Zod Validation (HIGH SEVERITY)

Repository Requirement: AGENTS.md:10 mandates Zod v4 for all validation

Issue: Multiple files use raw JSON.parse() with type assertions instead of Zod schemas.

Locations:

  • packages/inspector/src/oauth/callback-handler.ts:240
  • packages/inspector/src/oauth/preset-config.ts:141
  • packages/inspector/src/oauth/token-store.ts:91,193

Example from callback-handler.ts:240:

body = JSON.parse(await readBody(req)) as {
  connectionId?: string;
  config?: OAuthClientConfig;
};

Security Risk: Malformed JSON or unexpected fields bypass validation, leading to runtime errors or potential vulnerabilities.

Recommendation: Define Zod schemas in types.ts:

import { z } from 'zod';

export const OAuthClientConfigSchema = z.object({
  clientId: z.string().optional(),
  clientSecret: z.string().optional(),
  redirectUri: z.string().url(),
  scopes: z.string().optional(),
  enableDynamicRegistration: z.boolean().optional(),
  clientName: z.string().optional(),
});

export const PersistedTokenDataSchema = z.object({
  serverUrl: z.string().url(),
  tokens: z.object({
    access_token: z.string(),
    token_type: z.string(),
    expires_in: z.number().positive().optional(),
    refresh_token: z.string().optional(),
    scope: z.string().optional(),
  }).optional(),
  codeVerifier: z.string().optional(),
  clientInformation: z.any().optional(),
  savedAt: z.number(),
  expiresAt: z.number().optional(),
  requestedScopes: z.string().optional(),
});

Then use in handlers:

// callback-handler.ts:240
const result = OAuthConfigureBodySchema.safeParse(JSON.parse(await readBody(req)));
if (!result.success) {
  jsonResponse(res, 400, { error: "Invalid request body", details: result.error });
  return true;
}
const { config, connectionId } = result.data;

2. Missing URL Validation (MEDIUM SEVERITY)

Location: packages/inspector/src/oauth/token-store.ts:53-54

export function hashServerUrl(serverUrl: string): string {
  return createHash("sha256").update(serverUrl).digest("hex");
}

Issue: No validation that serverUrl is a valid URL before hashing. Could cause issues or collisions.

Recommendation:

export function hashServerUrl(serverUrl: string): string {
  try {
    new URL(serverUrl); // Validate URL format
  } catch {
    throw new Error(`Invalid server URL for token storage: ${serverUrl}`);
  }
  return createHash("sha256").update(serverUrl).digest("hex");
}

3. Interface Inconsistency (MEDIUM SEVERITY)

Location: packages/inspector/src/oauth/types.ts:42 + token-store.ts:117

Issue: PersistedTokenData declares tokens: OAuthTokens (required), but merge logic can result in undefined:

const merged: PersistedTokenData = {
  tokens: (data.tokens ?? existing?.tokens) as OAuthTokens, // Can be undefined!
  // ...
};

Recommendation: Make tokens optional in the interface:

export interface PersistedTokenData {
  serverUrl: string;
  tokens?: OAuthTokens; // Allow undefined for partial saves
  // ...
}

🟡 High Priority Recommendations

4. Rate Limiting Missing (MEDIUM SEVERITY)

Location: packages/inspector/src/oauth/callback-handler.ts

Issue: OAuth endpoints (/api/oauth/configure, /api/oauth/status, /api/oauth/revoke) have no rate limiting. Attackers could spam these endpoints.

Recommendation: Add per-connection request throttling:

const requestCounts = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(connectionId: string, maxPerMinute: number): boolean {
  const now = Date.now();
  const entry = requestCounts.get(connectionId);
  
  if (!entry || now > entry.resetAt) {
    requestCounts.set(connectionId, { count: 1, resetAt: now + 60_000 });
    return true;
  }
  
  if (entry.count >= maxPerMinute) return false;
  
  entry.count++;
  return true;
}

// In handlers:
if (!checkRateLimit(connectionId, 20)) {
  jsonResponse(res, 429, { error: "Too many requests" });
  return true;
}

5. Timeout Cleanup Leak (LOW SEVERITY)

Location: packages/inspector/src/oauth/provider.ts:317-326

Issue: If onAuthorizationComplete() is called with an error before the timeout, the timeout is never cleared → memory leak.

Recommendation:

private _pendingAuthTimeout: ReturnType<typeof setTimeout> | null = null;

waitForAuthorization(): Promise<void> {
  if (!this._pendingAuthUrl) return Promise.resolve();
  
  return new Promise<void>((resolve, reject) => {
    this._pendingAuthTimeout = setTimeout(() => {
      this._pendingAuthResolve = null;
      this._pendingAuthTimeout = null;
      reject(new Error("Authorization timed out after 5 minutes"));
    }, 300_000);

    this._pendingAuthResolve = () => {
      if (this._pendingAuthTimeout) {
        clearTimeout(this._pendingAuthTimeout);
        this._pendingAuthTimeout = null;
      }
      resolve();
    };
  });
}

6. Race Condition in invalidateCredentials (MEDIUM SEVERITY)

Location: packages/inspector/src/oauth/provider.ts:251-261

Issue: Delete → save pattern is non-atomic and could lose data in concurrent scenarios.

Recommendation: Make the operation atomic by using a single save with explicit undefined:

} else if (scope === "tokens") {
  const existing = await this.tokenStore.load(this.serverUrl);
  await this.tokenStore.save(this.serverUrl, {
    clientInformation: existing?.clientInformation,
    tokens: undefined,
    codeVerifier: undefined,
    expiresAt: undefined,
  });
  this.updateStatus("unauthenticated");
}

(Requires updating save() to distinguish undefined from nullish coalescing)


7. Token Expiry Bounds Check (LOW SEVERITY)

Location: packages/inspector/src/oauth/provider.ts:192-196

Issue: No bounds checking on expires_in. A very large value could cause timestamp overflow.

Recommendation:

const MAX_EXPIRES_IN = 365 * 24 * 60 * 60; // 1 year in seconds

if (tokens.expires_in !== undefined && tokens.expires_in !== null && tokens.expires_in > 0) {
  const expiresIn = Math.min(tokens.expires_in, MAX_EXPIRES_IN);
  this._expiresAt = Date.now() + expiresIn * 1000;
} else {
  this._expiresAt = undefined;
}

🟢 React/UI Improvements

8. Missing Memoization (MEDIUM PRIORITY)

Location: packages/inspector/src/dashboard/react/components/OAuthPanel.tsx:305-335

Issue: Callbacks and computed values recreated on every render, causing unnecessary re-renders.

Recommendation:

const selectedScopesSet = useMemo(() => 
  new Set(scopes.split(/\s+/).map(s => s.trim()).filter(Boolean)),
  [scopes]
);

const toggleScope = useCallback((scope: string) => {
  setScopes(prev => {
    const current = new Set(prev.split(/\s+/).map(s => s.trim()).filter(Boolean));
    if (current.has(scope)) current.delete(scope);
    else current.add(scope);
    return Array.from(current).join(" ");
  });
}, []); // No dependencies with functional update

9. Accessibility Gaps (MEDIUM PRIORITY)

Location: packages/inspector/src/dashboard/react/components/OAuthPanel.tsx

Issues:

  • Popover missing role="dialog" and aria-labelledby
  • No focus management when popover opens
  • Status badge lacks role="status" and aria-live="polite"
  • Scope chip group missing aria-label

Recommendations:

<div
  ref={popoverRef}
  role="dialog"
  aria-modal="false"
  aria-labelledby="oauth-panel-title"
  tabIndex={-1} // Allow focus
  // ...
>
  <span id="oauth-panel-title" style={styles.title}>OAuth Configuration</span>
  
  {/* Status badge */}
  <span role="status" aria-live="polite" style={styles.badge}>
    {status}
  </span>
  
  {/* Scope chips */}
  <div role="group" aria-label="Available scopes">
    {supportedScopes.map(scope => (
      <button aria-pressed={selectedScopesSet.has(scope)} ...>
        {scope}
      </button>
    ))}
  </div>
</div>

Add focus management:

useEffect(() => {
  if (isOpen && popoverRef.current) {
    popoverRef.current.focus();
  }
}, [isOpen]);

10. Error State Not Cleared (LOW PRIORITY)

Location: packages/inspector/src/dashboard/react/hooks/useOAuth.ts:169-174, 229-236

Issue: Errors persist across retries. Clear errors at the start of operations:

const configure = useCallback(async (config: OAuthClientConfig) => {
  setError(null); // Add this
  setIsLoading(true);
  try {
    // ...
  }
}, [/* ... */]);

✅ Security Strengths (Excellent Work!)

  • PKCE Implementation: Full RFC 7636 compliance with code verifier storage
  • XSS Protection: Proper HTML escaping in callback page (callback-handler.ts:536-543)
  • File Permissions: Restrictive 0o600 on token files, 0o700 on directories
  • Token Revocation: Complete RFC 7009 implementation with both access & refresh tokens
  • CORS Handling: Proper CORS headers on all OAuth endpoints
  • Atomic File Writes: Temp file + rename prevents corruption
  • HTTP Basic Auth: Correct implementation for confidential clients

🧪 Test Coverage Assessment

Overall Test Quality: A (93/100)

Strengths:

  • 1,052 tests passing with 40 new OAuth integration tests
  • 9 comprehensive test files (4,500+ lines of test code)
  • All 10 acceptance criteria have dedicated end-to-end tests
  • Excellent test isolation with proper cleanup (temp directories, mock restoration)
  • Integration tests cover realistic production scenarios

Minor Gaps:

  1. Missing permission verification: oauth-token-store.test.ts:196-209 should verify 0o600 mode with stat()
  2. Platform compatibility: Permission test needs Windows skip guard
  3. Timeout handling: No tests for fetch timeouts in revocation/discovery flows
  4. Concurrent access: No tests for race conditions in token save/load

Recommendation: Add explicit permission check:

it("should create files with restrictive permissions", async () => {
  // ... existing code ...
  
  if (process.platform !== 'win32') {
    const fileStat = await stat(join(tempDir, files[0]!));
    expect(fileStat.mode & 0o777).toBe(0o600);
  }
});

📊 Performance Considerations

Strengths:

  • Well-known metadata cached with 5-minute TTL
  • 10-second timeout on upstream fetches
  • Atomic file writes prevent corruption

Minor Concerns:

  • No cache size limits on metadata (unlikely issue but could add validation)
  • Popover position updates on every scroll/resize (consider debouncing)

🎓 OAuth Best Practices Compliance

Excellent Implementation:

  • ✅ PKCE (RFC 7636)
  • ✅ Token Revocation (RFC 7009)
  • ✅ Dynamic Client Registration (RFC 7591)
  • ✅ Authorization Server Metadata Discovery (RFC 8414)
  • ✅ Protected Resource Metadata Mirroring (RFC 9728)
  • ✅ Proper token refresh delegation to SDK

Minor Enhancement Opportunity:

  • Consider adding scope downgrade detection (warn if granted scopes < requested scopes)
  • Could discover preferred token_endpoint_auth_method from server metadata instead of hardcoding client_secret_basic

📝 Priority Summary

🔴 Must Fix Before Merge

  1. Add Zod validation for all JSON parsing (callback-handler, preset-config, token-store, useOAuth hooks)
  2. Add URL validation in hashServerUrl()
  3. Make PersistedTokenData.tokens optional in interface

🟡 Strongly Recommended

  1. Add rate limiting to OAuth API endpoints
  2. Fix timeout cleanup leak in waitForAuthorization()
  3. Fix race condition in invalidateCredentials()
  4. Add bounds checking for expires_in

🟢 Nice to Have

  1. Memoize callbacks and computed values in OAuthPanel
  2. Improve accessibility (ARIA roles, focus management)
  3. Clear error state at start of operations
  4. Add file permission verification in tests
  5. Add platform skip guard for Windows permission test

🚀 Conclusion

This is exceptional work that demonstrates deep understanding of OAuth 2.1 security practices and TypeScript development. The implementation is well-architected with excellent separation of concerns across the three operational modes.

After addressing the critical Zod validation requirements and minor security improvements, this PR will be production-ready.

The comprehensive test suite (particularly the 10 acceptance criteria integration tests) provides high confidence in the implementation's correctness and robustness.

Estimated effort for critical fixes: 4-6 hours

Great job on this feature! 🎉

@claude
Copy link
Contributor

claude bot commented Feb 1, 2026

Code Review - PR #137

Comprehensive OAuth implementation with excellent architecture. Main issues to address:

  1. Zod v4 validation missing - AGENTS.md requires Zod for schema validation
  2. Fetch timeouts needed - provider.ts:506, wellknown-proxy.ts:108 can hang
  3. Port parsing edge case - connection.ts:252 needs fallback
  4. Test platform skip - Windows permission test needs conditional

Verdict: High-quality work. REQUEST CHANGES for Zod + timeouts, then APPROVE.

See detailed review in my comment above or full analysis available on request.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/inspector/src/oauth/provider.ts`:
- Around line 262-269: The client scope branch incorrectly attempts to clear
clientInformation by passing clientInformation: undefined to tokenStore.save,
but TokenStore.save uses nullish coalescing so undefined preserves the old
value; change the logic in the block handling scope === "client" (the
tokenStore.load / tokenStore.save flow) to first delete the stored entry via
tokenStore.delete(this.serverUrl) and then, if the previously-loaded
existing.tokens exist, call tokenStore.save(this.serverUrl, { tokens:
existing.tokens }) so clientInformation is truly removed; use the same pattern
as the tokens-scope fix (referencing tokenStore.load, tokenStore.delete, and
tokenStore.save).
🧹 Nitpick comments (1)
packages/inspector/src/oauth/provider.ts (1)

392-394: Include error details in scope discovery failure log.

The empty catch block discards the error. Including the error message would aid debugging when scope discovery fails.

♻️ Suggested improvement
-    } catch {
-      this.log("Scope discovery failed — auth server metadata unavailable");
+    } catch (err) {
+      const message = err instanceof Error ? err.message : String(err);
+      this.log(`Scope discovery failed: ${message}`);
     }

Comment on lines +262 to +269
} else if (scope === "client") {
const existing = await this.tokenStore.load(this.serverUrl);
if (existing) {
await this.tokenStore.save(this.serverUrl, {
tokens: existing.tokens,
clientInformation: undefined,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n packages/inspector/src/oauth/token-store.ts | head -100

Repository: AndurilCode/mcp-apps-kit

Length of output: 3684


🏁 Script executed:

cat -n packages/inspector/src/oauth/token-store.ts | sed -n '100,200p'

Repository: AndurilCode/mcp-apps-kit

Length of output: 3897


🏁 Script executed:

cat -n packages/inspector/src/oauth/provider.ts | sed -n '242,280p'

Repository: AndurilCode/mcp-apps-kit

Length of output: 1864


Fix clientInformation clearing in the "client" scope.

The "client" scope at lines 262-269 uses the same broken pattern that was fixed for the "tokens" scope. Because TokenStore.save() line 119 uses nullish coalescing (clientInformation: data.clientInformation ?? existing?.clientInformation), passing clientInformation: undefined will preserve the existing value instead of clearing it.

Follow the "tokens" scope pattern: delete the stored data first, then re-save with only the tokens:

Suggested fix
} else if (scope === "client") {
  const existing = await this.tokenStore.load(this.serverUrl);
  await this.tokenStore.delete(this.serverUrl);
  if (existing?.tokens) {
    await this.tokenStore.save(this.serverUrl, {
      tokens: existing.tokens,
    });
  }
}
🤖 Prompt for AI Agents
In `@packages/inspector/src/oauth/provider.ts` around lines 262 - 269, The client
scope branch incorrectly attempts to clear clientInformation by passing
clientInformation: undefined to tokenStore.save, but TokenStore.save uses
nullish coalescing so undefined preserves the old value; change the logic in the
block handling scope === "client" (the tokenStore.load / tokenStore.save flow)
to first delete the stored entry via tokenStore.delete(this.serverUrl) and then,
if the previously-loaded existing.tokens exist, call
tokenStore.save(this.serverUrl, { tokens: existing.tokens }) so
clientInformation is truly removed; use the same pattern as the tokens-scope fix
(referencing tokenStore.load, tokenStore.delete, and tokenStore.save).

@claude
Copy link
Contributor

claude bot commented Feb 1, 2026

Detailed Code Review Findings

Critical Issues (Must Fix Before Merge)

1. Zod v4 Schema Validation Missing ⚠️

Location: callback-handler.ts:238-247, preset-config.ts:139-194, useOAuth.ts hooks

Issue: Manual JSON parsing with type assertions violates AGENTS.md requirement: "ALWAYS use Zod v4 for schema/validation"

Fix: Replace manual validation with Zod schemas:

import { z } from 'zod';

const OAuthConfigureBodySchema = z.object({
  connectionId: z.string().optional(),
  config: z.object({
    clientId: z.string().optional(),
    clientSecret: z.string().optional(),
    enableDynamicRegistration: z.boolean().optional(),
  }).refine(c => c.clientId || c.enableDynamicRegistration)
});

2. Fetch Calls Lack Timeouts 🕐

Location: provider.ts:506-510 (postRevocation), wellknown-proxy.ts:108-131 (fetchUpstream)

Issue: Can hang indefinitely if server doesn't respond

Fix: Add AbortController with 10s timeout:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);

3. Port Parsing Edge Case 🔌

Location: connection.ts:252-259

Issue: parseInt("", 10) returns NaN when URL has no port

Fix: const portStr = this.inspectorUrl ? (new URL(this.inspectorUrl).port || "6274") : "6274";

Medium Priority Issues

4. Windows Test Compatibility

oauth-integration-acceptance.test.ts:591 - Add it.skipIf(process.platform === 'win32')

5. Memory Leak Prevention

provider.ts:318 - Timeout should clear pending state: this._pendingAuthUrl = null

6. State Cleanup

useOAuth.ts:135 - Also clear setError(null) and setIsLoading(false) when connection changes

Strengths ✨

  • Excellent RFC compliance (OAuth 2.1, PKCE, Token Revocation, Dynamic Registration)
  • Comprehensive test coverage (1,052 tests, 40 new OAuth tests)
  • Secure token storage (XDG paths, 0o600 permissions, atomic writes)
  • Clean architecture with proper separation of concerns
  • Well-documented code with clear comments

Test Coverage Analysis

✅ All 10 acceptance criteria covered
✅ Unit tests for all OAuth modules
✅ Integration tests for connection flows
✅ Security tests (XSS escaping, permission checks)

Verdict

APPROVE after addressing Zod validation + fetch timeouts. This is high-quality, production-ready OAuth implementation.

The three-mode architecture (Dashboard, CLI, Dual) is particularly well-designed and follows best practices throughout.

@claude
Copy link
Contributor

claude bot commented Feb 1, 2026

📋 Action Items Checklist

Before merging, please address these items:

Required (Blocking)

  • Add Zod v4 schemas in callback-handler.ts, preset-config.ts, and dashboard hooks
  • Add fetch timeouts with AbortController in provider.ts:506 and wellknown-proxy.ts:108
  • Fix port parsing in connection.ts:252 to handle empty port string
  • Run full validation: pnpm build && pnpm test && pnpm lint && pnpm typecheck (per AGENTS.md)

Recommended (Non-blocking)

  • Add platform skip for Windows in permission test (it.skipIf(process.platform === 'win32'))
  • Clear pending state in timeout handler (provider.ts:318)
  • Clear error/loading flags on connection change (useOAuth.ts:135)
  • Remove non-null assertions in connection.ts:244,264 (use local const)

Code Quality Improvements (Optional)

  • Extract duplicated scope parsing logic in OAuthPanel.tsx:305-335
  • Document CORS wildcard in callback-handler.ts:41
  • Import OAuthClientInformationFull at top level instead of inline

Review Status: ⏸️ Requesting Changes

Once the Required items are addressed, I recommend APPROVAL. The codebase quality is excellent and the architecture is sound.

Feel free to push fixes in follow-up commits. I'll re-review once ready!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant