User-configured MCP Connectors with OAuth 2.1

nforum lets end users register Streamable-HTTP MCP servers from the account settings page and expose those tools to the chat agent. The three-commit feature covers the data model, a polished UI, and a full OAuth 2.1 flow with PKCE and dynamic client registration.

integrationsecurity

The foundation is a user_mcp_servers table with Postgres RLS (owner-only, four policies) and a headers jsonb column for bearer tokens. Tools discovered from registered servers are merged into the per-request tool list and routed to the LLM under the mcp__<slug>__<tool> prefix. A /user/mcp-servers CRUD router includes a /test probe that connects and lists available tools. The schema conversion from MCP inputSchema to the app's OpenAIToolSchema enforces a 64-character tool-name limit the spec requires.

The polish commit renames the settings tab to "Connectors" and adds a chat-input toggle per registered server. A new mcp_tool_result SSE event carries { server, tool, ok, args, output } with args and output capped at 4 KB for the stored preview; the model still receives full untruncated output. The UI renders Server · tool_name so the raw mcp__<slug>__<tool> string doesn't surface to users.

The third commit adds OAuth 2.1 via the MCP SDK's auth() helper, which handles RFC 9728 metadata discovery, RFC 7591 dynamic client registration, PKCE (S256), and token refresh. State across the popup hop is HMAC-signed with a 5-minute TTL - no server-side session needed. New DB columns auth_type, oauth_metadata, oauth_tokens, and oauth_code_verifier land via migration 002.

Before pulling: tokens and headers are plaintext in jsonb under RLS only; per-row encryption is explicitly deferred. The new mcp__<slug>__<tool> tool namespace interacts with the security-hardening commit that added TOOL USE BOUNDARIES to the system prompt - both land in the same window and should be read together. BACKEND_PUBLIC_URL must be set in any deployed environment for the OAuth callback URL to resolve. The @modelcontextprotocol/sdk@1.29.0 dependency pulls in @hono/node-server as a transitive; check licensing before shipping.

So what Worth a look if user-controlled MCP extensibility is on the roadmap. This is a security-aware implementation - RLS-scoped storage, HMAC-signed CSRF protection across the popup, PKCE - ahead of most forks in this space. The credential storage gap (plaintext under RLS) is the main thing to close before exposing this to production users.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

4 commits from nforum/mike, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
277339f6 feat(mcp): add user-configurable MCP servers (URL + custom headers) Zacharie Laik 2026-05-04 ↗ GitHub
commit body
Lets users register Streamable-HTTP MCP servers from the Settings page.
Tools discovered from each enabled server are merged into the per-request
tool set under the `mcp__<slug>__<tool>` prefix and dispatched back to the
right server via runToolCalls. Headers (e.g. `Authorization: Bearer ...`)
are stored on the row.

Backend
- New `user_mcp_servers` table (RLS owner-only) with migration 001 + the
  same DDL inlined in the one-shot schema.
