Skip to content

[FRONTEND] THU-348: Get MCPs working#508

Open
ital0 wants to merge 65 commits intomainfrom
italomenezes/thu-348-get-mcps-working-including-authenticated-options
Open

[FRONTEND] THU-348: Get MCPs working#508
ital0 wants to merge 65 commits intomainfrom
italomenezes/thu-348-get-mcps-working-including-authenticated-options

Conversation

@ital0
Copy link
Copy Markdown
Collaborator

@ital0 ital0 commented Mar 23, 2026

Summary

All MCP changes in a single PR for team validation. Use the desktop app (bun tauri dev) to test all features.

This PR contains the same code as PRs [1/6] through [6/6] combined, with all review fixes applied. Review individual PRs for focused diffs.

Test Scenarios

  • stdio (Desktop): npx + @playwright/mcp@latest --headless
  • HTTP + Bearer (Desktop): https://mcp.render.com/mcp + Render API key
  • HTTP + No Auth (Web): localhost MCP server via supergateway with --cors
  • Platform filtering: stdio hidden on web/mobile, HTTP/SSE available everywhere

Note

High Risk
High risk because it introduces new credential storage/encryption, OAuth redirect handling, and desktop shell-based stdio process spawning, plus changes to proxy redirect handling for SSRF mitigation.

Overview
Adds end-to-end MCP server support across HTTP (Streamable), SSE, and desktop stdio transports, including a refactored MCP settings UI (new add dialog, server cards with retry/authorize, platform transport filtering) and provider-side reconnect/backoff handling.

Introduces MCP authentication: bearer tokens and OAuth 2.1. OAuth now uses a dedicated callback route (/mcp/oauth/callback) with deep-link parsing, CSRF state checks, and a callback hook to exchange codes; credentials are stored in a new local-only mcp_credentials table using AES-GCM encryption derived from a per-device key.

Stops syncing mcp_servers via PowerSync (now local-only), updates tool wiring so chat sessions fetch enabled MCP clients via a provider getter, and namespaces MCP tools by sanitized server name to avoid collisions while surfacing a connected-server summary in the system prompt. Also hardens the backend mcp-proxy by following redirects under safeFetch to block redirect-based SSRF, and enables required Tauri plugins/capabilities (http always on, shell spawn for specific commands) plus broader localhost HTTP allowlists.

Reviewed by Cursor Bugbot for commit 19adf10. Bugbot is set up for automated code reviews on this repo. Configure here.

@ital0 ital0 changed the title THU-348: Get MCPs working (including authenticated options) [VALIDATION] [VALIDATION] THU-348: Get MCPs working (including authenticated options) Mar 23, 2026
@ital0 ital0 marked this pull request as ready for review March 23, 2026 20:10
Comment thread src/settings/use-mcp-servers-page.ts
Comment thread src/lib/mcp-transports/index.ts Outdated
Comment thread src/lib/mcp-auth/bearer-token-provider.ts
Copy link
Copy Markdown
Member

@cjroth cjroth left a comment

Choose a reason for hiding this comment

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

This is just a quick first-pass review - I'm going to do a deeper review when I have more brainspace later.

Comment thread src-tauri/capabilities/default.json
Comment thread src-tauri/src/commands.rs Outdated
Comment thread src-tauri/Cargo.toml Outdated
Comment thread src/lib/mcp-auth/credential-store.test.ts Outdated
Comment thread src/lib/mcp-auth/credential-store.ts
Comment thread src/lib/mcp-auth/oauth-client-provider.ts Outdated
Comment thread src/lib/mcp-transports/transport-factory.test.ts
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 23, 2026

PR Metrics

Metric Value
Lines changed (prod code) +3043 / -725
JS bundle size (gzipped) 🟡 1.00 MB → 1.02 MB (+11.3 KB, +1.1%)
Test coverage 🟡 70.27% → 70.08% (-0.2%)
Load time (preview) Lighthouse results unavailable

