Skip to content

feat: useMcp hook + a "bring your own MCP server" demo panel - #3

Merged
siarheidudko merged 5 commits into
mainfrom
claude/mcp-remote-http-oauth-ges1mj
Aug 16, 2026
Merged

feat: useMcp hook + a "bring your own MCP server" demo panel#3
siarheidudko merged 5 commits into
mainfrom
claude/mcp-remote-http-oauth-ges1mj

Conversation

@siarheidudko

Copy link
Copy Markdown
Member

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;
  • auth by static header or OAuth 2.1 + DCR;
  • the redirect round-trip: it persists what it needs before leaving, and on the way back finishes the code exchange, strips ?code=… from the address bar (single-use, and it lands in history/referrers) and reconnects — all behind completingAuthorization;
  • 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 ./mcp subpath is dynamically imported and typed structurally in src/mcp-types.ts. Apps that never connect keep @modelcontextprotocol/sdk out 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 a redirect_uri may not carry a fragment.

Tests

tests/mcp.test.ts covers the two pure pieces this package owns: the connect-outcome → UI-status mapping, and the URL cleanup after a redirect. Library typecheck / format:check / build / test (16 passing) and the demo typecheck / build are green.

Sequencing note

The OAuth path needs a core exporting BrowserOAuthProvider (dudko-dev/agent-web#4). The repo's @dudko.dev/agent-web devDependency — 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

claude added 2 commits August 16, 2026 14:05
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
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
claude added 2 commits August 16, 2026 15:24
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
siarheidudko marked this pull request as ready for review August 16, 2026 15:27
@siarheidudko
siarheidudko merged commit 9c27853 into main Aug 16, 2026
2 checks passed
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.
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