feat: useMcp hook + a "bring your own MCP server" demo panel - #3
Merged
Conversation
The demo could only drive tools defined inside itself. Add a second panel where the visitor pastes any remote MCP endpoint and the agent picks up that server's tools instead — including servers behind OAuth, which is where a browser client actually earns its keep (dynamic client registration means there is no client ID to hand out in advance). The hook owns the parts a host would otherwise rewrite: - one server per hook, connect / disconnect / status / catalog; - auth by static header or OAuth 2.1 + DCR; - the redirect round-trip — it persists what it needs before leaving, then on the way back finishes the code exchange, strips ?code= from the address bar (single-use, and it lands in history) and reconnects, all behind `completingAuthorization`; - `authorize()` navigates only from a real user gesture, never on its own, so an authorization demanded mid-run can't discard the session. The core's ./mcp subpath is loaded with a dynamic import and typed structurally in mcp-types.ts: apps that never connect keep @modelcontextprotocol/sdk out of their bundle (Vite splits it into its own chunk), and a core without the OAuth half degrades to a clear message instead of a crash. Tests cover the two pure pieces this package owns — the connect-outcome to UI-status mapping, and the URL cleanup after a redirect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FKop4At26QqqkwVGEjJur
…t await The headline bug was deterministic, not a race: useMcp read the callback after `await loadMcp()`, and React flushes the rest of the app's passive effects in that gap. The demo's own view effect rewrote location without the query string, so by the time the hook looked, `?code=` was gone — status stayed idle, no error, and pressing Connect restarted the same loop forever. The callback is now read and claimed synchronously, before any await, and the URL is cleaned as soon as the code is in hand rather than after the reconnect. The claim is module-level and keyed by the code, because StrictMode mounts every effect twice in development: both passes used to reach the token endpoint, and since the core's state check is single-use, the second reported "state mismatch (possible CSRF)" on a perfectly good authorization. Also from the review: - a hung handshake left completingAuthorization true forever, which disabled Connect permanently with no error and no way out but a reload that replayed a spent code. The flag is cleared before the reconnect, which owns the status from there. - the callback params were stripped even when the callback belonged to another sign-in flow on the same page. Only ours is touched now. - "Forget authorization" was a no-op after a reload (no live provider), so tokens the user believed revoked stayed in the vault and the next Connect silently reused them. The provider is rebuilt from a persisted record. - connect() had no epoch guard: unmounting or reconnecting mid-handshake orphaned an open MCP session. It now closes anything that arrives late. - a mid-session authorization failure left the panel reporting "connected" while every tool call failed; tool calls are wrapped so an UnauthorizedError flips the UI back to needs-authorization. - a rejected dynamic import was cached forever, killing MCP for the tab. - the resume path failed silently in three distinct ways. - oauthSupported was only resolved inside connect, so the panel offered OAuth on cores that cannot do it; checkOAuthSupport() resolves it when the user picks the mode, and the panel says so. - fragment-carried callbacks are detected and stripped, matching the core. - the demo stops the other panel's agent on a view switch, so one local WebGPU engine never runs two generations at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FKop4At26QqqkwVGEjJur
This was referenced Aug 16, 2026
The demo pinned @dudko.dev/agent-web 0.0.6 and ai v6 while the core has moved to ai v7. Pointing it at the published 0.0.11 surfaced two things at once: the demo's own AI SDK stack was a major behind (so its provider factories built models the core cannot accept), and its copy of the core resolved a different @ai-sdk/provider-utils than the one the aliased library dist resolves — two nominally distinct `Schema` symbols, which tsc rejects. Aligning the demo on ai ^7 with the v4 provider majors fixes both: one provider-utils across both trees, and models the core actually accepts. The peer floor moves to >=0.0.11 for the same reason — that is the first core whose own peer ranges resolve against AI SDK v7, and this package re-exports its types. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FKop4At26QqqkwVGEjJur
Review found the check for a lapsed authorization was dead code: the MCP SDK's UnauthorizedError never assigns `this.name`, so it reads as "Error" and `err.name === 'UnauthorizedError'` never matched. The panel kept reporting "connected" while every tool call failed — exactly the symptom the wrapper existed to prevent. Matching the message instead is not an option: the connector rethrows a failing tool's own text, so a tool that says "unauthorized" would flip the UI into an auth prompt. The core now re-exports the class, so this checks identity against the same module instance the connector uses, with a constructor-name fallback for an older core. Peer floor moves to >=0.0.12 accordingly. Two more from the same review: - StrictMode's simulated cleanup aborted the post-OAuth reconnect. The second setup could not take over (the code was already claimed and stripped from the URL), so the flow finished the authorization and then sat at "not connected" until the user pressed Connect again. The reconnect no longer bails on that flag — connect() is epoch-guarded and a setState after a real unmount is a no-op. - The `pending` record outlived its flow, so an unrelated ?code= on the same page could be claimed as ours. It now carries a timestamp, is ignored after ten minutes, and is cleared on a successful connect. Also: the tool wrapper no longer forces a promise, so the AI SDK can still detect a streaming tool by what execute() returns synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FKop4At26QqqkwVGEjJur
0.0.12 is the core that re-exports UnauthorizedError, which is what the mid-session authorization check now matches against by identity. Both trees were still on 0.0.11 — i.e. the repo violated its own declared peer range, and the demo build would have taken the constructor-name fallback instead of the real class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FKop4At26QqqkwVGEjJur
siarheidudko
marked this pull request as ready for review
August 16, 2026 15:27
siarheidudko
added a commit
that referenced
this pull request
Aug 16, 2026
Playwright over the demo's production build, against a real MCP server and a real authorization server on loopback: the OAuth round-trip, reuse of stored tokens across a reload, and an unreachable server. Covers the class of bug that shipped in #3, which no unit test could reach. Adds a Browser (demo) CI job. The preview server is bound to the literal address Playwright polls — Vite's default `localhost` resolves through DNS at listen time and can disagree with it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The demo could only drive tools defined inside itself. This adds a second panel where the visitor pastes any remote MCP endpoint and the agent picks up that server's tools — including servers behind OAuth, which is where a browser client actually earns its keep: dynamic client registration means there is no client ID to hand out in advance.
What
useMcp(src/hooks/use-mcp.ts) — one server per hook:connect/disconnect/status/catalog/tools, merged into any agent config;?code=…from the address bar (single-use, and it lands in history/referrers) and reconnects — all behindcompletingAuthorization;authorize()navigates only from a real user gesture. The core never redirects on its own, so an authorization demanded mid-run cannot discard the session.The core's
./mcpsubpath is dynamically imported and typed structurally insrc/mcp-types.ts. Apps that never connect keep@modelcontextprotocol/sdkout of their bundle (Vite splits it into its own ~204 KB chunk), and a core without the OAuth half degrades to a clear message rather than a crash (oauthSupported: false).Demo — tab switcher between "Sticky notes" (unchanged) and "Your MCP server": URL field, auth mode (none / bearer / OAuth+DCR), live status, the discovered tool list, an Authorize button when the server asks for it, and "Forget authorization". The model picker is shared; each panel keeps its own chat history.
?code=…on load opens the MCP tab, since aredirect_urimay not carry a fragment.Tests
tests/mcp.test.tscovers the two pure pieces this package owns: the connect-outcome → UI-status mapping, and the URL cleanup after a redirect. Librarytypecheck/format:check/build/test(16 passing) and the demotypecheck/buildare green.Sequencing note
The OAuth path needs a core exporting
BrowserOAuthProvider(dudko-dev/agent-web#4). The repo's@dudko.dev/agent-webdevDependency — which is what the aliased demo build resolves — is still on a version without it, so until that core is published the panel reports OAuth as unavailable and header auth works as usual. One version bump lights it up; nothing here breaks in the meantime.Generated by Claude Code