[Docs] Fix "Page Not Found" link for Anthropic endpoint (#23349)

* fix(anthropic): enforce type:'object' on tool input schemas

Anthropic's API requires all tool input_schema to have type:'object'
at the root level. When OpenAI-format tools have parameters with a
missing or non-'object' type field (common with MCP tool servers),
the schema was passed through unchanged, causing Anthropic to reject
with: 'tools.N.custom.input_schema.type: Input should be object'.

The existing default handles the case where parameters is entirely
missing, but does not normalize schemas that ARE provided with a
wrong or absent type field.

Fix: After extracting _input_schema in _map_tool_helper(), ensure
type is set to 'object' and properties exists. This matches the
normalization already done implicitly by the Bedrock handler.

Added 4 unit tests covering: missing type, wrong type, valid schema
(no-op), and entirely missing parameters.

Related issues: #12020, #64, #1671

* fix(anthropic): deduplicate tool_result messages by tool_call_id

Anthropic requires exactly one tool_result per tool_use. When
conversation history (e.g. from session resume/checkpoint restore)
contains duplicate tool result messages with the same tool_call_id,
the API rejects with: 'each tool_use must have a single result.
Found multiple tool_result blocks with id: <id>'.

This is already handled for Bedrock via _deduplicate_bedrock_tool_content()
but was missing from the Anthropic direct and Vertex AI partner paths,
which share sanitize_messages_for_tool_calling().

Fix: Add Case D to sanitize_messages_for_tool_calling() — after the
existing orphan detection passes, scan for duplicate tool_call_ids
and keep only the last occurrence (most complete result).

Added 3 unit tests: dedup with duplicates, no-op with unique IDs,
and behavior when modify_params=False.

Related issues: #11804, #11029, #6836, #1782, #151

* fix: shallow copy input_schema to avoid caller mutation + add mutation guard test

Addresses Greptile review:
- dict(_input_schema) before mutation prevents cross-provider state leakage
- Test asserts original tool parameters dict is unchanged after call

* feat: add qwen3.5 series for openrouter

* fix: typo on max_output_tokens and max_tokens from qwen3.5 series

* chore: fix

* chore: fix

* [Test] UI - Logs: Add unit tests for 5 untested view_logs components

Add vitest tests for TypeBadges, ErrorViewer, ConfigInfoMessage, TimeCell, and TruncatedValue covering rendering, user interactions, and edge cases.

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

* Rename 'Team-Based Guardrails' to 'Team Bring-Your-Own Guardrails' (#23307)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* feat(chat-ui): responses API + MCP tool execution in /chat (#23297)

* feat(ui): add Chat UI v0 — standalone LiteLLM-branded chat window

Adds a full chat UI accessible from the sidebar Chat link (opens in new tab).
- Standalone route at /chat (outside dashboard layout — no Navbar/Sidebar chrome)
- Claude.ai-style layout: model selector top-left, LiteLLM logo center, settings top-right
- Greeting with time-of-day, centered input card, suggestion chips (Write/Learn/Code/Brainstorm)
- Sliding conversation history sidebar with Cmd+K search, rename, delete, date grouping
- localStorage-backed conversation persistence (litellm_chat_history_v1)
- Streaming completions via makeOpenAIChatCompletionRequest with AbortController stop support
- MCP server picker (toggle servers on/off per conversation)
- LiteLLM aesthetic: white/light-gray background, Ant Design blue (#1677ff) primary, system font
- Sidebar2: Chat menu item opens in new tab via window.open

* feat(chat-ui): responses API + MCP tool execution display

- Switch /chat from chat completions to responses API (previous_response_id session chaining)
- Add MCP server picker with search filter in chat input bar
- Show MCP tool call events (list_tools + call_tool) inline in chat via MCPEventsDisplay
- Add tool chip strip showing available tools when MCP servers are selected
- Non-blocking MCP toggle: server added immediately, verification in background (works for no-auth MCPs like deepwiki)
- Add truncateAfterMessage to useChatHistory for edit/retry
- Sync activeConversationId on URL change (fixes stale conversation on new chat)
- Add "Open Chat" shortcut button to sidebar

* fix(chat-ui): switch to responses API, remove dead code, add tests

- Switch handleSend from makeOpenAIChatCompletionRequest to makeOpenAIResponsesRequest with previous_response_id session chaining
- Add responsesSessionId state; reset to null when starting a new conversation
- Remove unused ChatInputBar.tsx and ModelSelector.tsx (dead code)
- Add tests/test_litellm/test_chat_ui_responses_session.py covering previous_response_id forwarding and signature validation

* fix(chat-ui): address greptile review issues

- Reset responsesSessionId when activeConversationId changes (not just on new conversation)
- Wire onMCPEvent callback into makeOpenAIResponsesRequest; render MCPEventsDisplay below messages
- Clear mcpEvents on each new send
- Explicitly filter history to user/assistant roles only (no tool-role casting)
- Remove duplicate "Chat" menu item from sidebar (pinned button serves same purpose)
- Make Sider a flex column so "Open Chat" button actually pins to bottom
- Fix tests to intercept real HTTP requests and assert previous_response_id in body

* fix(chat-ui): address greptile review feedback (greploop iteration 1)

- Fix duplicate context: when responsesSessionId is set, only send the
  new user message as input (prior context is already server-side via
  session chaining). Full history is still sent on the first turn.
- Fix ephemeral MCP events: store events per-message in ChatMessage.mcpEvents
  instead of ephemeral component state. Events now survive across turns
  and render inline below each assistant response via MCPEventsDisplay.
- Remove stale mcpEvents useState and ephemeral panel at bottom of chat.

* fix(chat-ui): address greptile review feedback (greploop iteration 2)

- Fix stale session on edit/retry: derive previousResponseId as null when
  historyOverride is set so edit/retry always starts a fresh Responses API
  session rather than chaining off a now-invalid prior session
- Fix unsafe MCPEvent cast: import MCPEvent directly from MCPEventsDisplay
  into types.ts and type ChatMessage.mcpEvents as MCPEvent[], eliminating
  the bare 'as MCPEvent[]' cast in ChatMessages.tsx

* fix(chat-ui): fix MCPEvent layering, batch localStorage writes, module-level test imports

- Move MCPEvent interface definition into chat/types.ts (single source of truth)
- MCPEventsDisplay.tsx now imports MCPEvent from types.ts instead of defining it locally
- Batch MCP event localStorage writes: accumulate during stream, persist once in finally
- Move test imports to module level per PEP 8 convention

* fix(chat-ui): fix MCPEvent import path and rename truncateFromMessage

- responses_api.tsx now imports MCPEvent directly from chat/types (not via MCPEventsDisplay re-export)
- Remove the now-unnecessary MCPEvent re-export from MCPEventsDisplay.tsx
- Rename truncateAfterMessage → truncateFromMessage: the function removes the target message and all subsequent ones (not just what comes after), so the new name accurately describes the behavior

* fix(responses-api): fix whitespace token filter and MCP server URL construction

- Drop the delta.trim() whitespace filter that was silently swallowing spaces
  and newlines during streaming, causing words to concatenate and paragraphs
  to collapse. Only skip truly empty strings (delta.length > 0).
- Use proxyBaseUrl for MCP server_url construction instead of the hardcoded
  relative path "litellm_proxy/mcp", so non-root deployments route correctly.

* fix(responses-api): use unique server_label per MCP server to prevent tool routing collisions

* fix(chat-ui): move MCPEvent to shared mcp_tools/types, skip partial events on abort

- Move MCPEvent interface to mcp_tools/types.tsx (shared with MCPServer/MCPTool),
  eliminating the playground→chat cross-module dependency. chat/types.ts and
  both playground components now import from mcp_tools/types.
- Only persist accumulated MCP events when the stream completes cleanly; aborted
  or errored turns drop partial events to avoid showing incomplete tool calls.

* fix(responses-api): use server_name for MCP URL routing, fix test path

- Use server_name (not alias) as the URL path segment for MCP server_url;
  alias is a display name that may differ from the registered proxy route.
  URL-encode the path to handle names with spaces/special characters.
- Fix sys.path.insert in tests to use __file__-relative path so tests pass
  regardless of which directory pytest is invoked from.

* fix(chat-ui): fix stale session after failed edit, clean MCP event persistence, unique server_label

- Eagerly call setResponsesSessionId(null) when historyOverride is set so a
  failed/aborted edit does not leave a stale session contaminating the next turn
- Replace abort-signal check with streamCompletedCleanly flag to correctly skip
  MCP event persistence on both abort and non-abort errors (network/API failures)
- Use server_name (unique) as server_label instead of alias to prevent silent
  tool-routing failures when two MCP servers share the same display name

* [Feat] UI - Show logos on MCP Apps page (#23320)

* feat(ui): add MCP server logo support across admin and chat UIs

- New MCPLogoSelector component with grid of well-known logos (GitHub,
  Slack, Notion, Linear, Jira, etc.) and custom URL input
- Create MCP Server form: logo picker with preview, OpenAPI presets
  auto-fill logo from registry icon_url
- Edit MCP Server form: logo picker pre-populated from mcp_info.logo_url
- Admin table: logos rendered next to server name in Name column
- Chat MCPAppsPanel: logos on server cards (list + detail view) with
  graceful fallback to letter avatars
- Chat MCPConnectPicker: logos next to server names in toggle list
- Fix pre-existing bug: setTools -> clearTools in create form cancel
- All 321 vitest files / 3211 tests pass

Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>

* feat(ui): use local SVG logos for MCP services, fix Chat UI rendering

- Add 15 new MCP service logo SVGs (Slack, Notion, Linear, Jira, Figma,
  Gmail, Stripe, Salesforce, Shopify, HubSpot, Twilio, Sentry, Zapier,
  GitLab, Google Drive) to both source and pre-built directories
- Switch MCPLogoSelector from CDN URLs (cdn.simpleicons.org) to local
  asset paths (/ui/assets/logos/) for reliable rendering
- Logos now served by the proxy itself, working from any page path
  including /ui/chat/ (absolute paths resolve correctly everywhere)

Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>

* fix(codeql): remove ruby from language matrix (#23227)

* Add team-scoped MCP server filtering for key creation and fix UnboundLocalError

When creating a key, the MCP server list now filters by the selected team's
allowed servers. Also fixes UnboundLocalError on `is_restricted_virtual_key`
when `team_id` query param was provided to GET /v1/mcp/server.

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

* Fix cross-team MCP server info disclosure and restricted key bypass

The GET /v1/mcp/server endpoint allowed any authenticated user to pass
an arbitrary team_id and enumerate another team's MCP server config.
Restricted virtual keys could also use the team_id param to bypass
their access limitations. Add team membership check for non-admins
and block restricted keys from using the team_id filter.

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

* Fix mcp_tool_permissions JSON string deserialization in _resolve_team_allowed_mcp_servers

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

* [Feature] UI - MCP Servers: Add per-server health recheck

Allow users to recheck health for individual MCP servers by clicking
the health status badge. On hover the badge text changes to "Recheck"
with a refresh icon, and the check runs only for that server.

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

* Fix Anthropic docs link for beta endpoint

Update the Anthropic /v1/messages beta endpoint docstring to point to
its current pass-through documentation.

This keeps the change scoped to the incorrect URL and avoids changing
unverified wording in the surrounding comment.

---------

Co-authored-by: netbrah <162479981+netbrah@users.noreply.github.com>
Co-authored-by: Yong woo Song <ywsong.dev@kakao.com>
Co-authored-by: yuneng-jiang <yuneng.jiang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Krish Dholakia <krrishdholakia@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Ishaan Jaff <ishaanjaffer0324@gmail.com>
Co-authored-by: Sameer Kankute <sameer@berri.ai>
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
Co-authored-by: Joe Reyna <joseph.reyna@gmail.com>
This commit is contained in:
Peter Dave Hello
2026-03-11 20:17:41 +05:30
committed by GitHub
co-authored by netbrah Yong woo Song yuneng-jiang Claude Opus 4.6 Krish Dholakia Cursor Agent Ishaan Jaff Sameer Kankute Ishaan Jaff Joe Reyna
parent 5c8e87a9a1
commit 3f18cd2fdc
71 changed files with 2526 additions and 163 deletions
-2
View File
@@ -34,8 +34,6 @@ jobs:
build-mode: none
- language: python
build-mode: none
- language: ruby
build-mode: none
steps:
- name: Checkout repository
@@ -2221,6 +2221,11 @@ def sanitize_messages_for_tool_calling(
Case C: Empty text content
- Replace empty or whitespace-only text content with a placeholder message.
Case D: Duplicate tool_result for same tool_use (duplicate results)
- If multiple tool messages reference the same tool_call_id, keep only the last
occurrence. Anthropic requires exactly one tool_result per tool_use and rejects
with: "each tool_use must have a single result".
This function operates on OpenAI format messages before they are converted to
provider-specific formats.
"""
@@ -2256,6 +2261,49 @@ def sanitize_messages_for_tool_calling(
sanitized_messages.append(current_message)
i += 1
# Case D: Deduplicate tool results with the same tool_call_id.
# Anthropic requires exactly one tool_result per tool_use. Session history
# (e.g. from conversation resume) can contain duplicate tool_result messages
# for the same tool_call_id. Keep only the last occurrence *within each
# contiguous block of tool results following an assistant message*. This
# avoids dropping results from earlier turns if a tool_call_id is reused.
#
# NOTE: This intentionally keeps the *last* occurrence (most complete for
# session-resume duplicates), unlike _deduplicate_bedrock_content_blocks
# which keeps the *first*. The Bedrock case handles provider-side content
# block duplication where the first is authoritative; here the duplicate
# arises from history replay where the last entry is the final state.
duplicates_to_remove: Set[int] = set()
seen_in_block: Dict[str, int] = {} # tool_call_id -> index (reset per block)
for idx, msg in enumerate(sanitized_messages):
role = msg.get("role")
tcid = msg.get("tool_call_id") if role in ["tool", "function"] else None
if tcid:
if tcid in seen_in_block:
# Mark the earlier occurrence for removal (keep latest)
duplicates_to_remove.add(seen_in_block[tcid])
verbose_logger.warning(
"sanitize_messages_for_tool_calling: dropping duplicate "
"tool_result with tool_call_id=%s. This may indicate "
"duplicate tool messages in conversation history.",
tcid,
)
seen_in_block[tcid] = idx
elif role not in ("tool", "function"):
# Non-tool message (user, assistant, system) marks a
# conversational-turn boundary — reset tracking.
# Tool/function messages with no tool_call_id are malformed;
# they should NOT reset the block because they don't represent
# a turn boundary and would mask real within-block duplicates.
seen_in_block = {}
if duplicates_to_remove:
sanitized_messages = [
msg
for idx, msg in enumerate(sanitized_messages)
if idx not in duplicates_to_remove
]
return sanitized_messages
@@ -395,6 +395,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
},
)
# Anthropic requires input_schema.type to be "object". Normalize
# schemas from external sources (MCP servers, OpenAI callers) that
# may omit the type field or use a non-object type.
if _input_schema.get("type") != "object":
litellm.verbose_logger.debug(
"_map_tool_helper: coercing input_schema type from %r to "
"'object' for Anthropic compatibility (tool: %s)",
_input_schema.get("type"),
tool["function"].get("name"),
)
_input_schema = dict(_input_schema) # avoid mutating caller's dict
_input_schema["type"] = "object"
if "properties" not in _input_schema:
_input_schema["properties"] = {}
_allowed_properties = set(AnthropicInputSchema.__annotations__.keys())
input_schema_filtered = {
k: v for k, v in _input_schema.items() if k in _allowed_properties
@@ -27665,6 +27665,92 @@
"supports_reasoning": true,
"supports_tool_choice": true
},
"openrouter/qwen/qwen3.5-35b-a3b": {
"input_cost_per_token": 2.5e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 2e-06,
"source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/qwen/qwen3.5-27b": {
"input_cost_per_token": 3e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 2.4e-06,
"source": "https://openrouter.ai/qwen/qwen3.5-27b",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/qwen/qwen3.5-122b-a10b": {
"input_cost_per_token": 4e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 2e-06,
"source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/qwen/qwen3.5-flash-02-23": {
"input_cost_per_token": 1e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 1000000,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 4e-07,
"source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/qwen/qwen3.5-plus-02-15": {
"input_cost_per_token": 4e-07,
"input_cost_per_token_above_256k_tokens": 5e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 1000000,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 2.4e-06,
"output_cost_per_token_above_256k_tokens": 3e-06,
"source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/qwen/qwen3.5-397b-a17b": {
"input_cost_per_token": 6e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 3.6e-06,
"source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/switchpoint/router": {
"input_cost_per_token": 8.5e-07,
"litellm_provider": "openrouter",
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M8 24c2.208 0 4-1.792 4-4v-4H8c-2.208 0-4 1.792-4 4s1.792 4 4 4z" fill="#0ACF83"/>
<path d="M4 12c0-2.208 1.792-4 4-4h4v8H8c-2.208 0-4-1.792-4-4z" fill="#A259FF"/>
<path d="M4 4c0-2.208 1.792-4 4-4h4v8H8C5.792 8 4 6.208 4 4z" fill="#F24E1E"/>
<path d="M12 0h4c2.208 0 4 1.792 4 4s-1.792 4-4 4h-4V0z" fill="#FF7262"/>
<path d="M20 12c0 2.208-1.792 4-4 4s-4-1.792-4-4 1.792-4 4-4 4 1.792 4 4z" fill="#1ABCFE"/>
</svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 0 0-.867 0L16.418 9.45H7.582L4.918 1.263a.455.455 0 0 0-.867 0L1.386 9.452.044 13.587a.924.924 0 0 0 .331 1.03L12 23.054l11.625-8.436a.92.92 0 0 0 .33-1.03z" fill="#E24329"/>
<path d="M12 23.054L16.418 9.45H7.582L12 23.054z" fill="#FC6D26"/>
<path d="M12 23.054L7.582 9.452H1.386L12 23.054z" fill="#FCA326"/>
<path d="M1.386 9.452L.044 13.587a.924.924 0 0 0 .331 1.03L12 23.054 1.386 9.452z" fill="#E24329"/>
<path d="M12 23.054l4.418-13.602h5.036L12 23.054z" fill="#FCA326"/>
<path d="M22.614 9.452l1.341 4.135a.924.924 0 0 1-.33 1.03L12 23.054l10.614-13.602z" fill="#E24329"/>
</svg>

After

Width:  |  Height:  |  Size: 719 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z" fill="#EA4335"/>
</svg>

After

Width:  |  Height:  |  Size: 328 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M7.71 14.29L2 22h7.65l5.71-7.71H7.71z" fill="#0066DA"/>
<path d="M22 22l-5.71-7.71H8.65L14.35 22H22z" fill="#00AC47"/>
<path d="M8.16 2L2.45 14.29h7.65L15.81 2H8.16z" fill="#FFBA00"/>
<path d="M15.84 2l-5.71 12.29h7.65L23.49 2H15.84z" fill="#EA4335"/>
</svg>

After

Width:  |  Height:  |  Size: 337 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M18.164 7.93V5.084a2.198 2.198 0 0 0 1.267-1.984v-.066A2.2 2.2 0 0 0 17.237.84h-.066a2.2 2.2 0 0 0-2.194 2.194v.066c0 .844.48 1.574 1.18 1.94V7.93a6.152 6.152 0 0 0-2.866 1.388L5.962 3.72a2.385 2.385 0 0 0 .07-.557A2.37 2.37 0 0 0 3.662.793a2.37 2.37 0 0 0-2.37 2.37 2.37 2.37 0 0 0 2.37 2.37c.432 0 .836-.12 1.183-.325l7.47 5.5A6.175 6.175 0 0 0 11.19 14.1a6.2 6.2 0 0 0 1.097 3.504l-2.12 2.12a1.786 1.786 0 0 0-.52-.082A1.803 1.803 0 1 0 11.45 21.445l2.172-2.172a6.175 6.175 0 0 0 3.572 1.135 6.2 6.2 0 1 0 .97-12.478zM17.204 17.2a3.004 3.004 0 1 1 0-6.008 3.004 3.004 0 0 1 0 6.008z" fill="#FF7A59"/>
</svg>

After

Width:  |  Height:  |  Size: 683 B

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005z" fill="#2684FF"/>
<path d="M6.262 6.259H17.793a5.218 5.218 0 0 0-5.232-5.214H6.26A5.218 5.218 0 0 0 1.03 6.259v6.268a1.005 1.005 0 0 0 1.005 1.005h9.527V8.318a2.06 2.06 0 0 0-2.06-2.059H6.262z" fill="url(#jiraGrad1)"/>
<path d="M17.53 6.259a5.218 5.218 0 0 1 5.232 5.214v6.268a1.005 1.005 0 0 1-1.005 1.005H12.23v-5.214a2.06 2.06 0 0 1 2.06-2.059h3.24V6.259z" fill="url(#jiraGrad2)"/>
<defs>
<linearGradient id="jiraGrad1" x1="6" y1="1" x2="1" y2="13">
<stop stop-color="#0052CC"/>
<stop offset="1" stop-color="#2684FF"/>
</linearGradient>
<linearGradient id="jiraGrad2" x1="18" y1="6" x2="23" y2="18">
<stop stop-color="#0052CC"/>
<stop offset="1" stop-color="#2684FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 949 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M2.886 10.449a.64.64 0 0 1 .078.848L1.337 13.56a.32.32 0 0 1-.526-.036A11.044 11.044 0 0 1 .103 11.53a.32.32 0 0 1 .238-.37l2.186-.535a.64.64 0 0 1 .36-.176zm-.803 3.753a.32.32 0 0 0-.492.097 11.127 11.127 0 0 0-.747 2.148.32.32 0 0 0 .108.331l1.638 1.319a.32.32 0 0 0 .476-.07l1.18-1.87a.64.64 0 0 0-.033-.733L2.083 14.2zm.79 5.275a.32.32 0 0 0-.42.02l-.367.354a.32.32 0 0 0-.022.43 11.18 11.18 0 0 0 2.854 2.67.32.32 0 0 0 .42-.044l.404-.431a.32.32 0 0 0-.006-.445L2.873 19.477zM7.55 22.553a.32.32 0 0 0 .014.458l.28.232a.32.32 0 0 0 .427-.023 11.2 11.2 0 0 0 3.084-4.807.32.32 0 0 0-.19-.394l-.672-.25a.32.32 0 0 0-.405.18A9.614 9.614 0 0 1 7.55 22.553zM23.988 12c0 6.627-5.373 12-12 12-.742 0-1.47-.067-2.176-.197a.32.32 0 0 1-.218-.494L22.006 8.523a.32.32 0 0 1 .552.108c.283.882.43 1.818.43 2.79V12zm-.836-4.488a.32.32 0 0 0-.56-.05L10.163 22.68a.32.32 0 0 0 .096.472c.83.43 1.73.74 2.679.914a.32.32 0 0 0 .333-.14L23.195 8.204a.32.32 0 0 0-.043-.412v-.28zM20.684 5.216a.32.32 0 0 0 .484.013l.257-.269a.32.32 0 0 0 .017-.427A11.955 11.955 0 0 0 12 .007C5.373.007 0 5.38 0 12.007c0 .414.021.824.063 1.229a.32.32 0 0 0 .547.197l18.13-20.07a.32.32 0 0 1 .453-.014l1.49 1.368v.5z" fill="#5E6AD2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L18.57 2.33c-.42-.326-.98-.7-2.055-.607L3.62 2.79c-.466.046-.56.28-.374.466l1.213.952zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.84-.046.933-.56.933-1.167V6.354c0-.606-.233-.933-.746-.886l-15.177.886c-.56.047-.747.327-.747.934zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.747 0-.933-.234-1.494-.934l-4.577-7.186v6.952l1.448.327s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.14c-.093-.513.28-.886.747-.933l3.222-.187zM2.1 1.424L15.856.466c1.68-.14 2.1.093 2.8.606l3.876 2.753c.467.326.607.42.607.793v16.844c0 1.026-.374 1.633-1.68 1.726l-15.457.933c-.98.047-1.448-.093-1.962-.747l-3.13-4.06c-.56-.747-.793-1.306-.793-1.96V2.917c0-.84.374-1.54 1.782-1.493z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 975 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M10.006 5.15a4.678 4.678 0 0 1 3.358-1.426 4.7 4.7 0 0 1 4.273 2.775 5.476 5.476 0 0 1 2.163-.444C22.108 6.055 24 7.954 24 10.288a4.258 4.258 0 0 1-3.8 4.225h-.2a3.756 3.756 0 0 1-3.468 2.308 3.726 3.726 0 0 1-1.76-.44 4.418 4.418 0 0 1-3.89 2.32 4.418 4.418 0 0 1-3.798-2.15 3.678 3.678 0 0 1-.844.098A3.68 3.68 0 0 1 2.56 12.97c0-.77.237-1.484.642-2.074A4.448 4.448 0 0 1 0 7.2a4.448 4.448 0 0 1 4.448-4.448c1.11 0 2.13.41 2.91 1.086A4.672 4.672 0 0 1 10.006 5.15z" fill="#00A1E0"/>
</svg>

After

Width:  |  Height:  |  Size: 564 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M13.91 2.505c-.873-1.553-3.066-1.553-3.94 0L7.092 7.67a10.783 10.783 0 0 1 4.376 3.033l1.72-3.027a4.23 4.23 0 0 0-.207-.257L13.91 2.505zM3.6 20.3h2.685a8.782 8.782 0 0 0-.094-5.725l-2.59 4.613c-.25.445.109 1.003.613 1.003L3.6 20.3zm16.595.108c.5 0 .862-.557.613-1.003L14.075 7.5l-1.72 3.027A8.782 8.782 0 0 1 17.35 20.3h2.845v.108z" fill="#362D59"/>
</svg>

After

Width:  |  Height:  |  Size: 429 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M15.337 23.979l7.216-1.561s-2.604-17.613-2.625-17.73c-.018-.116-.114-.2-.2-.2s-1.848-.138-1.848-.138-1.225-1.197-1.363-1.338c-.04-.04-.085-.06-.132-.074l-.793 18.867 -.255.174zm-3.07-16.938s-.718-.378-1.594-.378c-1.29 0-1.353.809-1.353 1.013 0 1.112 2.9 1.538 2.9 4.145 0 2.051-1.3 3.37-3.053 3.37-2.105 0-3.18-1.31-3.18-1.31l.564-1.865s1.105.949 2.036.949c.607 0 .856-.479.856-.829 0-1.453-2.38-1.519-2.38-3.906 0-2.008 1.441-3.953 4.351-3.953 1.12 0 1.674.321 1.674.321l-.82 2.443z" fill="#95BF47"/>
<path d="M14.998 6.268c-.082-.025-.18-.048-.252-.048-.028 0-.062.002-.092.006l-.76 18.07.663-.143 3.377-23.182c-.138.14-1.363 1.337-1.363 1.337s-.95-.093-1.573-.04z" fill="#5E8E3E"/>
</svg>

After

Width:  |  Height:  |  Size: 766 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" fill="#E01E5A"/>
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" fill="#36C5F0"/>
<path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.27 0a2.528 2.528 0 0 1-2.522 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312z" fill="#2EB67D"/>
<path d="M15.165 18.956a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.27a2.527 2.527 0 0 1-2.521-2.522 2.527 2.527 0 0 1 2.521-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.521h-6.313z" fill="#ECB22E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.591-7.305z" fill="#635BFF"/>
</svg>

After

Width:  |  Height:  |  Size: 571 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 0C5.381 0 0 5.381 0 12s5.381 12 12 12 12-5.381 12-12S18.619 0 12 0zm0 20.4c-4.636 0-8.4-3.764-8.4-8.4S7.364 3.6 12 3.6s8.4 3.764 8.4 8.4-3.764 8.4-8.4 8.4zm3.6-10.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm0 4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm-4.8-4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm0 4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8z" fill="#F22F46"/>
</svg>

After

Width:  |  Height:  |  Size: 587 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M15.535 8.465l-1.263 1.264a4.502 4.502 0 0 0-4.544 0L8.465 8.465a6.51 6.51 0 0 1 2.534-1.796V4.5h2v2.17a6.51 6.51 0 0 1 2.536 1.795zM12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm3.535 5.035l1.263-1.264a6.51 6.51 0 0 1-1.796 2.536V19h-2v-2.17a6.51 6.51 0 0 1-2.534-1.795l1.263-1.264a4.502 4.502 0 0 0 4.544 0h-.74zM19 11h-2.17a6.51 6.51 0 0 1-1.795 2.534l1.264 1.263a4.502 4.502 0 0 0 0-4.544l-1.264 1.263A6.51 6.51 0 0 1 16.831 11H19v2zm-12.17 2H5v-2h2.17a6.51 6.51 0 0 1 1.795-2.534L7.7 7.203a4.502 4.502 0 0 0 0 4.544l1.264-1.263A6.51 6.51 0 0 1 7.17 13H6.83z" fill="#FF4A00"/>
</svg>

After

Width:  |  Height:  |  Size: 659 B

@@ -30,7 +30,7 @@ async def anthropic_response( # noqa: PLR0915
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Use `{PROXY_BASE_URL}/anthropic/v1/messages` instead - [Docs](https://docs.litellm.ai/docs/anthropic_completion).
Use `{PROXY_BASE_URL}/anthropic/v1/messages` instead - [Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion).
This was a BETA endpoint that calls 100+ LLMs in the anthropic format.
"""
@@ -64,6 +64,7 @@ from litellm.proxy.management_helpers.object_permission_utils import (
_set_object_permission,
attach_object_permission_to_dict,
handle_update_object_permission_common,
validate_key_mcp_servers_against_team,
)
from litellm.proxy.management_helpers.team_member_permission_checks import (
TeamMemberPermissionChecks,
@@ -638,6 +639,12 @@ async def _common_key_generation_helper( # noqa: PLR0915
data_json.pop("tags")
# Validate MCP servers in object_permission are within team scope
await validate_key_mcp_servers_against_team(
object_permission=data_json.get("object_permission"),
team_obj=team_table,
)
data_json = await _set_object_permission(
data_json=data_json,
prisma_client=prisma_client,
@@ -1947,6 +1954,27 @@ async def update_key_fn(
# Set Management Endpoint Metadata Fields
# Validate MCP servers in object_permission against the effective team
if data.object_permission is not None:
effective_team_obj = team_obj
# If team_id isn't being changed, resolve the existing key's team
if effective_team_obj is None and existing_key_row.team_id:
effective_team_obj = await get_team_object(
team_id=existing_key_row.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
object_permission_dict = (
data.object_permission.model_dump()
if hasattr(data.object_permission, "model_dump")
else data.object_permission
)
await validate_key_mcp_servers_against_team(
object_permission=object_permission_dict,
team_obj=effective_team_obj,
)
non_default_values = await prepare_key_update_data(
data=data, existing_key_row=existing_key_row
)
@@ -615,6 +615,46 @@ if MCP_AVAILABLE:
return "view_all"
return "restricted"
async def _get_team_scoped_mcp_server_list(
team_id: str,
) -> List[LiteLLM_MCPServerTable]:
"""
Return MCP servers scoped to a team: team's allowed servers + allow_all_keys servers.
Used by the Create Key UI to populate the MCP server dropdown.
"""
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.management_helpers.object_permission_utils import (
_get_allow_all_keys_server_ids,
_get_team_allowed_mcp_servers,
)
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
team_obj = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
team_server_ids = await _get_team_allowed_mcp_servers(team_obj)
allow_all_server_ids = _get_allow_all_keys_server_ids()
all_allowed_ids = team_server_ids | allow_all_server_ids
if not all_allowed_ids:
return []
# Collect servers from registry
servers: List[LiteLLM_MCPServerTable] = []
for server_id in all_allowed_ids:
server = global_mcp_server_manager.get_mcp_server_by_id(server_id)
if server is not None:
mcp_server_table = global_mcp_server_manager._build_mcp_server_table(
server
)
servers.append(mcp_server_table)
return _redact_mcp_credentials_list(servers)
@router.get(
"/server",
description="Returns the mcp server list with associated teams",
@@ -623,38 +663,88 @@ if MCP_AVAILABLE:
)
async def fetch_all_mcp_servers(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
team_id: Optional[str] = Query(
None,
description="Filter MCP servers by team scope. When provided, returns only "
"servers the team has access to plus globally available (allow_all_keys) servers. "
"Used by the Create Key UI to show team-scoped MCP servers.",
),
):
"""
Get all of the configured mcp servers for the user in the db with their associated teams
```
curl --location 'http://localhost:4000/v1/mcp/server' \
--header 'Authorization: Bearer your_api_key_here'
# Filter by team scope (for Create Key UI)
curl --location 'http://localhost:4000/v1/mcp/server?team_id=team-123' \
--header 'Authorization: Bearer your_api_key_here'
```
"""
user_mcp_management_mode = _get_user_mcp_management_mode()
# If team_id is provided, return team-scoped servers + allow_all_keys servers
is_restricted_virtual_key = _is_restricted_virtual_key_request(
user_api_key_dict
)
if user_mcp_management_mode == "view_all" and not is_restricted_virtual_key:
servers = await global_mcp_server_manager.get_all_mcp_servers_unfiltered()
redacted_mcp_servers = _redact_mcp_credentials_list(servers)
else:
auth_contexts = await build_effective_auth_contexts(user_api_key_dict)
aggregated_servers: Dict[str, LiteLLM_MCPServerTable] = {}
for auth_context in auth_contexts:
servers = await global_mcp_server_manager.get_all_allowed_mcp_servers(
user_api_key_auth=auth_context
if team_id is not None and isinstance(team_id, str) and team_id.strip():
# Restricted virtual keys must not use the team_id filter to
# bypass their own access limitations.
if is_restricted_virtual_key:
raise HTTPException(
status_code=403,
detail="Restricted virtual keys cannot query team-scoped MCP servers.",
)
for server in servers:
if server.server_id not in aggregated_servers:
aggregated_servers[server.server_id] = server
redacted_mcp_servers = _redact_mcp_credentials_list(
aggregated_servers.values()
)
# Only proxy admins may query another team's MCP servers.
# Non-admins must belong to the requested team.
sanitized_team_id = team_id.strip()
is_admin = _user_has_admin_view(user_api_key_dict)
if not is_admin:
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.proxy_server import (
prisma_client,
user_api_key_cache,
)
team_obj = await get_team_object(
team_id=sanitized_team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
user_in_team = any(
m.user_id is not None
and m.user_id == user_api_key_dict.user_id
for m in team_obj.members_with_roles
)
if not user_in_team:
raise HTTPException(
status_code=403,
detail="You do not have permission to view MCP servers for this team.",
)
redacted_mcp_servers = await _get_team_scoped_mcp_server_list(sanitized_team_id)
else:
user_mcp_management_mode = _get_user_mcp_management_mode()
if user_mcp_management_mode == "view_all" and not is_restricted_virtual_key:
servers = await global_mcp_server_manager.get_all_mcp_servers_unfiltered()
redacted_mcp_servers = _redact_mcp_credentials_list(servers)
else:
auth_contexts = await build_effective_auth_contexts(user_api_key_dict)
aggregated_servers: Dict[str, LiteLLM_MCPServerTable] = {}
for auth_context in auth_contexts:
servers = await global_mcp_server_manager.get_all_allowed_mcp_servers(
user_api_key_auth=auth_context
)
for server in servers:
if server.server_id not in aggregated_servers:
aggregated_servers[server.server_id] = server
redacted_mcp_servers = _redact_mcp_credentials_list(
aggregated_servers.values()
)
# augment the mcp servers with public status
if litellm.public_mcp_servers is not None:
@@ -4,12 +4,14 @@ organizations, teams, and keys.
"""
import json
from litellm._uuid import uuid
from typing import Dict, Optional, Union
from typing import Dict, List, Optional, Set, Union
from fastapi import HTTPException, status
from litellm._logging import verbose_proxy_logger
from litellm.proxy.utils import PrismaClient
from litellm._uuid import uuid
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy.utils import PrismaClient
@@ -177,4 +179,178 @@ async def _set_object_permission(
data_json["object_permission_id"] = created_permission.object_permission_id
data_json.pop("object_permission")
return data_json
return data_json
async def _resolve_team_allowed_mcp_servers(
team_object_permission: "LiteLLM_ObjectPermissionTable",
) -> Set[str]:
"""
Resolve the full set of MCP server IDs a team has access to.
Combines:
- Direct mcp_servers list
- Servers from mcp_access_groups
- Server IDs referenced in mcp_tool_permissions keys
"""
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
)
direct_servers: List[str] = team_object_permission.mcp_servers or []
access_group_servers: List[str] = (
await MCPRequestHandler._get_mcp_servers_from_access_groups(
team_object_permission.mcp_access_groups or []
)
)
raw_tool_perms = team_object_permission.mcp_tool_permissions or {}
if isinstance(raw_tool_perms, str):
raw_tool_perms = json.loads(raw_tool_perms)
tool_perm_servers: List[str] = list(raw_tool_perms.keys())
return set(direct_servers + access_group_servers + tool_perm_servers)
def _get_allow_all_keys_server_ids() -> Set[str]:
"""Return the set of MCP server IDs marked with allow_all_keys=True."""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
return set(global_mcp_server_manager.get_allow_all_keys_server_ids())
async def _get_team_allowed_mcp_servers(
team_obj: Optional["LiteLLM_TeamTableCachedObj"],
) -> Set[str]:
"""
Get the full set of MCP server IDs a team allows.
If team has no object_permission or no MCP config, returns empty set
(meaning only allow_all_keys servers are permitted).
"""
if team_obj is None:
return set()
team_object_permission = team_obj.object_permission
if team_object_permission is None:
return set()
return await _resolve_team_allowed_mcp_servers(team_object_permission)
def _extract_requested_mcp_server_ids(
object_permission: Optional[dict],
) -> Set[str]:
"""
Extract all MCP server IDs referenced in a key's object_permission dict.
Includes:
- mcp_servers list
- Keys from mcp_tool_permissions
"""
if not object_permission or not isinstance(object_permission, dict):
return set()
server_ids: Set[str] = set()
mcp_servers = object_permission.get("mcp_servers")
if isinstance(mcp_servers, list):
server_ids.update(mcp_servers)
mcp_tool_permissions = object_permission.get("mcp_tool_permissions")
if isinstance(mcp_tool_permissions, dict):
server_ids.update(mcp_tool_permissions.keys())
return server_ids
def _extract_requested_mcp_access_groups(
object_permission: Optional[dict],
) -> Set[str]:
"""Extract MCP access groups from a key's object_permission dict."""
if not object_permission or not isinstance(object_permission, dict):
return set()
groups = object_permission.get("mcp_access_groups")
if isinstance(groups, list):
return set(groups)
return set()
async def validate_key_mcp_servers_against_team(
object_permission: Optional[dict],
team_obj: Optional["LiteLLM_TeamTableCachedObj"],
):
"""
Validate that MCP servers requested on a key are within the allowed scope.
Rules:
- If key is in a team: key's mcp_servers must be a subset of
(team's allowed servers + allow_all_keys servers)
- If key is NOT in a team: key's mcp_servers must only contain
allow_all_keys servers
- If team has no MCP config: key can only use allow_all_keys servers
Raises HTTPException(403) if validation fails.
"""
requested_servers = _extract_requested_mcp_server_ids(object_permission)
requested_access_groups = _extract_requested_mcp_access_groups(object_permission)
# Nothing to validate
if not requested_servers and not requested_access_groups:
return
allow_all_keys_servers = _get_allow_all_keys_server_ids()
team_allowed_servers = await _get_team_allowed_mcp_servers(team_obj)
# Combined allowed set = team servers + allow_all_keys servers
all_allowed_servers = team_allowed_servers | allow_all_keys_servers
# Validate requested server IDs
if requested_servers:
disallowed_servers = requested_servers - all_allowed_servers
if disallowed_servers:
if team_obj is not None:
detail = (
f"Key requests MCP servers not allowed by team '{team_obj.team_id}': "
f"{sorted(disallowed_servers)}. "
f"Team allows: {sorted(team_allowed_servers)}. "
f"Global (allow_all_keys) servers: {sorted(allow_all_keys_servers)}."
)
else:
detail = (
f"Key is not in a team. Only globally available (allow_all_keys) MCP servers "
f"can be assigned: {sorted(allow_all_keys_servers)}. "
f"Disallowed servers: {sorted(disallowed_servers)}."
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": detail},
)
# Validate requested access groups (must be subset of team's access groups)
if requested_access_groups:
team_access_groups: Set[str] = set()
if (
team_obj is not None
and team_obj.object_permission is not None
and team_obj.object_permission.mcp_access_groups
):
team_access_groups = set(team_obj.object_permission.mcp_access_groups)
disallowed_groups = requested_access_groups - team_access_groups
if disallowed_groups:
if team_obj is not None:
detail = (
f"Key requests MCP access groups not allowed by team '{team_obj.team_id}': "
f"{sorted(disallowed_groups)}. "
f"Team allows: {sorted(team_access_groups)}."
)
else:
detail = (
f"Key is not in a team. MCP access groups cannot be assigned to "
f"keys outside of a team. Disallowed groups: {sorted(disallowed_groups)}."
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": detail},
)
+86
View File
@@ -27665,6 +27665,92 @@
"supports_reasoning": true,
"supports_tool_choice": true
},
"openrouter/qwen/qwen3.5-35b-a3b": {
"input_cost_per_token": 2.5e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 2e-06,
"source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/qwen/qwen3.5-27b": {
"input_cost_per_token": 3e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 2.4e-06,
"source": "https://openrouter.ai/qwen/qwen3.5-27b",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/qwen/qwen3.5-122b-a10b": {
"input_cost_per_token": 4e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 2e-06,
"source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/qwen/qwen3.5-flash-02-23": {
"input_cost_per_token": 1e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 1000000,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 4e-07,
"source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/qwen/qwen3.5-plus-02-15": {
"input_cost_per_token": 4e-07,
"input_cost_per_token_above_256k_tokens": 5e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 1000000,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 2.4e-06,
"output_cost_per_token_above_256k_tokens": 3e-06,
"source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/qwen/qwen3.5-397b-a17b": {
"input_cost_per_token": 6e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 3.6e-06,
"source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true
},
"openrouter/switchpoint/router": {
"input_cost_per_token": 8.5e-07,
"litellm_provider": "openrouter",
@@ -10,6 +10,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
BedrockImageProcessor,
_convert_to_bedrock_tool_call_invoke,
ollama_pt,
sanitize_messages_for_tool_calling,
)
@@ -1179,7 +1180,7 @@ def test_bedrock_tools_pt_does_not_handle_system_tool():
System tools (nova_grounding) should be added via web_search_options,
not via the tools parameter directly.
"""
from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt
# Regular function tools should still work
@@ -1741,3 +1742,288 @@ def test_bedrock_tool_call_invoke_multiple_normal_tools():
assert len(result) == 2
assert result[0]["toolUse"]["toolUseId"] == "call_1"
assert result[1]["toolUse"]["toolUseId"] == "call_2"
# ========================================================================
# Tool result deduplication tests (Case D in sanitize_messages_for_tool_calling)
# ========================================================================
def test_sanitize_messages_deduplicates_tool_results():
"""
Anthropic requires exactly one tool_result per tool_use. When conversation
history (e.g. from session resume) contains duplicate tool result messages
with the same tool_call_id, sanitize_messages_for_tool_calling should keep
only the last occurrence.
Without this fix, Anthropic rejects with:
each tool_use must have a single result. Found multiple tool_result
blocks with id: <id>
"""
original = litellm.modify_params
litellm.modify_params = True
try:
messages = [
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "NYC"}',
},
}
],
},
# First tool result (stale/duplicate)
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "Partial result...",
},
# Second tool result (final/complete — should be kept)
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": '{"temperature": 72, "condition": "sunny"}',
},
]
result = sanitize_messages_for_tool_calling(messages)
# Count tool messages with this ID — should be exactly 1
tool_results = [
m for m in result if m.get("role") == "tool" and m.get("tool_call_id") == "call_abc123"
]
assert len(tool_results) == 1
# Should keep the LAST occurrence (most complete)
assert tool_results[0]["content"] == '{"temperature": 72, "condition": "sunny"}'
finally:
litellm.modify_params = original
def test_sanitize_messages_preserves_unique_tool_results():
"""
When each tool_call_id has exactly one tool_result, no deduplication should
occur. Messages should pass through unchanged.
"""
original = litellm.modify_params
litellm.modify_params = True
try:
messages = [
{"role": "user", "content": "Get weather for two cities"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "NYC"}',
},
},
{
"id": "call_2",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "LA"}',
},
},
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "72F"},
{"role": "tool", "tool_call_id": "call_2", "content": "85F"},
]
result = sanitize_messages_for_tool_calling(messages)
tool_results = [m for m in result if m.get("role") == "tool"]
assert len(tool_results) == 2
assert tool_results[0]["tool_call_id"] == "call_1"
assert tool_results[0]["content"] == "72F"
assert tool_results[1]["tool_call_id"] == "call_2"
assert tool_results[1]["content"] == "85F"
finally:
litellm.modify_params = original
def test_sanitize_messages_dedup_disabled_when_modify_params_false():
"""
When litellm.modify_params is False, messages should be returned as-is
even if they contain duplicate tool results.
"""
original = litellm.modify_params
litellm.modify_params = False
try:
messages = [
{"role": "user", "content": "Test"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_dup",
"type": "function",
"function": {"name": "test", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_dup", "content": "first"},
{"role": "tool", "tool_call_id": "call_dup", "content": "second"},
]
result = sanitize_messages_for_tool_calling(messages)
# Should be unchanged — no sanitization when modify_params=False
assert result == messages
finally:
litellm.modify_params = original
def test_sanitize_messages_dedup_scoped_per_turn_preserves_cross_turn():
"""
When the same tool_call_id appears in two different assistant turns
(separated by a user message), both tool results must be preserved.
Deduplication should only apply within a single contiguous tool-result
block, not globally across the conversation.
Without per-turn scoping this would incorrectly drop the first tool result,
leaving the first assistant message without its required result (which
Anthropic would reject).
"""
original = litellm.modify_params
litellm.modify_params = True
try:
messages = [
{"role": "user", "content": "First question"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_X",
"type": "function",
"function": {"name": "lookup", "arguments": '{"q": "a"}'},
}
],
},
{"role": "tool", "tool_call_id": "call_X", "content": "result_turn_1"},
{"role": "user", "content": "Second question"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_X",
"type": "function",
"function": {"name": "lookup", "arguments": '{"q": "b"}'},
}
],
},
{"role": "tool", "tool_call_id": "call_X", "content": "result_turn_2"},
]
result = sanitize_messages_for_tool_calling(messages)
# Both tool results must survive — one per turn
tool_results = [
m for m in result
if m.get("role") == "tool" and m.get("tool_call_id") == "call_X"
]
assert len(tool_results) == 2, (
f"Expected 2 tool results (one per turn), got {len(tool_results)}. "
"Dedup may be global instead of per-turn scoped."
)
assert tool_results[0]["content"] == "result_turn_1"
assert tool_results[1]["content"] == "result_turn_2"
finally:
litellm.modify_params = original
def test_sanitize_messages_combined_case_a_and_case_d():
"""
Combined Case A + Case D: an assistant message has two tool_calls
one with a missing result (Case A should inject a dummy) and one with
duplicate results (Case D should deduplicate to keep only the last).
This validates that both sanitization passes compose correctly without
interfering with each other.
"""
original = litellm.modify_params
litellm.modify_params = True
try:
messages = [
{"role": "user", "content": "Do two things"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_missing",
"type": "function",
"function": {"name": "tool_a", "arguments": "{}"},
},
{
"id": "call_duped",
"type": "function",
"function": {"name": "tool_b", "arguments": '{"q": "x"}'},
},
],
},
# No result for call_missing — Case A should inject a dummy
# Duplicate results for call_duped — Case D should keep last
{"role": "tool", "tool_call_id": "call_duped", "content": "stale_result"},
{"role": "tool", "tool_call_id": "call_duped", "content": "fresh_result"},
{"role": "user", "content": "Now summarize"},
]
result = sanitize_messages_for_tool_calling(messages)
# Collect tool results from the output
tool_results = [m for m in result if m.get("role") in ("tool", "function")]
# Case A: call_missing should have a dummy result injected
missing_results = [
m for m in tool_results if m.get("tool_call_id") == "call_missing"
]
assert len(missing_results) == 1, (
f"Expected 1 dummy result for call_missing (Case A), got {len(missing_results)}"
)
# Case D: call_duped should have exactly 1 result (the fresh one)
duped_results = [
m for m in tool_results if m.get("tool_call_id") == "call_duped"
]
assert len(duped_results) == 1, (
f"Expected 1 result for call_duped after dedup (Case D), got {len(duped_results)}"
)
assert duped_results[0]["content"] == "fresh_result", (
f"Expected last-wins 'fresh_result', got '{duped_results[0]['content']}'"
)
# Verify tool results immediately follow the assistant message
asst_idx = next(
i for i, m in enumerate(result) if m.get("role") == "assistant"
)
tool_msgs_after_asst = [
m
for m in result[asst_idx + 1 :]
if m.get("role") in ("tool", "function")
]
assert len(tool_msgs_after_asst) == 2, (
f"Expected 2 tool results after assistant, got {len(tool_msgs_after_asst)}"
)
# Both tool_call_ids should be present (order may vary)
tool_ids = {m["tool_call_id"] for m in tool_msgs_after_asst}
assert tool_ids == {"call_missing", "call_duped"}, (
f"Expected tool_call_ids {{call_missing, call_duped}}, got {tool_ids}"
)
finally:
litellm.modify_params = original
@@ -3175,3 +3175,128 @@ def test_map_openai_params_max_tokens_normalized_to_int():
assert "max_tokens" in result
assert result["max_tokens"] == 1
# ========================================================================
# Tool schema normalization tests
# ========================================================================
def test_map_tool_helper_enforces_object_type_when_missing():
"""
Anthropic requires input_schema.type to be "object". When an OpenAI tool
has parameters without a 'type' field (common with MCP servers), LiteLLM
should inject type:"object" before forwarding to Anthropic.
Without this fix, Anthropic rejects with:
tools.N.custom.input_schema.type: Input should be 'object'
"""
config = AnthropicConfig()
# Tool with parameters that has properties but no 'type' field
tool = {
"type": "function",
"function": {
"name": "search_code",
"description": "Search for code patterns",
"parameters": {
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"],
},
},
}
original_params = tool["function"]["parameters"].copy()
result, _ = config._map_tool_helper(tool)
assert result is not None
assert result["input_schema"]["type"] == "object"
assert "properties" in result["input_schema"]
assert "query" in result["input_schema"]["properties"]
# Original parameters dict must not be modified in place
assert tool["function"]["parameters"] == original_params, (
"parameters dict was mutated; _map_tool_helper should not modify caller data"
)
def test_map_tool_helper_enforces_object_type_when_wrong_type():
"""
If a tool schema has type:"string" or type:"array" at the root level,
LiteLLM should normalize it to type:"object" for Anthropic compatibility.
"""
config = AnthropicConfig()
tool = {
"type": "function",
"function": {
"name": "echo",
"description": "Echo input",
"parameters": {
"type": "string",
"description": "The input to echo",
},
},
}
original_params = tool["function"]["parameters"].copy()
result, _ = config._map_tool_helper(tool)
assert result is not None
assert result["input_schema"]["type"] == "object"
assert result["input_schema"].get("properties") == {}, (
"properties should be injected as {} when schema has non-object type and no properties key"
)
# Original parameters dict must not be modified in place
assert tool["function"]["parameters"] == original_params, (
"parameters dict was mutated; _map_tool_helper should not modify caller data"
)
def test_map_tool_helper_preserves_valid_object_schema():
"""
When a tool schema already has type:"object", it should be preserved
without modification.
"""
config = AnthropicConfig()
tool = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
},
"required": ["city"],
},
},
}
result, _ = config._map_tool_helper(tool)
assert result is not None
assert result["input_schema"]["type"] == "object"
assert "city" in result["input_schema"]["properties"]
assert result["input_schema"]["required"] == ["city"]
def test_map_tool_helper_empty_parameters_get_default():
"""
When parameters is entirely missing, the existing default should still
produce a valid {type:"object", properties:{}} schema.
"""
config = AnthropicConfig()
tool = {
"type": "function",
"function": {
"name": "no_params_tool",
"description": "Tool with no parameters",
},
}
result, _ = config._map_tool_helper(tool)
assert result is not None
assert result["input_schema"]["type"] == "object"
assert result["input_schema"].get("properties") == {}
@@ -605,6 +605,10 @@ async def test_key_generation_with_mcp_tool_permissions(monkeypatch):
mock_prisma_client.insert_data = AsyncMock(side_effect=_insert_data_side_effect)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.validate_key_mcp_servers_against_team",
AsyncMock(),
)
from litellm.proxy._types import (
GenerateKeyRequest,
@@ -2346,6 +2350,9 @@ async def test_generate_key_with_object_permission():
), patch(
"litellm.proxy.proxy_server.litellm_proxy_admin_name",
"admin",
), patch(
"litellm.proxy.management_endpoints.key_management_endpoints.validate_key_mcp_servers_against_team",
new_callable=AsyncMock,
):
# Execute
result = await _common_key_generation_helper(
@@ -797,6 +797,157 @@ class TestListMCPServers:
assert result.status == "healthy"
class TestTeamScopedMCPServerAccess:
"""Tests for cross-team information disclosure and restricted key bypass fixes."""
@pytest.mark.asyncio
async def test_non_member_cannot_query_foreign_team(self):
"""Non-admin user who is NOT a member of the target team should get 403."""
from litellm.proxy._types import Member
mock_user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="attacker_user",
)
# Team with a different member
mock_team_obj = MagicMock()
mock_team_obj.members_with_roles = [
Member(user_id="legitimate_user", role="admin"),
]
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view",
return_value=False,
),
patch(
"litellm.proxy.auth.auth_checks.get_team_object",
AsyncMock(return_value=mock_team_obj),
),
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
with pytest.raises(HTTPException) as exc_info:
await fetch_all_mcp_servers(
user_api_key_dict=mock_user_auth, team_id="foreign-team-id"
)
assert exc_info.value.status_code == 403
assert "permission" in str(exc_info.value.detail).lower()
@pytest.mark.asyncio
async def test_team_member_can_query_own_team(self):
"""User who IS a member of the team should be able to query it."""
from litellm.proxy._types import Member
mock_user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="team_member",
)
mock_team_obj = MagicMock()
mock_team_obj.members_with_roles = [
Member(user_id="team_member", role="user"),
]
mock_team_obj.object_permission = MagicMock(mcp_servers=["server-1"])
mock_server = generate_mock_mcp_server_config_record(
server_id="server-1", name="Team Server"
)
mock_manager = MagicMock()
mock_manager.get_mcp_server_by_id = MagicMock(return_value=mock_server)
mock_manager._build_mcp_server_table = MagicMock(
return_value=generate_mock_mcp_server_db_record(server_id="server-1")
)
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view",
return_value=False,
),
patch(
"litellm.proxy.auth.auth_checks.get_team_object",
AsyncMock(return_value=mock_team_obj),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_team_scoped_mcp_server_list",
AsyncMock(
return_value=[
generate_mock_mcp_server_db_record(server_id="server-1")
]
),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
result = await fetch_all_mcp_servers(
user_api_key_dict=mock_user_auth, team_id="my-team-id"
)
assert len(result) == 1
assert result[0].server_id == "server-1"
@pytest.mark.asyncio
async def test_admin_can_query_any_team(self):
"""Proxy admins should be able to query any team's MCP servers."""
mock_user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="admin_user",
)
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view",
return_value=True,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_team_scoped_mcp_server_list",
AsyncMock(
return_value=[
generate_mock_mcp_server_db_record(server_id="server-1")
]
),
),
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
# Admin should NOT need to be a team member
result = await fetch_all_mcp_servers(
user_api_key_dict=mock_user_auth, team_id="any-team-id"
)
assert len(result) == 1
@pytest.mark.asyncio
async def test_restricted_virtual_key_cannot_use_team_id_filter(self):
"""Restricted virtual keys must not bypass access limits via team_id."""
mock_user_auth = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="vkey_user",
api_key="sk-restricted",
allowed_routes=["mcp_routes"],
)
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
with pytest.raises(HTTPException) as exc_info:
await fetch_all_mcp_servers(
user_api_key_dict=mock_user_auth, team_id="some-team"
)
assert exc_info.value.status_code == 403
assert "Restricted virtual key" in str(exc_info.value.detail)
class TestTemporaryMCPSessionEndpoints:
def test_inherit_credentials_from_existing_server(self):
payload = NewMCPServerRequest(
@@ -3,15 +3,21 @@ import os
import sys
import pytest
from fastapi import HTTPException
sys.path.insert(
0, os.path.abspath("../../../..")
)
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
from litellm.proxy.management_helpers.object_permission_utils import (
_extract_requested_mcp_access_groups,
_extract_requested_mcp_server_ids,
_resolve_team_allowed_mcp_servers,
_set_object_permission,
validate_key_mcp_servers_against_team,
)
@@ -82,3 +88,348 @@ async def test_set_object_permission():
assert result["user_id"] == "test_user"
assert result["models"] == ["gpt-4"]
# ---- Tests for _extract_requested_mcp_server_ids ----
def test_extract_requested_mcp_server_ids_from_mcp_servers():
obj_perm = {"mcp_servers": ["server-1", "server-2"]}
assert _extract_requested_mcp_server_ids(obj_perm) == {"server-1", "server-2"}
def test_extract_requested_mcp_server_ids_from_tool_permissions():
obj_perm = {"mcp_tool_permissions": {"server-a": ["tool1"], "server-b": ["tool2"]}}
assert _extract_requested_mcp_server_ids(obj_perm) == {"server-a", "server-b"}
def test_extract_requested_mcp_server_ids_combined():
obj_perm = {
"mcp_servers": ["server-1"],
"mcp_tool_permissions": {"server-2": ["tool1"]},
}
assert _extract_requested_mcp_server_ids(obj_perm) == {"server-1", "server-2"}
def test_extract_requested_mcp_server_ids_none():
assert _extract_requested_mcp_server_ids(None) == set()
assert _extract_requested_mcp_server_ids({}) == set()
# ---- Tests for _extract_requested_mcp_access_groups ----
def test_extract_requested_mcp_access_groups():
obj_perm = {"mcp_access_groups": ["group-a", "group-b"]}
assert _extract_requested_mcp_access_groups(obj_perm) == {"group-a", "group-b"}
def test_extract_requested_mcp_access_groups_none():
assert _extract_requested_mcp_access_groups(None) == set()
assert _extract_requested_mcp_access_groups({}) == set()
# ---- Tests for validate_key_mcp_servers_against_team ----
def _make_team_obj(
team_id="team-1",
mcp_servers=None,
mcp_access_groups=None,
mcp_tool_permissions=None,
):
"""Create a mock team object with the given MCP permissions."""
mock_team = MagicMock()
mock_team.team_id = team_id
if mcp_servers is not None or mcp_access_groups is not None or mcp_tool_permissions is not None:
mock_team.object_permission = MagicMock(spec=LiteLLM_ObjectPermissionTable)
mock_team.object_permission.mcp_servers = mcp_servers or []
mock_team.object_permission.mcp_access_groups = mcp_access_groups or []
mock_team.object_permission.mcp_tool_permissions = mcp_tool_permissions or {}
else:
mock_team.object_permission = None
return mock_team
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value=set(),
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_validate_no_object_permission(mock_access_groups, mock_allow_all):
"""No object_permission on key — should pass without error."""
await validate_key_mcp_servers_against_team(
object_permission=None,
team_obj=_make_team_obj(mcp_servers=["server-1"]),
)
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value=set(),
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_validate_key_servers_within_team_scope(mock_access_groups, mock_allow_all):
"""Key requests servers that are in the team's scope — should pass."""
team_obj = _make_team_obj(mcp_servers=["server-1", "server-2", "server-3"])
await validate_key_mcp_servers_against_team(
object_permission={"mcp_servers": ["server-1", "server-2"]},
team_obj=team_obj,
)
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value=set(),
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_validate_key_servers_outside_team_scope_raises(mock_access_groups, mock_allow_all):
"""Key requests servers NOT in the team's scope — should raise 403."""
team_obj = _make_team_obj(mcp_servers=["server-1"])
with pytest.raises(HTTPException) as exc_info:
await validate_key_mcp_servers_against_team(
object_permission={"mcp_servers": ["server-1", "server-outside"]},
team_obj=team_obj,
)
assert exc_info.value.status_code == 403
assert "server-outside" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value={"global-server"},
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_validate_allow_all_keys_servers_always_allowed(mock_access_groups, mock_allow_all):
"""allow_all_keys servers should be accessible even if not in team scope."""
team_obj = _make_team_obj(mcp_servers=["server-1"])
await validate_key_mcp_servers_against_team(
object_permission={"mcp_servers": ["server-1", "global-server"]},
team_obj=team_obj,
)
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value={"global-server"},
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_validate_no_team_only_allow_all_keys(mock_access_groups, mock_allow_all):
"""Key without a team can only use allow_all_keys servers."""
# This should pass — requesting a global server without a team
await validate_key_mcp_servers_against_team(
object_permission={"mcp_servers": ["global-server"]},
team_obj=None,
)
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value={"global-server"},
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_validate_no_team_non_global_server_raises(mock_access_groups, mock_allow_all):
"""Key without a team requesting a non-global server — should raise 403."""
with pytest.raises(HTTPException) as exc_info:
await validate_key_mcp_servers_against_team(
object_permission={"mcp_servers": ["private-server"]},
team_obj=None,
)
assert exc_info.value.status_code == 403
assert "not in a team" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value=set(),
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_validate_team_no_mcp_config_blocks_all(mock_access_groups, mock_allow_all):
"""Team with no object_permission — key can't use any non-global MCP servers."""
team_obj = _make_team_obj() # No object_permission
with pytest.raises(HTTPException) as exc_info:
await validate_key_mcp_servers_against_team(
object_permission={"mcp_servers": ["some-server"]},
team_obj=team_obj,
)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value=set(),
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_validate_tool_permissions_validated_against_team(mock_access_groups, mock_allow_all):
"""Server IDs in mcp_tool_permissions should also be validated."""
team_obj = _make_team_obj(mcp_servers=["server-1"])
with pytest.raises(HTTPException) as exc_info:
await validate_key_mcp_servers_against_team(
object_permission={
"mcp_tool_permissions": {"server-outside": ["tool1"]}
},
team_obj=team_obj,
)
assert exc_info.value.status_code == 403
assert "server-outside" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value=set(),
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_validate_access_groups_within_team_scope(mock_access_groups, mock_allow_all):
"""Key requests access groups that are in the team's scope — should pass."""
team_obj = _make_team_obj(mcp_access_groups=["group-a", "group-b"])
await validate_key_mcp_servers_against_team(
object_permission={"mcp_access_groups": ["group-a"]},
team_obj=team_obj,
)
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value=set(),
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_validate_access_groups_outside_team_scope_raises(mock_access_groups, mock_allow_all):
"""Key requests access groups NOT in the team's scope — should raise 403."""
team_obj = _make_team_obj(mcp_access_groups=["group-a"])
with pytest.raises(HTTPException) as exc_info:
await validate_key_mcp_servers_against_team(
object_permission={"mcp_access_groups": ["group-outside"]},
team_obj=team_obj,
)
assert exc_info.value.status_code == 403
assert "group-outside" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value=set(),
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_validate_access_groups_no_team_raises(mock_access_groups, mock_allow_all):
"""Key without a team requesting access groups — should raise 403."""
with pytest.raises(HTTPException) as exc_info:
await validate_key_mcp_servers_against_team(
object_permission={"mcp_access_groups": ["group-a"]},
team_obj=None,
)
assert exc_info.value.status_code == 403
assert "not in a team" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch(
"litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
return_value=set(),
)
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=["server-from-group"],
)
async def test_validate_team_access_groups_resolve_to_servers(mock_access_groups, mock_allow_all):
"""Team access groups should resolve to server IDs and be included in allowed set."""
team_obj = _make_team_obj(mcp_access_groups=["group-a"])
# Key requests a server that comes from the team's access group
await validate_key_mcp_servers_against_team(
object_permission={"mcp_servers": ["server-from-group"]},
team_obj=team_obj,
)
# ---- Tests for _resolve_team_allowed_mcp_servers with JSON string mcp_tool_permissions ----
@pytest.mark.asyncio
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_resolve_team_allowed_mcp_servers_string_tool_permissions(mock_access_groups):
"""mcp_tool_permissions stored as a JSON string (via safe_dumps) should be deserialized correctly."""
mock_perm = MagicMock(spec=LiteLLM_ObjectPermissionTable)
mock_perm.mcp_servers = ["server-1"]
mock_perm.mcp_access_groups = []
mock_perm.mcp_tool_permissions = json.dumps({"server-2": ["tool1"]})
result = await _resolve_team_allowed_mcp_servers(mock_perm)
assert result == {"server-1", "server-2"}
@pytest.mark.asyncio
@patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
new_callable=AsyncMock,
return_value=[],
)
async def test_resolve_team_allowed_mcp_servers_dict_tool_permissions(mock_access_groups):
"""mcp_tool_permissions as a dict should work without deserialization."""
mock_perm = MagicMock(spec=LiteLLM_ObjectPermissionTable)
mock_perm.mcp_servers = []
mock_perm.mcp_access_groups = []
mock_perm.mcp_tool_permissions = {"server-a": ["tool1"]}
result = await _resolve_team_allowed_mcp_servers(mock_perm)
assert result == {"server-a"}
@@ -0,0 +1,127 @@
"""
Tests for responses API session chaining used by the chat UI.
Verifies that:
1. previous_response_id is correctly forwarded when provided
2. Absence of previous_response_id does not break the call
3. The aresponses function signature exposes the expected parameters
"""
import inspect
import json
import os
import sys
import unittest.mock as mock
# Use __file__ so the import path is correct regardless of the pytest working directory.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import httpx
import pytest
import litellm
class TestResponsesSessionChaining:
"""Test previous_response_id session chaining for the chat UI."""
def test_responses_api_signature_accepts_previous_response_id(self):
"""aresponses must accept previous_response_id and onResponseId-like params."""
sig = inspect.signature(litellm.aresponses)
assert "previous_response_id" in sig.parameters, (
"aresponses must accept previous_response_id for multi-turn session chaining"
)
assert "input" in sig.parameters, "aresponses must accept input"
assert "model" in sig.parameters, "aresponses must accept model"
@pytest.mark.asyncio
async def test_previous_response_id_included_in_request_body(self):
"""previous_response_id must appear in the outgoing HTTP request body."""
captured_body: dict = {}
async def mock_send(self_transport, request: httpx.Request, **kwargs):
try:
captured_body.update(json.loads(request.content))
except Exception:
pass
# Return a minimal valid responses API response
response_json = {
"id": "resp_test123",
"object": "response",
"model": "gpt-4o-mini",
"output": [
{
"type": "message",
"id": "msg_001",
"role": "assistant",
"content": [{"type": "output_text", "text": "hi", "annotations": []}],
"status": "completed",
}
],
"usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8},
"status": "completed",
"created_at": 1700000000,
}
return httpx.Response(
200,
json=response_json,
request=request,
)
with mock.patch("httpx.AsyncClient.send", mock_send):
try:
await litellm.aresponses(
input="hello",
model="gpt-4o-mini",
previous_response_id="resp_prev_abc",
api_key="sk-test-fake",
)
except Exception:
pass # response parsing may fail; we only care about the outgoing body
assert captured_body.get("previous_response_id") == "resp_prev_abc", (
f"Expected previous_response_id in request body, got: {captured_body}"
)
@pytest.mark.asyncio
async def test_no_previous_response_id_omitted_from_request(self):
"""When previous_response_id is None, it must not appear in the request body."""
captured_body: dict = {}
async def mock_send(self_transport, request: httpx.Request, **kwargs):
try:
captured_body.update(json.loads(request.content))
except Exception:
pass
response_json = {
"id": "resp_new001",
"object": "response",
"model": "gpt-4o-mini",
"output": [
{
"type": "message",
"id": "msg_001",
"role": "assistant",
"content": [{"type": "output_text", "text": "hi", "annotations": []}],
"status": "completed",
}
],
"usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8},
"status": "completed",
"created_at": 1700000000,
}
return httpx.Response(200, json=response_json, request=request)
with mock.patch("httpx.AsyncClient.send", mock_send):
try:
await litellm.aresponses(
input="hello",
model="gpt-4o-mini",
previous_response_id=None,
api_key="sk-test-fake",
)
except Exception:
pass
assert "previous_response_id" not in captured_body, (
"previous_response_id must be omitted from the request body when None"
)
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M8 24c2.208 0 4-1.792 4-4v-4H8c-2.208 0-4 1.792-4 4s1.792 4 4 4z" fill="#0ACF83"/>
<path d="M4 12c0-2.208 1.792-4 4-4h4v8H8c-2.208 0-4-1.792-4-4z" fill="#A259FF"/>
<path d="M4 4c0-2.208 1.792-4 4-4h4v8H8C5.792 8 4 6.208 4 4z" fill="#F24E1E"/>
<path d="M12 0h4c2.208 0 4 1.792 4 4s-1.792 4-4 4h-4V0z" fill="#FF7262"/>
<path d="M20 12c0 2.208-1.792 4-4 4s-4-1.792-4-4 1.792-4 4-4 4 1.792 4 4z" fill="#1ABCFE"/>
</svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 0 0-.867 0L16.418 9.45H7.582L4.918 1.263a.455.455 0 0 0-.867 0L1.386 9.452.044 13.587a.924.924 0 0 0 .331 1.03L12 23.054l11.625-8.436a.92.92 0 0 0 .33-1.03z" fill="#E24329"/>
<path d="M12 23.054L16.418 9.45H7.582L12 23.054z" fill="#FC6D26"/>
<path d="M12 23.054L7.582 9.452H1.386L12 23.054z" fill="#FCA326"/>
<path d="M1.386 9.452L.044 13.587a.924.924 0 0 0 .331 1.03L12 23.054 1.386 9.452z" fill="#E24329"/>
<path d="M12 23.054l4.418-13.602h5.036L12 23.054z" fill="#FCA326"/>
<path d="M22.614 9.452l1.341 4.135a.924.924 0 0 1-.33 1.03L12 23.054l10.614-13.602z" fill="#E24329"/>
</svg>

After

Width:  |  Height:  |  Size: 719 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z" fill="#EA4335"/>
</svg>

After

Width:  |  Height:  |  Size: 328 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M7.71 14.29L2 22h7.65l5.71-7.71H7.71z" fill="#0066DA"/>
<path d="M22 22l-5.71-7.71H8.65L14.35 22H22z" fill="#00AC47"/>
<path d="M8.16 2L2.45 14.29h7.65L15.81 2H8.16z" fill="#FFBA00"/>
<path d="M15.84 2l-5.71 12.29h7.65L23.49 2H15.84z" fill="#EA4335"/>
</svg>

After

Width:  |  Height:  |  Size: 337 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M18.164 7.93V5.084a2.198 2.198 0 0 0 1.267-1.984v-.066A2.2 2.2 0 0 0 17.237.84h-.066a2.2 2.2 0 0 0-2.194 2.194v.066c0 .844.48 1.574 1.18 1.94V7.93a6.152 6.152 0 0 0-2.866 1.388L5.962 3.72a2.385 2.385 0 0 0 .07-.557A2.37 2.37 0 0 0 3.662.793a2.37 2.37 0 0 0-2.37 2.37 2.37 2.37 0 0 0 2.37 2.37c.432 0 .836-.12 1.183-.325l7.47 5.5A6.175 6.175 0 0 0 11.19 14.1a6.2 6.2 0 0 0 1.097 3.504l-2.12 2.12a1.786 1.786 0 0 0-.52-.082A1.803 1.803 0 1 0 11.45 21.445l2.172-2.172a6.175 6.175 0 0 0 3.572 1.135 6.2 6.2 0 1 0 .97-12.478zM17.204 17.2a3.004 3.004 0 1 1 0-6.008 3.004 3.004 0 0 1 0 6.008z" fill="#FF7A59"/>
</svg>

After

Width:  |  Height:  |  Size: 683 B

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005z" fill="#2684FF"/>
<path d="M6.262 6.259H17.793a5.218 5.218 0 0 0-5.232-5.214H6.26A5.218 5.218 0 0 0 1.03 6.259v6.268a1.005 1.005 0 0 0 1.005 1.005h9.527V8.318a2.06 2.06 0 0 0-2.06-2.059H6.262z" fill="url(#jiraGrad1)"/>
<path d="M17.53 6.259a5.218 5.218 0 0 1 5.232 5.214v6.268a1.005 1.005 0 0 1-1.005 1.005H12.23v-5.214a2.06 2.06 0 0 1 2.06-2.059h3.24V6.259z" fill="url(#jiraGrad2)"/>
<defs>
<linearGradient id="jiraGrad1" x1="6" y1="1" x2="1" y2="13">
<stop stop-color="#0052CC"/>
<stop offset="1" stop-color="#2684FF"/>
</linearGradient>
<linearGradient id="jiraGrad2" x1="18" y1="6" x2="23" y2="18">
<stop stop-color="#0052CC"/>
<stop offset="1" stop-color="#2684FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 949 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M2.886 10.449a.64.64 0 0 1 .078.848L1.337 13.56a.32.32 0 0 1-.526-.036A11.044 11.044 0 0 1 .103 11.53a.32.32 0 0 1 .238-.37l2.186-.535a.64.64 0 0 1 .36-.176zm-.803 3.753a.32.32 0 0 0-.492.097 11.127 11.127 0 0 0-.747 2.148.32.32 0 0 0 .108.331l1.638 1.319a.32.32 0 0 0 .476-.07l1.18-1.87a.64.64 0 0 0-.033-.733L2.083 14.2zm.79 5.275a.32.32 0 0 0-.42.02l-.367.354a.32.32 0 0 0-.022.43 11.18 11.18 0 0 0 2.854 2.67.32.32 0 0 0 .42-.044l.404-.431a.32.32 0 0 0-.006-.445L2.873 19.477zM7.55 22.553a.32.32 0 0 0 .014.458l.28.232a.32.32 0 0 0 .427-.023 11.2 11.2 0 0 0 3.084-4.807.32.32 0 0 0-.19-.394l-.672-.25a.32.32 0 0 0-.405.18A9.614 9.614 0 0 1 7.55 22.553zM23.988 12c0 6.627-5.373 12-12 12-.742 0-1.47-.067-2.176-.197a.32.32 0 0 1-.218-.494L22.006 8.523a.32.32 0 0 1 .552.108c.283.882.43 1.818.43 2.79V12zm-.836-4.488a.32.32 0 0 0-.56-.05L10.163 22.68a.32.32 0 0 0 .096.472c.83.43 1.73.74 2.679.914a.32.32 0 0 0 .333-.14L23.195 8.204a.32.32 0 0 0-.043-.412v-.28zM20.684 5.216a.32.32 0 0 0 .484.013l.257-.269a.32.32 0 0 0 .017-.427A11.955 11.955 0 0 0 12 .007C5.373.007 0 5.38 0 12.007c0 .414.021.824.063 1.229a.32.32 0 0 0 .547.197l18.13-20.07a.32.32 0 0 1 .453-.014l1.49 1.368v.5z" fill="#5E6AD2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L18.57 2.33c-.42-.326-.98-.7-2.055-.607L3.62 2.79c-.466.046-.56.28-.374.466l1.213.952zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.84-.046.933-.56.933-1.167V6.354c0-.606-.233-.933-.746-.886l-15.177.886c-.56.047-.747.327-.747.934zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.747 0-.933-.234-1.494-.934l-4.577-7.186v6.952l1.448.327s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.14c-.093-.513.28-.886.747-.933l3.222-.187zM2.1 1.424L15.856.466c1.68-.14 2.1.093 2.8.606l3.876 2.753c.467.326.607.42.607.793v16.844c0 1.026-.374 1.633-1.68 1.726l-15.457.933c-.98.047-1.448-.093-1.962-.747l-3.13-4.06c-.56-.747-.793-1.306-.793-1.96V2.917c0-.84.374-1.54 1.782-1.493z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 975 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M10.006 5.15a4.678 4.678 0 0 1 3.358-1.426 4.7 4.7 0 0 1 4.273 2.775 5.476 5.476 0 0 1 2.163-.444C22.108 6.055 24 7.954 24 10.288a4.258 4.258 0 0 1-3.8 4.225h-.2a3.756 3.756 0 0 1-3.468 2.308 3.726 3.726 0 0 1-1.76-.44 4.418 4.418 0 0 1-3.89 2.32 4.418 4.418 0 0 1-3.798-2.15 3.678 3.678 0 0 1-.844.098A3.68 3.68 0 0 1 2.56 12.97c0-.77.237-1.484.642-2.074A4.448 4.448 0 0 1 0 7.2a4.448 4.448 0 0 1 4.448-4.448c1.11 0 2.13.41 2.91 1.086A4.672 4.672 0 0 1 10.006 5.15z" fill="#00A1E0"/>
</svg>

After

Width:  |  Height:  |  Size: 564 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M13.91 2.505c-.873-1.553-3.066-1.553-3.94 0L7.092 7.67a10.783 10.783 0 0 1 4.376 3.033l1.72-3.027a4.23 4.23 0 0 0-.207-.257L13.91 2.505zM3.6 20.3h2.685a8.782 8.782 0 0 0-.094-5.725l-2.59 4.613c-.25.445.109 1.003.613 1.003L3.6 20.3zm16.595.108c.5 0 .862-.557.613-1.003L14.075 7.5l-1.72 3.027A8.782 8.782 0 0 1 17.35 20.3h2.845v.108z" fill="#362D59"/>
</svg>

After

Width:  |  Height:  |  Size: 429 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M15.337 23.979l7.216-1.561s-2.604-17.613-2.625-17.73c-.018-.116-.114-.2-.2-.2s-1.848-.138-1.848-.138-1.225-1.197-1.363-1.338c-.04-.04-.085-.06-.132-.074l-.793 18.867 -.255.174zm-3.07-16.938s-.718-.378-1.594-.378c-1.29 0-1.353.809-1.353 1.013 0 1.112 2.9 1.538 2.9 4.145 0 2.051-1.3 3.37-3.053 3.37-2.105 0-3.18-1.31-3.18-1.31l.564-1.865s1.105.949 2.036.949c.607 0 .856-.479.856-.829 0-1.453-2.38-1.519-2.38-3.906 0-2.008 1.441-3.953 4.351-3.953 1.12 0 1.674.321 1.674.321l-.82 2.443z" fill="#95BF47"/>
<path d="M14.998 6.268c-.082-.025-.18-.048-.252-.048-.028 0-.062.002-.092.006l-.76 18.07.663-.143 3.377-23.182c-.138.14-1.363 1.337-1.363 1.337s-.95-.093-1.573-.04z" fill="#5E8E3E"/>
</svg>

After

Width:  |  Height:  |  Size: 766 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" fill="#E01E5A"/>
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" fill="#36C5F0"/>
<path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.27 0a2.528 2.528 0 0 1-2.522 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312z" fill="#2EB67D"/>
<path d="M15.165 18.956a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.27a2.527 2.527 0 0 1-2.521-2.522 2.527 2.527 0 0 1 2.521-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.521h-6.313z" fill="#ECB22E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.591-7.305z" fill="#635BFF"/>
</svg>

After

Width:  |  Height:  |  Size: 571 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 0C5.381 0 0 5.381 0 12s5.381 12 12 12 12-5.381 12-12S18.619 0 12 0zm0 20.4c-4.636 0-8.4-3.764-8.4-8.4S7.364 3.6 12 3.6s8.4 3.764 8.4 8.4-3.764 8.4-8.4 8.4zm3.6-10.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm0 4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm-4.8-4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8zm0 4.8c0 .993-.807 1.8-1.8 1.8s-1.8-.807-1.8-1.8.807-1.8 1.8-1.8 1.8.807 1.8 1.8z" fill="#F22F46"/>
</svg>

After

Width:  |  Height:  |  Size: 587 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M15.535 8.465l-1.263 1.264a4.502 4.502 0 0 0-4.544 0L8.465 8.465a6.51 6.51 0 0 1 2.534-1.796V4.5h2v2.17a6.51 6.51 0 0 1 2.536 1.795zM12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm3.535 5.035l1.263-1.264a6.51 6.51 0 0 1-1.796 2.536V19h-2v-2.17a6.51 6.51 0 0 1-2.534-1.795l1.263-1.264a4.502 4.502 0 0 0 4.544 0h-.74zM19 11h-2.17a6.51 6.51 0 0 1-1.795 2.534l1.264 1.263a4.502 4.502 0 0 0 0-4.544l-1.264 1.263A6.51 6.51 0 0 1 16.831 11H19v2zm-12.17 2H5v-2h2.17a6.51 6.51 0 0 1 1.795-2.534L7.7 7.203a4.502 4.502 0 0 0 0 4.544l1.264-1.263A6.51 6.51 0 0 1 7.17 13H6.83z" fill="#FF4A00"/>
</svg>

After

Width:  |  Height:  |  Size: 659 B

@@ -20,6 +20,7 @@ import {
ToolOutlined,
TagsOutlined,
AuditOutlined,
MessageOutlined,
} from "@ant-design/icons";
// import {
// all_admin_roles,
@@ -47,6 +48,7 @@ interface SidebarProps {
interface MenuItemCfg {
key: string;
newTab?: boolean;
page: string; // legacy id; we map this to a path below
label: string;
roles?: string[];
@@ -105,6 +107,8 @@ const routeFor = (slug: string): string => {
return "guardrails";
case "policies":
return "policies";
case "chat":
return "chat";
// tools
case "mcp-servers":
@@ -371,19 +375,29 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
}, [pathname, filteredMenuItems, defaultSelectedKey]);
// ----- Navigation -----
const goTo = (slug: string) => {
const goTo = (slug: string, newTab?: boolean) => {
const href = toHref(slug);
router.push(href);
if (newTab) {
window.open(href, "_blank");
} else {
router.push(href);
}
};
// Wrap label in <a> so every nav item supports right-click → "Open in new tab"
// and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks.
const renderNavLink = (label: string, page: string): React.ReactNode => {
const renderNavLink = (label: string, page: string, newTab?: boolean): React.ReactNode => {
const href = toHref(page);
return (
<a
href={href}
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
onClick={(e) => {
if (newTab) {
e.stopPropagation();
return;
}
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
e.stopPropagation();
return;
@@ -409,6 +423,8 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
style={{
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
position: "relative",
display: "flex",
flexDirection: "column",
}}
>
<ConfigProvider
@@ -431,22 +447,60 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
borderRight: 0,
backgroundColor: "transparent",
fontSize: "14px",
flex: 1,
overflowY: "auto",
}}
items={filteredMenuItems.map((item) => ({
key: item.key,
icon: item.icon,
label: renderNavLink(item.label, item.page),
label: renderNavLink(item.label, item.page, item.newTab),
children: item.children?.map((child) => ({
key: child.key,
icon: child.icon,
label: renderNavLink(child.label, child.page),
onClick: () => goTo(child.page),
label: renderNavLink(child.label, child.page, child.newTab),
onClick: () => goTo(child.page, child.newTab),
})),
onClick: !item.children ? () => goTo(item.page) : undefined,
onClick: !item.children ? () => goTo(item.page, item.newTab) : undefined,
}))}
/>
</ConfigProvider>
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
{/* Pinned "Open Chat" button at bottom */}
<div style={{
padding: collapsed ? "10px 8px" : "10px 12px",
borderTop: "1px solid #f0f0f0",
flexShrink: 0,
}}>
<a
href={toHref("chat")}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "flex-start",
gap: 8,
padding: collapsed ? "8px 0" : "8px 10px",
borderRadius: 8,
background: "#1677ff",
color: "#fff",
textDecoration: "none",
fontSize: 13,
fontWeight: 600,
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLAnchorElement).style.background = "#0958d9";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLAnchorElement).style.background = "#1677ff";
}}
>
<MessageOutlined style={{ fontSize: 16, flexShrink: 0 }} />
{!collapsed && <span>Open Chat</span>}
</a>
</div>
</Sider>
</Layout>
);
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { fetchMCPServerHealth } from "@/components/networking";
import useAuthorized from "../useAuthorized";
@@ -12,11 +13,47 @@ interface MCPServerHealth {
export const useMCPServerHealth = () => {
const { accessToken } = useAuthorized();
return useQuery<MCPServerHealth[]>({
const queryClient = useQueryClient();
const [recheckingServerIds, setRecheckingServerIds] = useState<Set<string>>(new Set());
const query = useQuery<MCPServerHealth[]>({
queryKey: mcpServerHealthKeys.lists(),
queryFn: async () => await fetchMCPServerHealth(accessToken!),
enabled: !!accessToken,
// Refetch health status every 30 seconds to keep it up to date
refetchInterval: 30000,
});
const recheckServerHealth = useCallback(async (serverId: string) => {
if (!accessToken) return;
setRecheckingServerIds((prev) => new Set(prev).add(serverId));
try {
const result: MCPServerHealth[] = await fetchMCPServerHealth(accessToken, [serverId]);
queryClient.setQueriesData<MCPServerHealth[]>(
{ queryKey: mcpServerHealthKeys.lists() },
(oldData) => {
if (!oldData) return result;
return oldData.map((h) => {
const updated = result.find((r) => r.server_id === h.server_id);
return updated ?? h;
});
},
);
} finally {
setRecheckingServerIds((prev) => {
const next = new Set(prev);
next.delete(serverId);
return next;
});
}
}, [accessToken, queryClient]);
return {
...query,
recheckServerHealth,
recheckingServerIds,
};
};
@@ -6,11 +6,11 @@ import useAuthorized from "../useAuthorized";
const mcpServersKeys = createQueryKeys("mcpServers");
export const useMCPServers = () => {
export const useMCPServers = (teamId?: string | null) => {
const { accessToken } = useAuthorized();
return useQuery<MCPServer[]>({
queryKey: mcpServersKeys.list({}),
queryFn: async () => await fetchMCPServers(accessToken!),
queryKey: mcpServersKeys.list(teamId ? { filters: { teamId } } : undefined),
queryFn: async () => await fetchMCPServers(accessToken!, teamId),
enabled: !!accessToken,
});
};
@@ -8,6 +8,7 @@ import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import ReasoningContent from "../playground/chat_ui/ReasoningContent";
import MCPEventsDisplay from "../playground/chat_ui/MCPEventsDisplay";
import { ChatMessage } from "./types";
const { Panel } = Collapse;
@@ -237,6 +238,8 @@ interface AssistantBubbleProps {
isLastMessage: boolean;
isStreaming: boolean;
isTypingIndicator: boolean;
/** MCP events stored on the message — rendered inline below the response. */
mcpEvents?: ChatMessage["mcpEvents"];
}
function AssistantBubble({
@@ -244,6 +247,7 @@ function AssistantBubble({
isLastMessage,
isStreaming,
isTypingIndicator,
mcpEvents,
}: AssistantBubbleProps) {
// Ref to control ReasoningContent collapse on streaming end.
// ReasoningContent manages its own expanded state; we use a key to
@@ -321,6 +325,11 @@ function AssistantBubble({
</div>
<CopyButton text={mainContent} />
{mcpEvents && mcpEvents.length > 0 && (
<div style={{ marginTop: 8, maxWidth: "100%" }}>
<MCPEventsDisplay events={mcpEvents} />
</div>
)}
</div>
);
}
@@ -566,6 +575,7 @@ const ChatMessages: React.FC<Props> = ({ messages, isStreaming, onEditMessage })
isLastMessage={isLastMessage}
isStreaming={isStreaming}
isTypingIndicator={isLastMessage && isTypingIndicator}
mcpEvents={msg.mcpEvents}
/>
);
})}
@@ -26,6 +26,8 @@ import MCPConnectPicker from "./MCPConnectPicker";
import MCPAppsPanel from "./MCPAppsPanel";
import { fetchAvailableModels } from "../playground/llm_calls/fetch_models";
import { makeOpenAIChatCompletionRequest } from "../playground/llm_calls/chat_completion";
import { makeOpenAIResponsesRequest } from "../playground/llm_calls/responses_api";
import type { MCPEvent } from "./types";
import { getProxyBaseUrl } from "@/components/networking";
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
@@ -135,6 +137,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
const [modelSearchText, setModelSearchText] = useState("");
const [selectedMCPServers, setSelectedMCPServers] = useState<string[]>([]);
const [responsesSessionId, setResponsesSessionId] = useState<string | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [inputText, setInputText] = useState("");
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
@@ -162,7 +165,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
createConversation,
appendMessage,
updateLastAssistantMessage,
truncateAfterMessage,
truncateFromMessage,
deleteConversation,
renameConversation,
} = useChatHistory(activeConversationId);
@@ -203,6 +206,12 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
if (staleId) router.replace(getChatUrl(uiRoot));
}, [staleId, router]);
// Reset the responses session when switching between conversations so that
// previous_response_id from conversation A is never sent for conversation B.
useEffect(() => {
setResponsesSessionId(null);
}, [activeConversationId]);
const toggleModel = useCallback((model: string) => {
setSelectedModels((prev) => {
let next: string[];
@@ -231,6 +240,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
let convId = activeConversationId;
if (!convId) {
convId = createConversation(model);
setResponsesSessionId(null); // new conversation starts a fresh session
router.push(getChatUrl(uiRoot, convId));
}
@@ -240,29 +250,56 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
setIsStreaming(true);
abortControllerRef.current = new AbortController();
const history = [
...(historyOverride ?? (activeConversation?.messages ?? [])
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
}))),
{ role: "user" as const, content: trimmed },
];
// When historyOverride is set (edit / retry), the existing server-side
// session chain covers messages that were just truncated and is no longer
// valid for the rewritten history. Eagerly clear the session so that a
// failed/aborted edit does not leave a stale session ID that contaminates
// the next regular send.
if (historyOverride) {
setResponsesSessionId(null);
}
// On a normal continuation turn with an active session, the Responses API
// already holds the prior context server-side, so we only pass the new
// user message (sending the full history would double-count it).
//
// On the very first turn (no session yet), we send the full history.
const previousResponseId = historyOverride ? null : responsesSessionId;
const history: Array<{ role: "user" | "assistant"; content: string }> =
historyOverride
? [...historyOverride, { role: "user" as const, content: trimmed }]
: previousResponseId
? [{ role: "user" as const, content: trimmed }]
: [
// Explicitly filter to only user/assistant roles — tool messages
// lack a required tool_call_id and would cause API errors.
...(activeConversation?.messages ?? [])
.filter((m): m is typeof m & { role: "user" | "assistant" } =>
m.role === "user" || m.role === "assistant"
)
.map((m) => ({ role: m.role, content: m.content })),
{ role: "user" as const, content: trimmed },
];
let accumulatedContent = "";
let accumulatedReasoning = "";
// MCP events accumulated locally so we can persist them to the message
// without relying on component state (which would cause stale closures).
const accumulatedMCPEvents: MCPEvent[] = [];
// Track clean completion so partial events are not shown on error/abort.
let streamCompletedCleanly = false;
try {
await makeOpenAIChatCompletionRequest(
await makeOpenAIResponsesRequest(
history,
(chunk: string) => {
(_role: string, chunk: string) => {
accumulatedContent += chunk;
updateLastAssistantMessage(convId!, { content: accumulatedContent });
},
model,
accessToken,
undefined,
undefined, // tags
abortControllerRef.current.signal,
(rc: string) => {
accumulatedReasoning += rc;
@@ -270,7 +307,15 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
},
undefined, undefined, undefined, undefined, undefined, undefined,
selectedMCPServers.length > 0 ? selectedMCPServers : undefined,
previousResponseId,
(id: string) => setResponsesSessionId(id),
(event: MCPEvent) => {
// Accumulate locally only — persisted once in finally to avoid
// one full localStorage write per MCP event during streaming.
accumulatedMCPEvents.push(event);
},
);
streamCompletedCleanly = true;
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
updateLastAssistantMessage(convId!, {
@@ -282,12 +327,17 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
});
}
} finally {
// Only persist MCP events on clean completion — partial events from an
// aborted or errored turn would show incomplete tool calls to the user.
if (accumulatedMCPEvents.length > 0 && streamCompletedCleanly) {
updateLastAssistantMessage(convId!, { mcpEvents: accumulatedMCPEvents });
}
setIsStreaming(false);
abortControllerRef.current = null;
}
},
[activeConversationId, activeConversation, selectedModels, selectedMCPServers, accessToken,
createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming],
createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming, responsesSessionId],
);
const handleSendComparison = useCallback(
@@ -355,10 +405,10 @@ const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, user
const priorMessages = (idx === -1 ? msgs : msgs.slice(0, idx))
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({ role: m.role as "user" | "assistant", content: m.content }));
truncateAfterMessage(activeConversationId, messageId);
truncateFromMessage(activeConversationId, messageId);
handleSend(newContent, priorMessages);
},
[activeConversationId, isStreaming, activeConversation, truncateAfterMessage, handleSend],
[activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend],
);
const handleSubmit = useCallback(
@@ -170,9 +170,25 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
{/* Avatar + name + connect */}
<div style={{ display: "flex", alignItems: "flex-start", gap: 20, marginBottom: 28 }}>
{detailServer.mcp_info?.logo_url ? (
<img
src={detailServer.mcp_info.logo_url}
alt={`${name} logo`}
style={{
width: 64, height: 64, borderRadius: 16,
objectFit: "contain", flexShrink: 0,
background: "#f9fafb",
}}
onError={(e) => {
const el = e.target as HTMLImageElement;
el.style.display = "none";
if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex";
}}
/>
) : null}
<div style={{
width: 64, height: 64, borderRadius: 16,
background: color, display: "flex",
background: color, display: detailServer.mcp_info?.logo_url ? "none" : "flex",
alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize: 28, flexShrink: 0,
}}>
@@ -351,9 +367,26 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fafafa"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fff"; }}
>
{server.mcp_info?.logo_url ? (
<img
src={server.mcp_info.logo_url}
alt={`${name} logo`}
style={{
width: 38, height: 38, borderRadius: 10,
objectFit: "contain", flexShrink: 0,
background: "#f9fafb",
}}
onError={(e) => {
const el = e.target as HTMLImageElement;
el.style.display = "none";
if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex";
}}
/>
) : null}
<div style={{
width: 38, height: 38, borderRadius: 10, background: color,
display: "flex", alignItems: "center", justifyContent: "center",
display: server.mcp_info?.logo_url ? "none" : "flex",
alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize: 16, flexShrink: 0,
}}>
{name.charAt(0).toUpperCase()}
@@ -112,6 +112,18 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
gap: 12,
}}
>
{server.mcp_info?.logo_url && (
<img
src={server.mcp_info.logo_url}
alt={`${name} logo`}
style={{
width: 24, height: 24, borderRadius: 6,
objectFit: "contain", flexShrink: 0,
marginTop: 1,
}}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
@@ -1,8 +1,12 @@
export type { MCPEvent } from "../mcp_tools/types";
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "tool";
content: string;
reasoningContent?: string;
/** MCP tool events that occurred during this assistant turn, in order. */
mcpEvents?: MCPEvent[];
toolName?: string;
toolArgs?: Record<string, unknown>;
toolResult?: string;
@@ -51,8 +51,9 @@ export function useChatHistory(activeConversationId: string | null): {
staleId: boolean;
createConversation: (model: string) => string;
appendMessage: (conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => void;
updateLastAssistantMessage: (conversationId: string, updates: Partial<Pick<ChatMessage, "content" | "reasoningContent">>) => void;
truncateAfterMessage: (conversationId: string, messageId: string) => void;
updateLastAssistantMessage: (conversationId: string, updates: Partial<Pick<ChatMessage, "content" | "reasoningContent" | "mcpEvents">>) => void;
/** Remove the message with `messageId` and all subsequent messages from the conversation. */
truncateFromMessage: (conversationId: string, messageId: string) => void;
deleteConversation: (id: string) => void;
renameConversation: (id: string, newTitle: string) => void;
setActiveConversationId: (id: string | null) => void;
@@ -148,7 +149,7 @@ export function useChatHistory(activeConversationId: string | null): {
const updateLastAssistantMessage = useCallback(
(
conversationId: string,
updates: Partial<Pick<ChatMessage, "content" | "reasoningContent">>,
updates: Partial<Pick<ChatMessage, "content" | "reasoningContent" | "mcpEvents">>,
) => {
setConversations((prev) => {
const updated = prev.map((conv) => {
@@ -168,7 +169,7 @@ export function useChatHistory(activeConversationId: string | null): {
[],
);
const truncateAfterMessage = useCallback(
const truncateFromMessage = useCallback(
(conversationId: string, messageId: string) => {
setConversations((prev) => {
const updated = prev.map((conv) => {
@@ -222,7 +223,7 @@ export function useChatHistory(activeConversationId: string | null): {
createConversation,
appendMessage,
updateLastAssistantMessage,
truncateAfterMessage,
truncateFromMessage,
deleteConversation,
renameConversation,
setActiveConversationId,
@@ -13,6 +13,7 @@ interface MCPServerSelectorProps {
accessToken: string;
placeholder?: string;
disabled?: boolean;
teamId?: string | null;
}
const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
@@ -22,8 +23,9 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
accessToken,
placeholder = "Select MCP servers",
disabled = false,
teamId,
}) => {
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers();
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId);
const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups();
const loading = serversLoading || groupsLoading;
@@ -0,0 +1,123 @@
import React, { useState } from "react";
import { Input, Tooltip } from "antd";
import { InfoCircleOutlined, LinkOutlined } from "@ant-design/icons";
const logos = "/ui/assets/logos/";
const WELL_KNOWN_LOGOS: { name: string; url: string }[] = [
{ name: "GitHub", url: `${logos}github.svg` },
{ name: "Slack", url: `${logos}slack.svg` },
{ name: "Notion", url: `${logos}notion.svg` },
{ name: "Linear", url: `${logos}linear.svg` },
{ name: "Jira", url: `${logos}jira.svg` },
{ name: "Figma", url: `${logos}figma.svg` },
{ name: "Gmail", url: `${logos}gmail.svg` },
{ name: "Google Drive", url: `${logos}google_drive.svg` },
{ name: "Stripe", url: `${logos}stripe.svg` },
{ name: "Shopify", url: `${logos}shopify.svg` },
{ name: "Salesforce", url: `${logos}salesforce.svg` },
{ name: "HubSpot", url: `${logos}hubspot.svg` },
{ name: "Twilio", url: `${logos}twilio.svg` },
{ name: "Cloudflare", url: `${logos}cloudflare.svg` },
{ name: "Sentry", url: `${logos}sentry.svg` },
{ name: "PostgreSQL", url: `${logos}postgresql.svg` },
{ name: "Snowflake", url: `${logos}snowflake.svg` },
{ name: "Zapier", url: `${logos}zapier.svg` },
{ name: "Google", url: `${logos}google.svg` },
{ name: "GitLab", url: `${logos}gitlab.svg` },
];
interface MCPLogoSelectorProps {
value?: string;
onChange?: (url: string | undefined) => void;
}
const MCPLogoSelector: React.FC<MCPLogoSelectorProps> = ({ value, onChange }) => {
const [imgErrors, setImgErrors] = useState<Set<string>>(new Set());
const handleSelect = (url: string) => {
onChange?.(value === url ? undefined : url);
};
const handleImgError = (url: string) => {
setImgErrors((prev) => new Set(prev).add(url));
};
return (
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-700">Logo</span>
<Tooltip title="Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages.">
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</div>
{/* Preview */}
{value && (
<div className="flex items-center gap-3 mb-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<img
src={value}
alt="Selected logo"
className="w-10 h-10 object-contain rounded"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-500 truncate">{value}</div>
</div>
<button
type="button"
onClick={() => onChange?.(undefined)}
className="text-xs text-gray-400 hover:text-red-500 cursor-pointer bg-transparent border-none"
>
</button>
</div>
)}
{/* Well-known logo grid */}
<div className="grid grid-cols-10 gap-1.5 mb-3">
{WELL_KNOWN_LOGOS.map((logo) => {
const isSelected = value === logo.url;
const hasFailed = imgErrors.has(logo.url);
if (hasFailed) return null;
return (
<Tooltip key={logo.name} title={logo.name}>
<button
type="button"
onClick={() => handleSelect(logo.url)}
className={`flex items-center justify-center p-2 rounded-lg border transition-all cursor-pointer
${isSelected
? "border-blue-500 bg-blue-50 shadow-sm"
: "border-gray-200 hover:border-blue-300 hover:bg-gray-50"
}`}
style={{ width: 40, height: 40 }}
>
<img
src={logo.url}
alt={logo.name}
className="w-5 h-5 object-contain"
onError={() => handleImgError(logo.url)}
/>
</button>
</Tooltip>
);
})}
</div>
{/* Custom URL input */}
<Input
prefix={<LinkOutlined className="text-gray-400" />}
placeholder="Or paste a custom logo URL..."
value={value && !WELL_KNOWN_LOGOS.some((l) => l.url === value) ? value : ""}
onChange={(e) => {
const v = e.target.value.trim();
onChange?.(v || undefined);
}}
className="rounded-lg"
size="small"
/>
</div>
);
};
export default MCPLogoSelector;
@@ -12,6 +12,8 @@ interface OpenAPIFormSectionProps {
onValuesChange: (updates: Record<string, any>) => void;
/** Called when key tools change (from registry preset selection). */
onKeyToolsChange?: (tools: OpenAPIKeyTool[]) => void;
/** Called when a preset is selected so the parent can set the logo URL from icon_url. */
onLogoUrlChange?: (url: string | undefined) => void;
/** Called when the OAuth docs URL changes (e.g. link to create a GitHub OAuth App). */
onOAuthDocsUrlChange?: (url: string | null) => void;
}
@@ -26,6 +28,7 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
accessToken,
onValuesChange,
onKeyToolsChange,
onLogoUrlChange,
onOAuthDocsUrlChange,
}) => {
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
@@ -33,6 +36,7 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
const handlePresetSelect = (entry: OpenAPIRegistryEntry) => {
setSelectedPreset(entry.name);
onKeyToolsChange?.(entry.key_tools ?? []);
onLogoUrlChange?.(entry.icon_url || undefined);
const updates: Record<string, any> = {
spec_path: entry.spec_url,
};
@@ -11,6 +11,7 @@ import MCPToolConfiguration from "./mcp_tool_configuration";
import StdioConfiguration from "./StdioConfiguration";
import MCPPermissionManagement from "./MCPPermissionManagement";
import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection";
import MCPLogoSelector from "./MCPLogoSelector";
import { isAdminRole } from "@/utils/roles";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
@@ -70,6 +71,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const [keyTools, setKeyTools] = useState<OpenAPIKeyTool[]>([]);
const [searchValue, setSearchValue] = useState<string>("");
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
const [logoUrl, setLogoUrl] = useState<string | undefined>(undefined);
const [oauthDocsUrl, setOauthDocsUrl] = useState<string | null>(null);
// Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests.
@@ -101,6 +103,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
allowedTools,
searchValue,
aliasManuallyEdited,
logoUrl,
}),
);
} catch (err) {
@@ -202,6 +205,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
if (typeof parsed.aliasManuallyEdited === "boolean") {
setAliasManuallyEdited(parsed.aliasManuallyEdited);
}
if (parsed.logoUrl) {
setLogoUrl(parsed.logoUrl);
}
} catch (err) {
console.error("Failed to restore MCP create state", err);
} finally {
@@ -357,6 +363,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
mcp_info: {
server_name: restValues.server_name || restValues.url,
description: restValues.description,
logo_url: logoUrl || undefined,
mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null,
},
mcp_access_groups: accessGroups,
@@ -394,6 +401,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
clearTools();
setAllowedTools([]);
setAliasManuallyEdited(false);
setLogoUrl(undefined);
setModalVisible(false);
onCreateSuccess(response);
}
@@ -414,6 +422,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
clearTools();
setAllowedTools([]);
setAliasManuallyEdited(false);
setLogoUrl(undefined);
setModalVisible(false);
};
@@ -590,6 +599,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
/>
</Form.Item>
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
<Form.Item
label={<span className="text-sm font-medium text-gray-700">GitHub / Source URL</span>}
name="source_url"
@@ -645,6 +656,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
setFormValues((prev) => ({ ...prev, ...updates }))
}
onKeyToolsChange={setKeyTools}
onLogoUrlChange={setLogoUrl}
onOAuthDocsUrlChange={setOauthDocsUrl}
/>
)}
@@ -1,3 +1,4 @@
import { useState } from "react";
import { ColumnDef } from "@tanstack/react-table";
import { MCPServer } from "./types";
import { Icon } from "@tremor/react";
@@ -6,6 +7,82 @@ import { getMaskedAndFullUrl } from "./utils";
import { Tooltip } from "antd";
import { CheckOutlined } from "@ant-design/icons";
const HealthStatusBadge: React.FC<{
server: MCPServer;
isLoadingHealth?: boolean;
isRechecking?: boolean;
onRecheck?: (serverId: string) => void;
}> = ({ server, isLoadingHealth, isRechecking, onRecheck }) => {
const [isHovered, setIsHovered] = useState(false);
const status = server.status || "unknown";
const lastCheck = server.last_health_check;
const error = server.health_check_error;
if (isLoadingHealth || isRechecking) {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 px-2 py-0.5 rounded-full bg-gray-50 border border-gray-100">
<span className="h-1.5 w-1.5 rounded-full bg-gray-300 animate-pulse"></span>
Checking
</span>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case "healthy":
return "text-green-700 bg-green-50 border border-green-200";
case "unhealthy":
return "text-red-700 bg-red-50 border border-red-200";
default:
return "text-gray-600 bg-gray-50 border border-gray-200";
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "healthy":
return "✓";
case "unhealthy":
return "✗";
default:
return "?";
}
};
const isClickable = !!onRecheck;
const tooltipContent = (
<div className="max-w-xs">
<div className="font-semibold mb-1">Health Status: {status}</div>
{lastCheck && <div className="text-xs mb-1">Last Check: {new Date(lastCheck).toLocaleString()}</div>}
{error && (
<div className="text-xs">
<div className="font-medium text-red-400 mb-1">Error:</div>
<div className="break-words">{error}</div>
</div>
)}
{!lastCheck && !error && <div className="text-xs text-gray-400">No health check data available</div>}
{isClickable && <div className="text-xs text-gray-400 mt-1">Click to recheck</div>}
</div>
);
return (
<Tooltip title={tooltipContent} placement="top">
<span
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${getStatusColor(status)} ${isClickable ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={isClickable ? () => onRecheck(server.server_id) : undefined}
>
<span>{isHovered && isClickable ? "↻" : getStatusIcon(status)}</span>
{isHovered && isClickable
? "Recheck"
: status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</Tooltip>
);
};
export const mcpServerColumns = (
userRole: string,
onView: (serverId: string) => void,
@@ -13,6 +90,8 @@ export const mcpServerColumns = (
onDelete: (serverId: string) => void,
isLoadingHealth?: boolean,
onByokConnect?: (server: MCPServer) => void,
onRecheckHealth?: (serverId: string) => void,
recheckingServerIds?: Set<string>,
): ColumnDef<MCPServer>[] => [
{
accessorKey: "server_id",
@@ -31,6 +110,23 @@ export const mcpServerColumns = (
accessorKey: "server_name",
header: "Name",
enableSorting: true,
cell: ({ row }) => {
const logoUrl = row.original.mcp_info?.logo_url;
const name = row.original.server_name;
return (
<div className="flex items-center gap-2">
{logoUrl ? (
<img
src={logoUrl}
alt={`${name ?? "MCP"} logo`}
className="h-5 w-5 rounded object-contain flex-shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : null}
<span>{name}</span>
</div>
);
},
},
{
accessorKey: "alias",
@@ -81,68 +177,14 @@ export const mcpServerColumns = (
{
id: "health_status",
header: "Health Status",
cell: ({ row }) => {
const server = row.original;
const status = server.status || "unknown";
const lastCheck = server.last_health_check;
const error = server.health_check_error;
if (isLoadingHealth) {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 px-2 py-0.5 rounded-full bg-gray-50 border border-gray-100">
<span className="h-1.5 w-1.5 rounded-full bg-gray-300 animate-pulse"></span>
Checking
</span>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case "healthy":
return "text-green-700 bg-green-50 border border-green-200";
case "unhealthy":
return "text-red-700 bg-red-50 border border-red-200";
default:
return "text-gray-600 bg-gray-50 border border-gray-200";
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "healthy":
return "✓";
case "unhealthy":
return "✗";
default:
return "?";
}
};
const tooltipContent = (
<div className="max-w-xs">
<div className="font-semibold mb-1">Health Status: {status}</div>
{lastCheck && <div className="text-xs mb-1">Last Check: {new Date(lastCheck).toLocaleString()}</div>}
{error && (
<div className="text-xs">
<div className="font-medium text-red-400 mb-1">Error:</div>
<div className="break-words">{error}</div>
</div>
)}
{!lastCheck && !error && <div className="text-xs text-gray-400">No health check data available</div>}
</div>
);
return (
<Tooltip title={tooltipContent} placement="top">
<span
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full cursor-default ${getStatusColor(status)}`}
>
<span>{getStatusIcon(status)}</span>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</Tooltip>
);
},
cell: ({ row }) => (
<HealthStatusBadge
server={row.original}
isLoadingHealth={isLoadingHealth}
isRechecking={recheckingServerIds?.has(row.original.server_id)}
onRecheck={onRecheckHealth}
/>
),
},
{
id: "mcp_access_groups",
@@ -8,6 +8,7 @@ import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPPermissionManagement from "./MCPPermissionManagement";
import MCPToolConfiguration from "./mcp_tool_configuration";
import StdioConfiguration from "./StdioConfiguration";
import MCPLogoSelector from "./MCPLogoSelector";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
@@ -41,6 +42,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
const [toolNameToDisplayName, setToolNameToDisplayName] = useState<Record<string, string>>({});
const [toolNameToDescription, setToolNameToDescription] = useState<Record<string, string>>({});
const [pendingRestoredValues, setPendingRestoredValues] = useState<Record<string, any> | null>(null);
const [logoUrl, setLogoUrl] = useState<string | undefined>(mcpServer.mcp_info?.logo_url || undefined);
const authType = Form.useWatch("auth_type", form) as string | undefined;
const transportType = Form.useWatch("transport", form) as string | undefined;
const isStdioTransport = transportType === "stdio";
@@ -538,6 +540,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
mcp_info: {
server_name: mcpInfoServerName,
description: restValues.description,
logo_url: logoUrl || undefined,
mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null,
},
mcp_access_groups: accessGroups,
@@ -604,6 +607,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
<Form.Item label="Description" name="description">
<Input className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" />
</Form.Item>
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
<Form.Item label="Transport Type" name="transport" rules={[{ required: true }]}>
<Select onChange={handleTransportChange}>
<Select.Option value="http">Streamable HTTP (Recommended)</Select.Option>
@@ -29,7 +29,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers();
// Fetch health status for all servers
const { data: healthStatuses, isLoading: isLoadingHealth } = useMCPServerHealth();
const { data: healthStatuses, isLoading: isLoadingHealth, recheckServerHealth, recheckingServerIds } = useMCPServerHealth();
// Merge health status data into servers
const serversWithHealth = useMemo(() => {
@@ -163,8 +163,10 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
handleDelete,
isLoadingHealth,
(server: MCPServer) => setByokModalServer(server),
recheckServerHealth,
recheckingServerIds,
),
[userRole, isLoadingHealth],
[userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds],
);
function handleDelete(server_id: string) {
@@ -1,3 +1,30 @@
/** A single MCP tool event emitted by the LiteLLM proxy during a Responses API turn. */
export interface MCPEvent {
type: string;
sequence_number?: number;
output_index?: number;
item_id?: string;
item?: {
id?: string;
type?: string;
server_label?: string;
tools?: Array<{
name: string;
description: string;
annotations?: {
read_only?: boolean;
};
input_schema?: unknown;
}>;
name?: string;
arguments?: string;
output?: string;
};
delta?: string;
arguments?: string;
timestamp?: number;
}
export interface Team {
team_id: string;
team_alias?: string;
@@ -6320,10 +6320,15 @@ export const fetchDiscoverableMCPServers = async (accessToken: string) => {
}
};
export const fetchMCPServers = async (accessToken: string) => {
export const fetchMCPServers = async (accessToken: string, teamId?: string | null) => {
try {
// Construct base URL
const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`;
// Construct base URL with optional team_id filter
let url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`;
if (teamId) {
const params = new URLSearchParams();
params.append("team_id", teamId);
url = `${url}?${params.toString()}`;
}
console.log("Fetching MCP servers from:", url);
@@ -563,6 +563,8 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
if (!pendingPrefillModels) {
form.setFieldValue("models", []);
}
// Clear MCP server selection when team changes (available servers may differ)
form.setFieldValue("allowed_mcp_servers_and_groups", { servers: [], accessGroups: [] });
}, [selectedCreateKeyTeam, selectedProjectId, accessToken, userID, userRole, form]);
// Apply deferred model prefill once the available model list arrives.
@@ -1323,6 +1325,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
onChange={(val: any) => form.setFieldValue("allowed_mcp_servers_and_groups", val)}
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
accessToken={accessToken}
teamId={selectedCreateKeyTeam?.team_id ?? null}
placeholder="Select MCP servers or access groups (optional)"
/>
</Form.Item>
@@ -1,35 +1,10 @@
import React from "react";
import { Typography, Collapse } from "antd";
import type { MCPEvent } from "../../mcp_tools/types";
const { Text } = Typography;
const { Panel } = Collapse;
export interface MCPEvent {
type: string;
sequence_number?: number;
output_index?: number;
item_id?: string;
item?: {
id?: string;
type?: string;
server_label?: string;
tools?: Array<{
name: string;
description: string;
annotations?: {
read_only?: boolean;
};
input_schema?: any;
}>;
name?: string;
arguments?: string;
output?: string;
};
delta?: string;
arguments?: string;
timestamp?: number;
}
interface MCPEventsDisplayProps {
events: MCPEvent[];
className?: string;
@@ -3,7 +3,7 @@ import { MessageType } from "../chat_ui/types";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { getProxyBaseUrl } from "@/components/networking";
import NotificationManager from "@/components/molecules/notifications_manager";
import { MCPEvent } from "../chat_ui/MCPEventsDisplay";
import type { MCPEvent } from "../../mcp_tools/types";
import { MCPServer } from "../../mcp_tools/types";
import {
CodeInterpreterResult,
@@ -98,20 +98,22 @@ export async function makeOpenAIResponsesRequest(
tools.push({
type: "mcp",
server_label: "litellm",
server_url: "litellm_proxy/mcp",
server_url: `${proxyBaseUrl}/mcp`,
require_approval: "never",
});
} else {
// Individual servers selected - create one entry per server
selectedMCPServers.forEach((serverId) => {
const server = mcpServers?.find((s) => s.server_id === serverId);
const serverName = server?.alias || server?.server_name || serverId;
// Use server_name for both routing and labelling. server_name is the
// unique registered identifier; aliases can collide across servers.
const routeName = server?.server_name || serverId;
const allowedTools = mcpServerToolRestrictions?.[serverId] || [];
tools.push({
type: "mcp",
server_label: "litellm",
server_url: `litellm_proxy/mcp/${serverName}`,
server_label: routeName, // unique per request — collisions cause silent tool-routing failures
server_url: `${proxyBaseUrl}/mcp/${encodeURIComponent(routeName)}`,
require_approval: "never",
...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}),
});
@@ -197,8 +199,7 @@ export async function makeOpenAIResponsesRequest(
if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
const delta = event.delta;
console.log("Text delta", delta);
// skip pure whitespace/newlines
if (delta.trim().length > 0) {
if (delta.length > 0) {
updateTextUI("assistant", delta, selectedModel);
// Calculate time to first token
@@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { ConfigInfoMessage } from "./ConfigInfoMessage";
describe("ConfigInfoMessage", () => {
it("should render the info message when show is true", () => {
render(<ConfigInfoMessage show={true} />);
expect(screen.getByText("Request/Response Data Not Available")).toBeInTheDocument();
});
it("should render nothing when show is false", () => {
const { container } = render(<ConfigInfoMessage show={false} />);
expect(container.innerHTML).toBe("");
});
it("should display the YAML config snippet", () => {
render(<ConfigInfoMessage show={true} />);
expect(screen.getByText(/store_prompts_in_spend_logs: true/)).toBeInTheDocument();
});
it("should render the settings button when onOpenSettings is provided", () => {
render(<ConfigInfoMessage show={true} onOpenSettings={() => {}} />);
expect(screen.getByText("open the settings")).toBeInTheDocument();
});
it("should not render the settings button when onOpenSettings is omitted", () => {
render(<ConfigInfoMessage show={true} />);
expect(screen.queryByText("open the settings")).not.toBeInTheDocument();
});
it("should call onOpenSettings when the settings button is clicked", async () => {
const user = userEvent.setup();
const onOpenSettings = vi.fn();
render(<ConfigInfoMessage show={true} onOpenSettings={onOpenSettings} />);
await user.click(screen.getByText("open the settings"));
expect(onOpenSettings).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,87 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { ErrorViewer } from "./ErrorViewer";
const basicError = {
error_class: "NotFoundError",
error_message: "Model gpt-5 not found",
};
const errorWithTraceback = {
error_class: "AuthenticationError",
error_message: "Invalid API key",
traceback: `Traceback (most recent call last):
File "/app/main.py", line 42, in handle_request
result = await client.chat(model="gpt-4")
File "/app/llms/openai.py", line 100, in chat
response = self._make_request(payload)
File "/app/llms/base.py", line 55, in _make_request
raise AuthenticationError("Invalid API key")`,
};
describe("ErrorViewer", () => {
it("should render error type and message", () => {
render(<ErrorViewer errorInfo={basicError} />);
expect(screen.getByText("NotFoundError")).toBeInTheDocument();
expect(screen.getByText("Model gpt-5 not found")).toBeInTheDocument();
});
it("should show 'Unknown Error' when error_class is missing", () => {
render(<ErrorViewer errorInfo={{ error_message: "something broke" }} />);
expect(screen.getByText("Unknown Error")).toBeInTheDocument();
});
it("should show 'Unknown error occurred' when error_message is missing", () => {
render(<ErrorViewer errorInfo={{ error_class: "RuntimeError" }} />);
expect(screen.getByText("Unknown error occurred")).toBeInTheDocument();
});
it("should render traceback frames when traceback is present", () => {
render(<ErrorViewer errorInfo={errorWithTraceback} />);
expect(screen.getByText("Traceback")).toBeInTheDocument();
expect(screen.getByText("main.py")).toBeInTheDocument();
expect(screen.getByText("openai.py")).toBeInTheDocument();
expect(screen.getByText("base.py")).toBeInTheDocument();
});
it("should expand a frame when clicked", async () => {
const user = userEvent.setup();
render(<ErrorViewer errorInfo={errorWithTraceback} />);
await user.click(screen.getByText("main.py"));
expect(
screen.getByText("result = await client.chat(model=\"gpt-4\")")
).toBeInTheDocument();
});
it("should expand all frames when 'Expand All' is clicked", async () => {
const user = userEvent.setup();
render(<ErrorViewer errorInfo={errorWithTraceback} />);
await user.click(screen.getByText("Expand All"));
expect(screen.getByText("Collapse All")).toBeInTheDocument();
});
it("should copy traceback to clipboard when copy button is clicked", async () => {
const user = userEvent.setup();
const mockWriteText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText: mockWriteText },
writable: true,
configurable: true,
});
render(<ErrorViewer errorInfo={errorWithTraceback} />);
await user.click(screen.getByTitle("Copy traceback"));
expect(mockWriteText).toHaveBeenCalledWith(errorWithTraceback.traceback);
});
it("should not render traceback section when traceback is absent", () => {
render(<ErrorViewer errorInfo={basicError} />);
expect(screen.queryByText("Traceback")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,32 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TruncatedValue } from "./TruncatedValue";
describe("TruncatedValue", () => {
it("should render the value text", () => {
render(<TruncatedValue value="chatcmpl-abc123" />);
expect(screen.getByText("chatcmpl-abc123")).toBeInTheDocument();
});
it("should render a dash when value is undefined", () => {
render(<TruncatedValue />);
expect(screen.getByText("-")).toBeInTheDocument();
});
it("should render a dash when value is empty string", () => {
render(<TruncatedValue value="" />);
expect(screen.getByText("-")).toBeInTheDocument();
});
it("should apply the default maxWidth when not specified", () => {
render(<TruncatedValue value="some-long-id-value" />);
const el = screen.getByText("some-long-id-value");
expect(el).toHaveStyle({ maxWidth: "180px" });
});
it("should apply custom maxWidth when provided", () => {
render(<TruncatedValue value="test-value" maxWidth={300} />);
const el = screen.getByText("test-value");
expect(el).toHaveStyle({ maxWidth: "300px" });
});
});
@@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LlmBadge, McpBadge, AgentBadge } from "./TypeBadges";
describe("TypeBadges", () => {
describe("LlmBadge", () => {
it("should render with default 'LLM' text when no count is provided", () => {
render(<LlmBadge />);
expect(screen.getByText("LLM")).toBeInTheDocument();
});
it("should render the count when provided", () => {
render(<LlmBadge count={5} />);
expect(screen.getByText("5")).toBeInTheDocument();
});
it("should render count of 0 instead of default text", () => {
render(<LlmBadge count={0} />);
expect(screen.getByText("0")).toBeInTheDocument();
});
});
describe("McpBadge", () => {
it("should render with default 'MCP' text when no count is provided", () => {
render(<McpBadge />);
expect(screen.getByText("MCP")).toBeInTheDocument();
});
it("should render the count when provided", () => {
render(<McpBadge count={3} />);
expect(screen.getByText("3")).toBeInTheDocument();
});
});
describe("AgentBadge", () => {
it("should render with default 'Agent' text when no count is provided", () => {
render(<AgentBadge />);
expect(screen.getByText("Agent")).toBeInTheDocument();
});
it("should render the count when provided", () => {
render(<AgentBadge count={12} />);
expect(screen.getByText("12")).toBeInTheDocument();
});
});
});
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TimeCell, getTimeZone } from "./time_cell";
describe("TimeCell", () => {
it("should render a formatted time string", () => {
render(<TimeCell utcTime="2025-06-15T14:30:00Z" />);
// The global toLocaleString mock in setupTests returns "YYYY-MM-DD HH:MM:SS"
expect(screen.getByText(/2025/)).toBeInTheDocument();
});
it("should render 'Error converting time' for invalid dates", () => {
// toLocaleString on an Invalid Date returns "Invalid Date", not throwing,
// but the component catches exceptions. Force an error by passing something
// that causes Date constructor to produce NaN.
render(<TimeCell utcTime="not-a-date" />);
// The mock returns "NaN-NaN-NaN NaN:NaN:NaN" for invalid dates
// The component has a try/catch that returns "Error converting time" on exception
const el = screen.getByText(/NaN|Error/);
expect(el).toBeInTheDocument();
});
it("should render with monospace font", () => {
render(<TimeCell utcTime="2025-06-15T14:30:00Z" />);
const span = screen.getByText(/2025/);
expect(span).toHaveStyle({ fontFamily: "monospace" });
});
});
describe("getTimeZone", () => {
it("should return a non-empty timezone string", () => {
const tz = getTimeZone();
expect(typeof tz).toBe("string");
expect(tz.length).toBeGreaterThan(0);
});
});