- `lib/mcp/{client,servers,types}.ts`: thin wrapper around
  @modelcontextprotocol/sdk's StreamableHTTPClientTransport, per-request
  loader, schema converter (MCP `inputSchema` -> Mike's OpenAIToolSchema)
  with 64-char tool-name truncation.
- `runLLMStream` and `runToolCalls` accept an optional `mcpServers` list;
  chat routes load + close clients in a try/finally.
- New `routes/mcpServers.ts` mounted at `/user/mcp-servers` with
  GET/POST/PATCH/DELETE plus `/test` for connect-and-list-tools probing.
  All handlers filter by user_id since the backend uses the service role
  key.

Frontend
- New `account/mcp` settings tab and page: add/edit/delete servers, toggle
  enabled, run test connection. Header values are masked in the form
  (type=password) and the GET endpoint returns header keys only.
- `mikeApi.ts`: typed CRUD wrappers.

Notes for review
- Header values are stored via the same RLS-only model used today for
  `user_profiles.claude_api_key`/`gemini_api_key`. Per-row encryption is
  a clean follow-up.
- OAuth-protected MCP servers are out of scope for this PR; a follow-up
  will add an OAuth 2.1 client (PKCE + dynamic client registration) so
  spec-conformant servers (e.g. https://legaldatahunter.com/mcp) work
  without manual token paste.
fad06aca feat(mcp): rename to Connectors, prettier tool calls, observability Zacharie Laik 2026-05-04 ↗ GitHub
commit body
Polish on top of the initial MCP support commit. Same scope (no auth/marketplace yet),
just smoothing the rough edges from a real test session.

UX
- Settings tab + chat-input button renamed to "Connectors". MCP is mentioned in
  the page description (with a link to modelcontextprotocol.io) so the protocol
  is still discoverable.
- New `Connectors` button next to Documents / Workflows in the chat input opens a
  popover with a per-server toggle switch. Hides itself when the user has no
  connectors configured.
- Tool calls in chat now render `Running <Server> · <tool>` (friendly) instead of
  the raw `mcp__<slug>__<tool>` prefix; the original name still routes correctly.
- After each MCP tool call, a result block shows ✓/✗ + first line of output, with
  a "Show details" toggle that expands pretty-printed JSON arguments and the full
  text output.
- New connectors auto-discover their tool list immediately on save (no extra Test
  click). Re-enabling a disabled connector also auto-tests.
- Settings card redesigned: status pill, header chips, expandable per-tool
  descriptions with More/Less. Sanitises Name field if it looks like a Bearer
  token was pasted into it (best-effort safety net).
- Amber "only add connectors you trust" notice at the top of the page and a
  compact restated form inside the Add panel.

Backend
- New SSE event type `mcp_tool_result` with `{ server, tool, ok, args, output }`.
  args/output capped at 4 KB each before persistence (the model still receives
  the untruncated tool output - only the user-visible preview is capped).
- `tool_call_start` now optionally carries `display_name`; the renderer
  prefers it.
52749e6e feat(mcp): OAuth 2.1 sign-in for connectors Zacharie Laik 2026-05-05 ↗ GitHub
commit body
Adds OAuth 2.1 (RFC 9728 discovery + RFC 7591 dynamic client registration +
PKCE) so spec-conformant MCP servers like https://legaldatahunter.com/mcp
work without the user pasting any token.

The MCP TypeScript SDK does almost all the heavy lifting via its `auth()`
helper - discovery, DCR, PKCE, code exchange, refresh. We only have to plug
in an OAuthClientProvider whose getters/setters read and write the row's
oauth_* columns, plus an HMAC-signed state token so the popup callback can
look the row up without a server-side session.

DB
- migration 002 + inline patch to the one-shot:
  alter table user_mcp_servers
    add auth_type ('headers'|'oauth' default 'headers'),
    add oauth_metadata jsonb,
    add oauth_tokens jsonb,
    add oauth_code_verifier text;

Backend
- New `lib/mcp/oauth.ts`:
  - `DbOAuthProvider` implements OAuthClientProvider, persists everything
    on the user_mcp_servers row.
  - "initiate" mode (used by /oauth/start) captures the authorize URL into
    a property so the route can return it for the popup; "use" mode (used
    by chat) throws ReauthRequiredError when the SDK wants the user back,
    so the caller can mark the row reauth_required.
  - signOAuthState/verifyOAuthState - HMAC over user_id+server_id (5 min
    TTL) reusing DOWNLOAD_SIGNING_SECRET. No DB round-trip on callback.
- `lib/mcp/client.ts`: accepts an optional authProvider passed through to
  StreamableHTTPClientTransport - the SDK auto-attaches Authorization
  headers and auto-refreshes on 401.
- `lib/mcp/servers.ts`: builds a DbOAuthProvider for OAuth rows that have
  tokens; rows without tokens are skipped (UI surfaces a "Sign in" button
  in settings instead).
- New `routes/mcpOauth.ts` mounted at /mcp/oauth: public callback that
  verifies state, finishes the SDK auth() flow, and returns a small HTML
  page that postMessage()s the opener and closes the popup.
- `routes/mcpServers.ts`:
  - POST /:id/oauth/start kicks off discovery + DCR via the SDK and
    returns { authorize_url } for the frontend popup.
  - POST creates honor `auth_type`; PATCH/test/list now project + return
    auth_type and a boolean oauth_authorized (the access_token itself
    never round-trips to the browser).
- `BACKEND_PUBLIC_URL` env var (defaults to http://localhost:${PORT}) used
  to build the OAuth redirect URI; documented in `.env.example`.

Frontend
- `account/mcp/page.tsx`:
  - Authentication mode radio in the Add form: "API key / headers" vs
    "OAuth (auto-discover)". Headers section hides itself in OAuth mode.
  - Save button label switches to "Save & sign in" for OAuth, which
    immediately opens the authorize popup. The page polls listMcpServers
    until oauth_authorized flips, then auto-runs tool discovery.
  - Per-card status pills: "OAuth · signed in" (blue) / "OAuth · sign-in
    required" (amber). Cards in the latter state show a "Sign in" button
    instead of "Test".
  - Simplified copy per user feedback: dropped the OAuth explainer block,
    redundant "By saving..." trust pill, and helper text under Name and
    URL inputs. Single load-bearing trust warning at top of page remains.
- `mikeApi.ts`: `startMcpOauth(id)` wrapper.

Security notes for reviewers
- access_token / refresh_token / oauth_metadata are stored at-rest in
  jsonb (RLS owner-only). Per-row encryption deferred to a separate
  hardening PR - matches existing precedent for user_profiles.{claude,
  gemini}_api_key.
- State token is HMAC-signed with DOWNLOAD_SIGNING_SECRET, 5 min TTL,
  carries user_id + server_id only. CSRF-safe across the popup hop with
  no server-side session needed.
- Public client (token_endpoint_auth_method=none, PKCE-protected) - no
  client secret needed for confidential storage.
f944962a Merge PR #32: feat(mcp): add Connectors - URL+headers and OAuth 2.1 Bojan Plese 2026-05-07 ↗ GitHub

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-50.md from inside the repo you want the changes in.

⬇ Download capture-thread-50.md