Skip to content

Implement MCP OAuth 2.0/2.1 authentication with Dynamic Client Registration (fixes 401 on protected servers like Notion)#2

Draft
Copilot wants to merge 17 commits intomainfrom
copilot/add-oauth-2-support-for-mcp
Draft

Implement MCP OAuth 2.0/2.1 authentication with Dynamic Client Registration (fixes 401 on protected servers like Notion)#2
Copilot wants to merge 17 commits intomainfrom
copilot/add-oauth-2-support-for-mcp

Conversation

Copy link
Copy Markdown

Copilot AI commented Apr 1, 2026

OAuth-protected MCP servers (e.g. Notion MCP at https://mcp.notion.com/mcp) return 401 Unauthorized, causing zero tools to be discovered. The client had no OAuth lifecycle: no token acquisition, no refresh, no retry. Notion's remote MCP endpoint uses OAuth 2.1 with Dynamic Client Registration (DCR) — no client_id needs to be pre-configured.

New modules

  • omlx/mcp/token_store.pyTokenData + TokenStore: persists tokens per server name using the OS keychain (keyring) with a ~/.config/omlx/mcp_tokens.json (mode 0o600) fallback. is_expired includes a 30s safety margin. TokenData now also stores registered_client_id for DCR-based servers.
  • omlx/mcp/oauth.pyMCPOAuthManager orchestrating two flows:
    • Authorization Code + PKCE (flow="pkce") — opens browser, captures redirect on an ephemeral local HTTP server
    • Device Authorization Grant (flow="device") — prints user code, polls token endpoint
    • Dynamic Client Registration (RFC 7591 / RFC 8414) — when no client_id is configured, the manager auto-discovers the OAuth Authorization Server metadata (/.well-known/oauth-authorization-server) and registers the client on the fly to obtain a client_id. The dynamically registered client_id is persisted in TokenData and reused for token refresh.
    • Auto-refresh on expiry; get_token_info() returns metadata only (raw tokens never exposed)

Modified files

  • omlx/mcp/types.py — new MCPAuthConfig dataclass (type, client_id, auth_url, token_url, scopes, audience, device_auth_url, token_store); client_id, auth_url, and token_url are optional — when omitted, DCR auto-discovery is used. MCPServerConfig gains optional auth field; MCPServerStatus gains auth_state.
  • omlx/mcp/config.py — parses the auth dict block into MCPAuthConfig (backward-compatible; servers without auth are unaffected). Keys prefixed with _ (e.g. _comment) are stripped before parsing so documentation annotations in JSON configs don't cause errors.
  • omlx/mcp/client.pyconnect() injects Authorization: Bearer header before connecting, and retries once on any 401 with a force-refreshed token. _connect_sse() now passes headers to the MCP SDK's sse_client. get_status() reports auth_state. Server URL is forwarded to the OAuth manager to enable DCR discovery.
  • omlx/cli.py — new omlx mcp command:
    omlx mcp login <server> [--flow pkce|device]
    omlx mcp logout <server>
    omlx mcp status
    

Notion configuration

Notion's remote MCP server uses OAuth 2.1 with DCR — no developer portal registration or manual client_id is required. The minimal config is:

"notion": {
  "transport": "streamable-http",
  "url": "https://mcp.notion.com/mcp",
  "auth": { "type": "oauth2" }
}

Then run omlx mcp login notion. The client discovers https://mcp.notion.com/.well-known/oauth-authorization-server, registers itself, opens a browser for consent, and stores the resulting tokens. For developers with a registered public OAuth application, explicit client_id/auth_url/token_url fields are still supported.

Copilot AI linked an issue Apr 1, 2026 that may be closed by this pull request
Copilot AI changed the title [WIP] Add OAuth 2.0 support for MCP streamable-http servers Implement MCP OAuth 2.0 authentication (fixes 401 on protected servers like Notion) Apr 1, 2026
Copilot AI requested a review from ChristianPraiss April 1, 2026 14:45
@ChristianPraiss
Copy link
Copy Markdown

@copilot for notion there is not real oauth configuration, just the /mcp endpoint. how would that be handled here?

Copy link
Copy Markdown
Author

Copilot AI commented Apr 1, 2026

@copilot for notion there is not real oauth configuration, just the /mcp endpoint. how would that be handled here?

Great point. Notion has two modes:

Option A — Internal integration token (most common for personal use): you get a static token from https://www.notion.so/profile/integrations and pass it directly as a header. No auth block or OAuth flow needed at all:

"notion": {
  "transport": "streamable-http",
  "url": "https://mcp.notion.com/mcp",
  "headers": {
    "Authorization": "******"
  }
}

Option B — Public OAuth app: only needed when you've registered a public Notion OAuth application (has a client_id). This uses the full PKCE flow via omlx mcp login notion.

Updated mcp.example.json to document both options clearly (commit 05714b7). The _comment fields in the JSON are stripped automatically by the config parser so they don't cause errors.

@ChristianPraiss
Copy link
Copy Markdown

@copilot no there is also no token. could it be some dynamic client registration thing?

Copy link
Copy Markdown
Author

Copilot AI commented Apr 1, 2026

@copilot no there is also no token. could it be some dynamic client registration thing?

Yes, exactly — Notion's remote MCP server uses OAuth 2.1 with Dynamic Client Registration (RFC 7591 / RFC 8414). There's no client_id to configure; the MCP client registers itself automatically on first login.

Implemented in commit 2689a5f:

  • _discover_oauth_metadata() — fetches /.well-known/oauth-authorization-server from the server's base URL (with RFC 8414 path-based fallback)
  • _register_dynamic_client() — POSTs to the registration_endpoint to obtain a client_id on the fly (no pre-registration in Notion's developer portal)
  • registered_client_id is persisted in TokenData so it's reused for token refresh without re-registering
  • MCPAuthConfig no longer requires client_id/token_url (they're discovered at runtime)

The minimal config for Notion is now just:

"notion": {
  "transport": "streamable-http",
  "url": "https://mcp.notion.com/mcp",
  "auth": { "type": "oauth2" }
}

Then run omlx mcp login notion — it discovers the OAuth endpoints, registers the client, opens a browser for consent, and stores the tokens.

Copilot AI changed the title Implement MCP OAuth 2.0 authentication (fixes 401 on protected servers like Notion) Implement MCP OAuth 2.0/2.1 authentication with Dynamic Client Registration (fixes 401 on protected servers like Notion) Apr 1, 2026
@ChristianPraiss ChristianPraiss force-pushed the copilot/add-oauth-2-support-for-mcp branch from acadfa0 to ecbc7d4 Compare April 3, 2026 07:04
Copilot AI and others added 13 commits April 5, 2026 12:29
Agent-Logs-Url: https://github.com/convidera/omlx/sessions/16fe44b2-7940-4be4-8cc4-0fee521779b1

Co-authored-by: ChristianPraiss <6369555+ChristianPraiss@users.noreply.github.com>
…flow

Agent-Logs-Url: https://github.com/convidera/omlx/sessions/e7f02143-b251-4cf7-bdfb-429b7f97e6f2

Co-authored-by: ChristianPraiss <6369555+ChristianPraiss@users.noreply.github.com>
…e Notion

Agent-Logs-Url: https://github.com/convidera/omlx/sessions/5ffcc387-cbcc-485b-96ca-81e467724689

Co-authored-by: ChristianPraiss <6369555+ChristianPraiss@users.noreply.github.com>
When a request includes `execute_mcp_tools: true` (sent by the built-in
chat UI), the server now executes MCP tool calls in a loop and streams
the final model response — the client never sees intermediate tool_calls.

All other clients (API scripts, OpenClaw, etc.) get the standard OpenAI
behaviour: tool_calls are returned for the client to handle.

Also fixes two bugs in the tool message construction:
- Use "" instead of None for assistant content to avoid Jinja2 TypeError
- Guard json.loads() against returning None ("null") which broke the
  Harmony chat template's `in` containment checks

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
`omlx mcp login/logout/status` now respect the configured MCP config
path from settings when --mcp-config is not explicitly provided.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Emit tool_call_event chunks from server during MCP execution loops,
  containing tool name, arguments, and result
- Add tooluse-container CSS and renderToolUseBlock() JS for collapsible
  tool call display (matching the thinking block style)
- Rewrite renderMarkdown/renderStreamingMarkdown to process <think> and
  <tooluse> tags in document order via unified _renderSequential()
- Replace full-innerHTML streaming updates with incremental DOM appends:
  completed blocks become permanent .streaming-stable nodes, only the
  active tail is updated in-place, preventing flicker between tool calls

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- New /admin/api/mcp/* routes: list servers with status/auth info,
  reconnect, authenticate (OAuth PKCE), logout, and get/save config
- OAuth PKCE flow runs entirely in-browser: server returns auth_url,
  frontend opens a popup, callback page posts message on completion
- New MCP tab in admin navbar and _mcp.html template showing server
  cards with transport/state/auth badges, action buttons, and a
  collapsible raw config JSON editor
- DASHBOARD_MAIN_TABS updated to include 'mcp'; tab loads servers
  automatically on activation

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…erError

- Fix Lucide icon processor in base.html to skip Alpine.js attributes
  (@, x- prefixes) when copying attributes to replacement SVG elements,
  preventing InvalidCharacterError: Invalid qualified name: '@mouseenter'
- Replace all :class/:show bindings on <i data-lucide> elements with
  inline SVG spinners on wrappers — Lucide's setInterval poller replaces
  <i> elements and severs Alpine reactivity, making spinners and disabled
  states invisible; now all loading states use text changes + inline SVGs
- Add tools list to GET /admin/api/mcp/servers response (name,
  description, param_count per tool from connected clients)
- Show tools per server in an expandable section (click tool count badge)
- Add mcpExpandedServer state to track which server is expanded

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
After auth completes, the server-side reconnect is an asyncio.create_task
so the server is still 'connecting' when the first loadMcpServers() fires.
Now poll every 1.5s (up to 12 attempts / 18s) until the server leaves the
'connecting' state, updating the message to 'Connected successfully' or
'Authenticated' accordingly.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- POST /admin/api/mcp/reload — stops current MCP manager, loads config
  from disk, and restarts with new config (no server restart needed)
- 'Reload Config' button in the MCP tab header with spinner feedback
- Banner message shows reload result (server/tool count on success,
  error detail on failure), auto-dismisses after 5s

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Each transport (stdio, sse, streamable-http) is now run inside its own
asyncio.Task via _run_transport_in_task / _start_transport_task. The
context manager's __aenter__ and __aexit__ are called within that single
Task, satisfying anyio's invariant that cancel scopes must be exited from
the same task that entered them.

Disconnect is signalled via asyncio.Event; _cleanup_resources sets the
event and awaits the task (5 s timeout then cancels) instead of calling
__aexit__ directly from the caller's task.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Tool cards show 3-line clamp with pointer cursor and hover highlight
- Clicking a card opens a modal overlay with the full markdown description
- Modal dismisses on backdrop click, X button, or Escape key

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
ChristianPraiss and others added 4 commits April 5, 2026 12:47
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ind class

max-h-[80vh] is not in the precompiled Tailwind CSS, so maxHeight resolved
to 'none' and the modal expanded to full content height. Inline style
max-height: 80vh is always applied regardless of the CSS build.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Persist discovered token_url in TokenData so token refresh works for
  servers using Dynamic Client Registration (fixes empty URL error)
- Reuse existing registered_client_id in OAuth start flow to prevent
  redundant DCR on repeated authentication attempts
- Only unpack tool result JSON content back to dict (not list/scalar)
  for gpt_oss, preventing Jinja2 |items filter failure on non-mappings

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ckaging fixes

- Add omlx/mcp/builtins.py with BuiltinToolProvider exposing builtin__fetch
  and builtin__web_search (DuckDuckGo) — available without any MCP server
- Integrate builtins into MCPClientManager (get_all_tools, execute, _find_server)
- Extract inject_tool_calling() into omlx/utils/tokenizer so both VLM and
  batched engines share the same logic; patch gemma4 regex to match hyphenated
  tool names; handle mlx-lm private-attr API vs plain instance attrs
- Switch packaging/build.py from pipx run to uvx for venvstacks commands
- Add ddgs and truststore dependencies to pyproject.toml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ChristianPraiss ChristianPraiss force-pushed the copilot/add-oauth-2-support-for-mcp branch from ecbc7d4 to 6ce853f Compare April 5, 2026 16:34
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.

Implement MCP Authentication

2 participants