Updated Mon, 13 Apr 2026 18:36:26 GMT · run #774

Comment thread src/lib/mcp-provider.tsx Outdated
Comment thread src/dal/mcp-servers.ts Outdated
Comment thread src/settings/use-mcp-servers-page.ts
Comment thread src/lib/mcp-provider.tsx
Comment thread src/lib/mcp-transports/proxied-fetch.ts
Comment thread backend/src/mcp-proxy/routes.ts Outdated
Comment thread .team/validate-mcp-e2e.ts Outdated
Comment thread src/lib/mcp-provider.tsx
Comment thread src-tauri/capabilities/default.json
Comment thread src/lib/mcp-provider.tsx Outdated
Comment thread src/lib/mcp-auth/oauth-client-provider.ts Outdated
Comment thread src/lib/mcp-auth/oauth-client-provider.ts
Comment thread src/lib/mcp-provider.tsx
@claude
Copy link
Copy Markdown

claude Bot commented Apr 8, 2026

Review

Bugs & Data Loss

Credential deleted on transport config changeremoveServer now deletes the server's stored credential (credentialStoreRef.current.delete(serverId)). useMcpSync calls removeServer whenever the transport config changes (URL, command, etc.), then immediately calls addServer. The credential (bearer token or OAuth tokens) is permanently deleted before the new connection attempt, with no way to recover. A user who edits a server URL loses their stored API key.

Fix: separate "remove from provider state" from "delete credential". Credentials should only be deleted when the user explicitly removes a server, not during config-change re-adds. useMcpSync should call a provider method that only disconnects and removes the state, not one that also purges credentials.
src/hooks/use-mcp-sync.tsx:83 + src/lib/mcp-provider.tsx:181

Auth config changes not propagatedconfigChanged only compares transport (URL/command). If a user adds or changes a bearer token on an existing server, configChanged is false, the server is not re-added, and the running connection never picks up the new credential. The token sits in the store but is never used until the app restarts.
src/hooks/use-mcp-sync.tsx:80

Invalid OAuth redirect URI fallbackexchangeAuthorization is called with redirectUri: oauthState.redirectUrl || oauthState.serverUrl. When redirectUrl is null, this falls back to the MCP server URL (e.g. https://api.example.com/mcp), which was never registered as a redirect URI. The authorization server will reject the token exchange with an invalid_grant or redirect_uri_mismatch error.
src/hooks/use-mcp-oauth-callback.ts:107


Project Convention Violations

useEffect for navigation (src/components/mcp-oauth-callback.tsx:37-42) — the same-tab redirect path wraps navigate() in a useEffect with a 300ms setTimeout. CLAUDE.md explicitly lists "Navigation side effects" as a useEffect anti-pattern and says to return <Navigate replace /> in JSX instead. The popup path's postMessage + window.close() is a legitimate side effect, but the navigate branch should use a conditional render.

Dynamic imports without circular-dependency justification (src/hooks/use-mcp-oauth-callback.ts:28,89 and src/lib/mcp-transports/transport-factory.ts) — CLAUDE.md says "Prefer top-level imports over inline/dynamic imports when no circular dependency exists." These imports (@/lib/mcp-transports/proxied-fetch, @modelcontextprotocol/sdk/client/auth.js, etc.) have no obvious circular-dependency reason to be deferred.

Comment thread src/hooks/use-mcp-sync.tsx
Comment thread src/hooks/use-mcp-sync.tsx
Comment thread src/hooks/use-mcp-oauth-callback.ts
Comment thread src/components/mcp-oauth-callback.tsx
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9e4525f. Configure here.

Comment thread src/lib/mcp-provider.tsx
Comment thread src/lib/mcp-provider.tsx
Comment on lines +85 to +102
} catch (err) {
// If the server requires OAuth, the SDK calls redirectToAuthorization then throws.
// On web: redirectToAuthorization does window.location.assign (page navigates away).
// The error is expected — on return, useMcpOAuthCallback exchanges the code.
// On desktop/mobile: waitForAuthCode captures the code, finishAuth exchanges it.
if (authProvider && err instanceof Error && err.message.includes('Unauthorized')) {
await transport.close()
setServers((prev) =>
prev.map((s) =>
s.id === config.id
? { ...s, client: null, isConnected: false, error: null, errorMessage: 'needsAuth', enabled: true }
: s,
),
)
return new Promise<McpClient>(() => {}) // park — user must click Authorize
}
throw err
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Transport leaked when createMCPClient fails for non-OAuth reasons

When createMCPClient throws for a reason other than OAuth (e.g., network failure, server error), the transport created by createTransport is not stored in transportRefs and not explicitly closed before the error propagates. connectServer's catch block schedules a retry — the retry calls createTransport again creating a new transport, while the previous one remains open and unreachable.

With up to maxAttempts (6) retries, a persistently unreachable server leaks 6 transports. SSE transports in particular may hold open EventSource-style connections.

} catch (err) {
  if (authProvider && err instanceof Error && err.message.includes('Unauthorized')) {
    await transport.close()
    // ... existing OAuth handling ...
    return new Promise<McpClient>(() => {})
  }
  await transport.close()  // close on all non-OAuth error paths too
  throw err
}

