Skip to content

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

Draft
ChristianPraiss with Copilot wants to merge 46 commits into
mainfrom
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
ChristianPraiss with Copilot wants to merge 46 commits into
mainfrom
copilot/add-oauth-2-support-for-mcp

Conversation

Copilot AI commented Apr 1, 2026

Copy link
Copy Markdown

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?

Copilot AI commented Apr 1, 2026

Copy link
Copy Markdown
Author

@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?

Copilot AI commented Apr 1, 2026

Copy link
Copy Markdown
Author

@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 2 times, most recently from ecbc7d4 to 6ce853f Compare April 5, 2026 16:34
yohann-bearzi and others added 5 commits April 12, 2026 10:13
…oE models

Quantizing Qwen3.5-397B-A17B (and similar large MoE checkpoints) on Apple
Silicon failed in two ways:

1. mx.load and mx.quantize each issue a single Metal dispatch per tensor,
   which exceeds the command-buffer timeout on 512x2048x4096 expert tensors.
2. mlx-vlm's Model.sanitize() returns a transformed dict containing every
   weight, which OOMs a 512 GB Mac on a 397B-parameter model.

Changes:

* Replace eager mx.load with _LazyTensorIndex, a memory-mapped view over
  safetensors files. Tensors are read on demand via _LazyTensor._load_rows,
  which sub-chunks numpy->MLX conversion to stay under both the device's
  max_buffer_length (queried via mx.device_info) and MLX's int32 element
  count limit. Chunk budgets scale with hardware: ~14 GiB per chunk on M3
  Ultra, ~875 MiB on M1, with safe fallbacks if Metal info is unavailable.

* Add _quantize_chunked, a drop-in replacement for mx.quantize that
  bisects on dim 0 and concatenates the per-chunk results. Same buffer
  and element-count budgets. mx.synchronize + mx.clear_cache between
  chunks drains the command queue.

* Add _StreamingPlan, a streaming sanitizer for VLM models that builds a
  per-output-tensor transformation plan from the lazy index without
  materializing any weights. Implements the Qwen3.5 MoE Model.sanitize
  logic (drop mtp.*, optional lm_head tied-embedding drop, fused
  gate_up_proj split on axis -2, model.language_model -> language_model.model
  and model.visual -> vision_tower renames, lm_head -> language_model.lm_head,
  conv1d.weight axis (2,1) permute, +1.0 on 1D norm weights, and patch_embed
  Conv3d (out,in,T,H,W) -> (out,T,H,W,in) permute). Quantize loop pulls
  one tensor at a time via pop(), peak RAM stays bounded.

Tested end-to-end on M3 Ultra 512GB with Qwen3.5-397B-A17B oQ4: model
loads directly in mlx-vlm with no post-hoc converter, generates coherent
output at ~30 tok/s, peak memory 229 GB.

LLM-only models still go through the original _build_model_sanitizer
path; only VLM checkpoints (architectures containing 'ForConditionalGeneration')
use the streaming plan.
Replace JinaForRanking score-token logit scoring with the upstream listwise hidden-state projector pipeline so multilingual reranking behavior matches the model contract. Also classify JinaForRanking as a directly supported reranker architecture to avoid false negatives from CausalLM directory-name heuristics.
Replaces Qwen3.5-specific _StreamingPlan with a generic discovery mechanism
that runs the real Model.sanitize() on _TrackedTensor proxies. The proxies
record shape/dtype/lineage without materializing GPU data, and a set of
monkey-patched mx ops (stack/concatenate/split/moveaxis/transpose) capture
the transforms. Result is a plan of output_key -> {sources, transform, shape}
that _DiscoveredPlan materializes one tensor at a time with chunked stacking.

Addresses review feedback on jundot#737:

- _StreamingPlan no longer corrupts non-Qwen VLMs — it's not even in the
  activation path anymore. Discovery handles every model mlx-lm/mlx-vlm
  supports (tested on Gemma 4 E2B, Trinity Nano AfMoE, Qwen 3.5 397B MoE).

- _LazyTensorIndex.pop() now materializes to mx.array instead of returning
  _LazyTensor, so third-party sanitizers that call mx.stack on popped
  tensors work correctly.

- _LazyTensorIndex.__iter__ and items() now include _overrides keys so
  sanitize-written tensors are visible during iteration.