for (const providerServer of servers) {
for (const providerServer of currentServers) {
if (!dbServerIds.has(providerServer.id)) {
removeServer(providerServer.id)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

removeServer is async (it calls disconnectServer + credential delete), but here it's fire-and-forget — errors are silently dropped and the cleanup runs concurrently with the rest of the loop. Line 83 below awaits the same function, which is the correct pattern. Change to await removeServer(providerServer.id) to match.

Comment thread src/lib/mcp-provider.tsx
authorizeServer: (serverId: string) => Promise<void>
addServer: (server: McpServerConfig) => Promise<void>
removeServer: (serverId: string) => void
updateServerStatus: (serverId: string, enabled: boolean) => void
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

removeServer and updateServerStatus are implemented as async functions but are typed here as () => void. TypeScript won't flag missing awaits at call sites — which is exactly how the unawaited removeServer call in use-mcp-sync.tsx:68 slipped through. Both should be (serverId: string) => Promise<void> and (serverId: string, enabled: boolean) => Promise<void> respectively.

@claude
Copy link
Copy Markdown

claude Bot commented Apr 13, 2026

Review

Solid PR overall — the OAuth/PKCE flow is well-structured, the credential store design is sound, the SSRF fix (redirect: 'manual'createSafeFetch validating each hop) is an improvement, and the PowerSync local-only migration for MCP tables is handled correctly.

Two issues, both stemming from the same root cause:

Bugs

MCPContextType declares removeServer / updateServerStatus as () => void but both are asyncsrc/lib/mcp-provider.tsx:14-15

TypeScript treats void-returning functions as non-awaitable, which masks missing awaits at call sites. Both should return Promise<void>.

removeServer unawaited in the deletion loopsrc/hooks/use-mcp-sync.tsx:68

When a server is deleted from the DB, removeServer(providerServer.id) fires without await. Disconnection errors are silently dropped and cleanup runs concurrently with the rest of the sync loop. Line 83 in the same function correctly awaits the same call — this inconsistency is a direct consequence of the void type above.

@cjroth cjroth temporarily deployed to italomenezes/thu-348-get-mcps-working-including-authenticated-options - thunderbolt PR #508 April 13, 2026 18:34 — with Render Destroyed
@claude
Copy link
Copy Markdown

claude Bot commented Apr 13, 2026

Review: PR #508 Get MCPs working

Two issues found.

Security — shell capabilities allow unrestricted argument injection
src-tauri/capabilities/default.json lines 63–89

"args": true is set for every allowed command (npx, uvx, node, python3, bun). The only frontend validation in validateStdioArgs rejects null bytes but nothing else. Arguments like -e "require('child_process').execSync(...)" (node) or -c "import os; os.system(...)" (python3) are syntactically valid and pass all checks. Since the user explicitly configures their own MCP servers the immediate blast radius is self-contained, but a maliciously crafted importable server config (QR code, shared JSON, etc.) would execute code silently. Consider blocking shell-interpreter flags (-e, --eval, -c, etc.) in validateStdioArgs.

Bug — OAuth redirectUri falls back to serverUrl
src/hooks/use-mcp-oauth-callback.ts line 108

redirectUri: oauthState.redirectUrl || oauthState.serverUrl

OAuth providers require an exact redirect_uri match between the authorization request and token exchange. serverUrl is the MCP server root, not a registered redirect URI. If oauthState.redirectUrl is absent (expired state, partial DB corruption, or a future code path that omits it), exchangeAuthorization will receive an unregistered URI and fail. The fallback should throw rather than silently use an invalid value.

clientInformation: clientInfo,
authorizationCode: mcpOauth.code,
codeVerifier: oauthState.codeVerifier,
redirectUri: oauthState.redirectUrl || oauthState.serverUrl,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If redirectUrl is absent from state (expired, DB corruption, or a future code path that forgets to set it), serverUrl is used as the redirect_uri. OAuth providers enforce an exact match against the URI registered during authorization — serverUrl is the MCP server root, not a redirect endpoint, so this would fail. Throw instead of falling back to an invalid value.

{
"name": "npx",
"cmd": "npx",
"args": true
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"args": true grants unrestricted arguments to node/python3/npx/bun/uvx. validateStdioArgs only rejects null bytes, so flags like node -e "..." or python3 -c "..." pass validation and execute arbitrary inline code. A user importing a malicious server config (e.g., from a QR code or shared JSON) would trigger silent code execution. Block shell-interpreter flags (-e, --eval, -c, --input-type=module) in validateStdioArgs.

Comment on lines +133 to +148
<div className="grid gap-2">
<Label htmlFor="auth-type">Authentication</Label>
<Select
value={formState.authType}
onValueChange={(value) => formDispatch({ type: 'SET_AUTH_TYPE', payload: value as McpAuthType })}
>
<SelectTrigger id="auth-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="bearer">API Key / Bearer Token</SelectItem>
<SelectItem value="oauth">OAuth 2.1</SelectItem>
</SelectContent>
</Select>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 OAuth auth type available for stdio, but unsupported — creates unrecoverable stuck state

The auth-type selector renders oauth as a valid option for all transport types, including stdio. However, discoverOAuth in mcp-provider.tsx calls createTransport, which for stdio returns no authProvider. discoverOAuth then immediately returns { error: 'Server does not require OAuth' }, and authorizeServer surfaces that as an error message.

The resulting user journey:

  1. User configures stdio + oauth and saves.
  2. connectServer detects authType === 'oauth', finds no stored credential, and sets state to needsAuth.
  3. User clicks "Authorize" → discoverOAuth returns "Server does not require OAuth".
  4. The server is permanently stuck — the user must delete and re-add it to recover.

isValid() in useMcpServerFormState accepts any auth type for stdio with no guard:

if (state.transportType === 'stdio') {
  validateStdioCommand(state.command)
  validateStdioArgs(state.args)
  return true  // auth type not checked
}

Fix: hide (or disable) the oauth option when transportType === 'stdio':

Suggested change
<div className="grid gap-2">
<Label htmlFor="auth-type">Authentication</Label>
<Select
value={formState.authType}
onValueChange={(value) => formDispatch({ type: 'SET_AUTH_TYPE', payload: value as McpAuthType })}
>
<SelectTrigger id="auth-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="bearer">API Key / Bearer Token</SelectItem>
<SelectItem value="oauth">OAuth 2.1</SelectItem>
</SelectContent>
</Select>
</div>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="bearer">API Key / Bearer Token</SelectItem>
{formState.transportType !== 'stdio' && (
<SelectItem value="oauth">OAuth 2.1</SelectItem>
)}
</SelectContent>

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.

2 participants