- _LazyTensor.__getitem__ and _materialize_source now handle 0-dim scalars
  (needed for Gemma 4's scaling factors).

Tested end-to-end:
- Gemma 4 E2B oQ8: generates coherent text
- Qwen 3.5 397B: unchanged behavior (discovery produces same plan
  _StreamingPlan did)
@ChristianPraiss
ChristianPraiss force-pushed the copilot/add-oauth-2-support-for-mcp branch from ed039a1 to dcc60f9 Compare April 15, 2026 10:55
jundot and others added 6 commits April 16, 2026 10:10
…port

Replaces Qwen3.5-specific _StreamingPlan activation with a generic
discovery mechanism that works for any model architecture:

Discovery-based streaming sanitizer:
- _TrackedTensor: fake tensor proxy that records shape/dtype/lineage
  during a sanitize() dry run. Supports reshape, astype, arithmetic,
  None-broadcasting indexing, and slice patterns.
- _discover_sanitize_plan(): runs the real Model.sanitize() on tracked
  tensors with monkey-patched mx ops (stack/concatenate/split/moveaxis/
  transpose/from_fp8/pad/eval/clear_cache). Produces a transform plan
  without materializing any GPU data. Cost: <1s even on 42K-tensor models.
- _DiscoveredPlan: dict-like wrapper that materializes one tensor at a
  time using the discovered plan, with chunked stacking (16 experts per
  chunk) to bound peak memory on large MoE models.
- Graceful fallback to eager sanitize if discovery fails.

FP8 source model support (MiniMax-M2.7, DeepSeek FP8, etc.):
- _LazyTensor: F8_E4M3 and F8_E5M2 dtype support — loaded as uint8
  so sanitize can call mx.from_fp8() on them.
- _streaming_fp8_dequant(): processes FP8 weight/scale_inv pairs one
  at a time, runs block-scaled dequant (from_fp8 + pad + reshape +
  scale multiply + slice), writes bf16 results to scratch safetensors
  shards on disk, and re-indexes the lazy loader. Peak RAM bounded to
  one tensor at a time regardless of model size.
- FP8 sources bypass discovery (dequant chain is too complex to replay)
  and use eager sanitize after streaming dequant completes.

Other fixes:
- _LazyTensorIndex.pop() materializes mx.array instead of returning
  raw _LazyTensor objects.
- _LazyTensorIndex.__iter__ and items() include _overrides keys.
- _LazyTensor.__getitem__ and _materialize_source handle 0-dim scalars
  (needed for Gemma 4 scaling factors).

Tested end-to-end on M3 Ultra 512GB:
- Gemma 4 E2B oQ2-8: coherent output at all levels
- Trinity Nano Preview (AfMoE) oQ4-8: coherent output
- Qwen 3.5 397B oQ2-8: unchanged behavior
- MiniMax-M2.7 (FP8 source): streaming dequant completes, oQ8 builds
The Gemma 4 chat template checks for tool_responses on the current
message (the assistant turn that issued tool_calls) BEFORE falling
back to a forward-scan for role='tool' messages.  The previous code
created a separate assistant message for tool_responses, which caused
both template paths to miss — producing a corrupt bare <|tool_response>
tag and making the model loop on the same tool call indefinitely.

Attach tool_responses directly to the assistant message that already
has tool_calls (via the existing out_msg reference).  This is the
companion fix to jundot#789 which preserved tool fields through the VLM
engine; this fix ensures the message extractor produces the correct
structure before those fields reach the template.

Tested with Gemma 4 31B IT on multi-turn agentic conversations with
tool use — model now sees tool results and stops looping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ting (jundot#796)

get_message_json() converts string content to list format for VLM model
types, which breaks simplified chat templates that only handle strings.
Collapse text-only list content back to plain string after formatting.
…sponses-same-message

fix: attach tool_responses to same assistant message as tool_calls
LSUIElement=true in Info.plist conflicts with the runtime
Regular->Accessory activation policy switch, causing macOS
ControlCenter to move the NSStatusItem to a blocked list.

- remove LSUIElement from Info.plist (dock hiding already handled
  by setActivationPolicy_ at runtime)
- add autosaveName so ControlCenter persists visibility prefs and
  existing blocked users get a fresh item identity
- add delayed isVisible check to guide users to System Settings
  if ControlCenter still blocks the icon

closes jundot#725, closes jundot#806
… CLI

v0.3.6 relied on NSStatusItem.isVisible() and button-window frame, both
of which stay "visible" on Tahoe even when ControlCenter or the Menu Bar
toggle hides the icon. Switch to NSWindow.isVisible plus the
NSWindowOcclusionStateVisible bit, which actually flip, and log a
"menubar visibility probe" line with all raw signals for diagnostics.

- Alert now activates the app, raises itself to NSFloatingWindowLevel so
  it surfaces from an Accessory process, and adds a "View Log" button.
- Tahoe+ deep-links to System Settings > Menu Bar. Sequoia and older skip
  the Settings button (no system UI exists for third-party status items
  pre-Tahoe) and suggest restarting oMLX / checking Bartender/Ice.
- Swap showAbout_ to orderFrontStandardAboutPanelWithOptions_ with a
  clickable GitHub link via NSLinkAttributeName so About matches other
  Mac apps' centered layout.
- Route menubar logs to ~/Library/Application Support/oMLX/logs/menubar.log
  via RotatingFileHandler (the process had no file handler before).
- "omlx diagnose menubar" tails both menubar.log and server.log and
  surfaces visibility probe lines plus manual recovery steps.

Apple's sandbox blocks programmatic re-enable on Tahoe (visibility prefs
live in group.com.apple.controlcenter's Group Container, unreachable by
third parties; legacy plist writes are ignored on-device), so the focus
here is accurate detection and clear recovery guidance.

Refs jundot#725 jundot#806
@ChristianPraiss
ChristianPraiss force-pushed the copilot/add-oauth-2-support-for-mcp branch from d6c4b9d to 633c9ff Compare April 17, 2026 07:52
jundot and others added 26 commits April 17, 2026 20:52
…polish

Keeps iterating on the Tahoe 26.x menubar visibility story from jundot#725 / jundot#806.
The dev3/dev4/dev5 work here is one logical change: give the "Menubar Icon
Hidden" alert a user-fixable path for the two distinct failure modes users
have actually reported.

Detection (packaging/omlx_app/app.py):
- Extract NSStatusItem creation into _create_status_item() with stable
  accessibility attributes (identifier / title / label / tooltip) so AX
  enumerators can find us.
- Add a session-limited _recreate_status_item() recovery: if the 3s
  post-launch probe reports hidden, remove the status item and recreate
  it once before alerting, then reprobe after 1s. Covers the Tahoe
  registration race reported in Maccy jundot#1224 and Stats #2734.
- Defer the Regular to Accessory policy switch to the next runloop tick
  via a one-shot NSTimer and dedicated switchToAccessoryPolicy_ selector,
  so the status item registers while the process is still Regular.
- Drop the mid-session visibility probe from healthCheck_; fullscreen
  video, slideshows and the like triggered false-positive hidden alerts.
  The launch-time probe (+ one-shot recreate) is the only check now.
- Probe log emits only when hidden is detected, with pid / AX signals /
  frame / recreated flag. No more verbose INFO spam on every tick.

StatusKit Auto-Fix (packaging/omlx_app/app.py):
- When the alert fires on Tahoe, user can click Auto-Fix to flip
  com.omlx.app's isAllowed to True in
  ~/Library/Group Containers/group.com.apple.controlcenter/Library/
  Preferences/group.com.apple.controlcenter.plist and restart
  ControlCenter. Mechanism cross-referenced against
  anthropics/claude-code#42019 for Claude for Desktop.
- Full Disk Access gate with a dedicated second dialog that deep-links
  to System Settings > Privacy & Security > Full Disk Access when FDA
  hasn't been granted yet.
- Atomic write via tmp + os.replace, plist validation re-read after
  write, automatic restore from backup
  (~/Library/Application Support/oMLX/backups/statuskit-<ts>.plist) if
  the fresh file can't be parsed. killall ControlCenter at the end to
  reload.

Bartender conflict (packaging/omlx_app/app.py):
- Detect Bartender via NSWorkspace.runningApplications() with a
  com.surteesstudios.Bartender prefix match (covers 4 / 5 / future).
- If Bartender is active when the alert would fire, swap in
  _show_bartender_conflict_alert() with Bartender-specific messaging
  (no Auto-Fix / no Open Settings; neither helps) and a pointer to
  Ice (https://icemenubar.app) as an alternative menubar manager.

About panel (packaging/omlx_app/app.py):
- Swap the NSAlert-based showAbout_ for
  orderFrontStandardAboutPanelWithOptions_ so the layout matches the
  standard Mac About dialog, with the GitHub URL embedded as a
  clickable NSLinkAttributeName in the Credits string.

Info.plist (packaging/build.py):
- Add NSPrincipalClass = NSApplication, which Xcode-generated bundles
  include by default but our manual bundle was missing.
- Rebuild NSHumanReadableCopyright to use the current build year
  automatically, drop the version suffix (the About panel shows it
  separately), and add the Apache 2.0 license notice on its own line.

Refs jundot#725 jundot#806 jundot#821
…#837)

Adds 4 new intelligence benchmarks (BBQ, MathQA, MMLU-Pro, SafetyBench) with bundled JSONL data, plus UI grouping in the accuracy benchmark dashboard (Knowledge / Commonsense & Reasoning / Math / Coding / Safety & Alignment). QuestionResult.category now flows from scheduler through SSE payload into CSV and TXT downloads for per-subject drill-down.

Co-authored-by: michal-stengg <153718997+michal-stengg@users.noreply.github.com>
…ents (jundot#814)

* feat: add preserve_thinking support for Qwen 3.6+ models

Qwen 3.6 chat templates strip <think> blocks from historical assistant
turns by default, which breaks KV prefix cache reuse and prevents the
model from seeing its own prior reasoning. Add preserve_thinking=True
as an auto-detected default when the template supports it.

- Auto-detect preserve_thinking support from chat template
- Auto-set preserve_thinking=True when thinking is active
- Add preserve_thinking to ModelSettings for manual override
- Pipe through engine_pool, admin routes, and all server endpoints

* fix: reconstruct <think> blocks from reasoning_content for external clients

preserve_thinking only works if <think> blocks are present in historical
assistant turns. External OpenAI/Anthropic-compatible clients receive
clean content (thinking stripped into reasoning_content by the reasoning
parser) and echo back only content on the next turn — so the template
has nothing to preserve.

Rebuild <think> blocks server-side from client-provided thinking before
the chat template is applied:

- OpenAI path: add reasoning_content to the Message request model;
  extract_text_content and extract_multimodal_content merge reasoning
  into <think>...</think>\n\n{content} for assistant turns.
- Anthropic path: stop dropping "thinking" content blocks in all three
  convert_anthropic_to_internal sites (including the native-tool-calling
  assistant branch that previously had no handling at all). Append
  reconstructed <think> blocks in source order instead of insert-at-0
  so multiple thinking blocks keep their original ordering.
- Harmony branch left unchanged (gpt-oss uses <|channel|>analysis, not
  <think>).

Tested end-to-end with Qwen3.6-35B-A3B-8bit on both endpoints: model
correctly recalls the exact second 20-digit number generated in prior
thinking when reasoning is echoed; fabricates a different number
otherwise, confirming the fix is load-bearing.

Assisted by Claude.
…es (jundot#853)

* feat(model-settings): add per-model profile and global template data layer

- Add Profile dataclass with settings snapshot, active flag, timestamps
- Add Template dataclass with display_name and settings snapshot
- Add ModelProfile dataclass linking profiles to specific model IDs
- Add ModelSettingsManager.save_profile, update_profile, delete_profile, apply_profile
- Add list_profiles per-model and list_templates globally
- Persist profiles to model_profiles.json, templates to model_templates.json
- Atomic JSON writes with temp file + rename
- Cascade delete when model is removed
- Add profile name validator (alphanumeric, dash, underscore; 1-64 chars)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(admin): add profile and template HTTP API endpoints

- Add CreateProfileRequest, UpdateProfileRequest, CreateTemplateRequest,
  UpdateTemplateRequest, ModelSettingsRequest Pydantic models
- Add /profiles/ POST/GET (list global templates)
- Add /profiles/{name} GET/PUT/DELETE
- Add /profiles/{name}/apply POST to activate a profile
- Add /models/{model}/profiles/ GET list per-model profiles
- Add /models/{model}/profiles/{name}/apply POST
- Wire into existing admin router

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(i18n): add profile/template UI strings for 5 locales

Add all user-facing i18n strings for:
- Profile/template section labels and pills
- Inline create/edit form placeholders and buttons
- Delete confirmation prompts and action labels
- Custom label for models without an active profile

Locales: en, zh, zh-TW, ja, ko

* feat(ui): add profile/template pills to model settings modal

- Add profiles/templates section with pills in model settings modal
- Replace per-parameter colored chips with single profile chip in model list
- Support inline create/edit forms for both profiles and templates
- Delete confirmation flow with explicit confirm step
- Show active profile badge with emerald styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: restore dashboard.js with profile/template frontend logic

The feature branch was reset to main but dashboard.js was accidentally
left with the stripped-down version. Restore the full implementation:
- Alpine state: profiles[], templates[], activeProfileName, profilesDrift, CRUD flags
- applyProfileToForm(), applyTemplateToForm(), computeDrift()
- formValuesForProfile(), formValuesForTemplate() helpers
- DELETE /profiles/{name}/ PUT /profiles/{name} calls in save/delete handlers

* fix: add emerald CSS classes to Tailwind safelist for profile pills

Profile pills use emerald color scheme (bg-emerald-50, text-emerald-700,
border-emerald-200). JIT mode purges these unless explicitly listed in the
safelist. Also update compiled CSS to include the new classes.

* fix: add missing model_profiles.py module

The model_profiles.py file containing Profile/Template dataclasses,
field allowlists, and the validate_profile_name helper was not included
in the cherry-pick. Re-create it from the original commits (4f52f46, 97727fd).

* fix: add active_profile_name field to ModelSettings dataclass

The field was missing from the dataclass definition, causing it to be
dropped when applying a profile. The apply_profile manager method was
setting merged["active_profile_name"] but ModelSettings.from_dict()
was stripping it because the field didn't exist on the dataclass.

* feat: eliminate field allowlist triplication

- Add GET /admin/api/profile-fields endpoint serving
  UNIVERSAL_PROFILE_FIELDS and MODEL_SPECIFIC_PROFILE_FIELDS
  from model_profiles.py as single source of truth
- dashboard.js fetches field lists on init (loadProfileFields)
- Remove hardcoded UNIVERSAL_PROFILE_FIELDS / MODEL_SPECIFIC_PROFILE_FIELDS
- applyProfileToForm and formValuesForTemplate now use
  profileFields.universal / profileFields.model_specific
- Fix Pydantic mutable default: use Field(default_factory=dict)
  on CreateProfileRequest.settings and CreateTemplateRequest.settings
- Fix applyProfileToForm: r.json() was a Promise (not await'd);
  UI state now only updated after r.ok; 401 redirect handled
- model_settings.py docstring updated with all fields
- Add test for /admin/api/profile-fields endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(profile): 4 high-priority robustness and drift issues

1. formValuesForProfile driven by allowlist (dashboard.js)
   Iterate profileFields.universal + model_specific instead of hand-writing
   a 50-line object. Adding a field to model_profiles.py now automatically
   covers it in the form serializer without a JS change.

2. Write-then-mutate rollback (model_settings.py)
   _save_profiles now serializes before writing; if write fails, in-memory
   state is unchanged. Also clean up temp file on error.

3. Rename-cascade save order (model_settings.py)
   _save_profiles() now runs BEFORE _save() so that if profiles write throws,
   settings update is not persisted (operation is atomic). Previously
   _save() ran first, leaving model_settings.json pointing to the new name
   while model_profiles.json still had the old name.

4. Apply-race guard (dashboard.js)
   Increment _applySeq before fetch; discard response if seq has advanced
   by the time it resolves. Prevents activeProfileName flipping to an earlier
   pill's name when clicks race.

* test(profiles): catch stale allowlist entries for removed fields

Guard test now also fails when a ModelSettings field is removed without
cleaning its entries out of UNIVERSAL_PROFILE_FIELDS,
MODEL_SPECIFIC_PROFILE_FIELDS, or EXCLUDED_FROM_PROFILES.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Xingcheng Sun <xingchengsun@Wheres-my-Laptop.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Change step from 5 to 1 for Memory Limit (Models Only) and Cold Cache
Limit (SSD Cache) sliders so users can pick any integer percent, matching
the Hot Cache slider. Storage format (integer GB string) is unchanged.
mlx-lm#1171 changed the MiniMax M2 tool parser to return a list when a
single <minimax:tool_call> block contains multiple <invoke>s. Without
list/dict flattening in api/tool_calling.py, parallel tool calls were
silently dropped via AttributeError swallowed by the existing except.

Flatten parser results in the native path using the same isinstance(list)
pattern already used in the Gemma 4 fallback. Add regression tests for
single-dict, multi-list, and multi-block-multi-invoke cases.

Also picks up BatchKVCache/BatchRotatingKVCache.extend() batch-dim fix
(jundot#1141) and the tree_reduce import fix (jundot#1165) from the same bump.
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>
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 633c9ff to a5587d6 Compare April 24, 2026 09:30
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

9 participants