Merge pull request #19401 from BerriAI/main

Merge main 01 20 2026
This commit is contained in:
Sameer Kankute 2026-01-20 16:45:08 +05:30 • committed by GitHub
commit dd6b35e825
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 3452 additions and 694 deletions

View file

@ -44,8 +44,8 @@ commands:
pip install "pytest-asyncio==0.21.1"
pip install "respx==0.22.0"
pip install "hypercorn==0.17.3"
pip install "pydantic==2.10.2"
pip install "mcp==1.10.1"
pip install "pydantic==2.11.0"
pip install "mcp==1.25.0"
pip install "requests-mock>=1.12.1"
pip install "responses==0.25.7"
pip install "pytest-xdist==3.6.1"
@ -1152,8 +1152,8 @@ jobs:
pip install "pytest-cov==5.0.0"
pip install "pytest-asyncio==0.21.1"
pip install "respx==0.22.0"
pip install "pydantic==2.10.2"
pip install "mcp==1.21.2"
pip install "pydantic==2.11.0"
pip install "mcp==1.25.0"
# Run pytest and generate JUnit XML report
- run:
name: Run tests
@ -1556,8 +1556,8 @@ jobs:
pip install "pytest-asyncio==0.21.1"
pip install "respx==0.22.0"
pip install "hypercorn==0.17.3"
pip install "pydantic==2.10.2"
pip install "mcp==1.10.1"
pip install "pydantic==2.11.0"
pip install "mcp==1.25.0"
pip install "requests-mock>=1.12.1"
pip install "responses==0.25.7"
pip install "pytest-xdist==3.6.1"
@ -1915,7 +1915,7 @@ jobs:
pip install "pytest-asyncio==0.21.1"
pip install "pytest-cov==5.0.0"
pip install "tomli==2.2.1"
pip install "mcp==1.10.1"
pip install "mcp==1.25.0"
- run:
name: Run tests
command: |

View file

@ -8,12 +8,12 @@ redis==5.2.1
redisvl==0.4.1
anthropic
orjson==3.10.12 # fast /embedding responses
pydantic==2.10.2
pydantic==2.11.0
google-cloud-aiplatform==1.43.0
google-cloud-iam==2.19.1
fastapi-sso==0.16.0
uvloop==0.21.0
mcp==1.10.1 # for MCP server
mcp==1.25.0 # for MCP server
semantic_router==0.1.10 # for auto-routing with litellm
fastuuid==0.12.0
responses==0.25.7 # for proxy client tests

View file

@ -34,8 +34,8 @@ jobs:
poetry run pip install "pytest-cov==5.0.0"
poetry run pip install "pytest-asyncio==0.21.1"
poetry run pip install "respx==0.22.0"
poetry run pip install "pydantic==2.10.2"
poetry run pip install "mcp==1.10.1"
poetry run pip install "pydantic==2.11.0"
poetry run pip install "mcp==1.25.0"
poetry run pip install pytest-xdist
- name: Setup litellm-enterprise as local package

View file

@ -237,6 +237,27 @@ litellm_settings:
language: "en"
```
### Example: Pillar Security
[Pillar Security](https://pillar.security) uses the Generic Guardrail API to provide comprehensive AI security scanning including prompt injection protection, PII/PCI detection, secret detection, and content moderation.
```yaml
guardrails:
- guardrail_name: "pillar-security"
litellm_params:
guardrail: generic_guardrail_api
mode: [pre_call, post_call]
api_base: https://api.pillar.security/api/v1/integrations/litellm
api_key: os.environ/PILLAR_API_KEY
default_on: true
additional_provider_specific_params:
plr_mask: true # Enable automatic masking of sensitive data
plr_evidence: true # Include detection evidence in response
plr_scanners: true # Include scanner details in response
```
See the [Pillar Security documentation](../proxy/guardrails/pillar_security.md) for full configuration options.
## Usage
Users apply your guardrail by name:

View file

@ -21,6 +21,11 @@ LiteLLM Proxy provides an MCP Gateway that allows you to use a fixed endpoint fo
| Supported MCP Transports | • Streamable HTTP<br/>• SSE<br/>• Standard Input/Output (stdio) |
| LiteLLM Permission Management | • By Key<br/>• By Team<br/>• By Organization |
:::caution MCP protocol update
Starting in LiteLLM v1.80.18, the LiteLLM MCP protocol version is `2025-11-25`.<br/>
LiteLLM namespaces multiple MCP servers by prefixing each tool name with its MCP server name, so newly created servers now must use names that comply with SEP-986—noncompliant names cannot be added anymore. Existing servers that still violate SEP-986 only emit warnings today, but future MCP-side rollouts may block those names entirely, so we recommend updating any legacy server names proactively before MCP enforcement makes them unusable.
:::
## Adding your MCP
### Prerequisites

View file

@ -45,7 +45,7 @@ model_list:
litellm_params:
model: vertex_ai/gemini-1.0-pro
vertex_project: adroit-crow-413218
vertex_region: us-central1
vertex_location: us-central1
vertex_credentials: /path/to/credentials.json
use_in_pass_through: true # 👈 KEY CHANGE
```
@ -57,9 +57,9 @@ model_list:
<TabItem value="yaml" label="Set in config.yaml">
```yaml
default_vertex_config:
default_vertex_config:
vertex_project: adroit-crow-413218
vertex_region: us-central1
vertex_location: us-central1
vertex_credentials: /path/to/credentials.json
```
</TabItem>

File diff suppressed because it is too large Load diff

View file

@ -59,6 +59,18 @@ guardrails:
presidio_score_thresholds: # minimum confidence scores for keeping detections
CREDIT_CARD: 0.8
EMAIL_ADDRESS: 0.6
# Example Pillar Security config via Generic Guardrail API
- guardrail_name: "pillar-security"
litellm_params:
guardrail: generic_guardrail_api
mode: [pre_call, post_call]
api_base: https://api.pillar.security/api/v1/integrations/litellm
api_key: os.environ/PILLAR_API_KEY
additional_provider_specific_params:
plr_mask: true
plr_evidence: true
plr_scanners: true
```

View file

@ -0,0 +1,279 @@
import Image from '@theme/IdealImage';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Claude Code Plugin Marketplace
LiteLLM AI Gateway acts as a central registry for Claude Code plugins. Admins can govern which plugins are available across the organization, and engineers can discover and install approved plugins from a single source.
## Prerequisites
- LiteLLM Proxy running with database connected
- Admin access to LiteLLM UI
- Plugins hosted on GitHub, GitLab, or any git-accessible URL
## Admin Guide: Managing the Marketplace
### Step 1: Navigate to Claude Code Plugins
In the LiteLLM Admin UI, click on **Claude Code Plugins** in the left navigation menu.
<Image img={require('../../img/claude_code_marketplace/step1_navigate_plugins.jpeg')} style={{ width: '800px', height: 'auto' }} />
### Step 2: View the Plugins List
You'll see the list of all registered plugins. From here you can add, enable, disable, or delete plugins.
<Image img={require('../../img/claude_code_marketplace/step3_plugins_list.jpeg')} style={{ width: '800px', height: 'auto' }} />
### Step 3: Add a New Plugin
Click **+ Add New Plugin** to register a plugin in your marketplace.
<Image img={require('../../img/claude_code_marketplace/step4_add_plugin.jpeg')} style={{ width: '800px', height: 'auto' }} />
### Step 4: Fill in Plugin Details
Enter the plugin information:
- **Name**: Plugin identifier (kebab-case, e.g., `my-plugin`)
- **Source Type**: Choose GitHub or URL
- **Repository/URL**: The git source (e.g., `org/repo` for GitHub)
- **Version**: Semantic version (optional)
- **Description**: What the plugin does
- **Category**: Plugin category for organization
- **Keywords**: Search terms
<Image img={require('../../img/claude_code_marketplace/step5_plugin_form.jpeg')} style={{ width: '800px', height: 'auto' }} />
### Step 5: Submit the Plugin
After filling in the details, click **Add Plugin** to register it.
<Image img={require('../../img/claude_code_marketplace/step9_submit.jpeg')} style={{ width: '800px', height: 'auto' }} />
### Step 6: Enable/Disable Plugins
Toggle plugins on or off to control what appears in the public marketplace. Only **enabled** plugins are visible to engineers.
<Image img={require('../../img/claude_code_marketplace/step11_enable_plugin.jpeg')} style={{ width: '800px', height: 'auto' }} />
## Engineer Guide: Installing Plugins
### Step 1: Add the LiteLLM Marketplace
Add your company's LiteLLM marketplace to Claude Code:
```bash
claude plugin marketplace add http://your-litellm-proxy:4000/claude-code/marketplace.json
```
<Image img={require('../../img/claude_code_marketplace/step12_cli_marketplace.jpeg')} style={{ width: '800px', height: 'auto' }} />
### Step 2: Browse Available Plugins
List all available plugins from the marketplace:
```bash
claude plugin search @litellm
```
### Step 3: Install a Plugin
Install any plugin from the marketplace:
```bash
claude plugin install my-plugin@litellm
```
<Image img={require('../../img/claude_code_marketplace/step15_cli_paste.jpeg')} style={{ width: '800px', height: 'auto' }} />
### Step 4: Verify Installation
The plugin is now installed and ready to use:
<Image img={require('../../img/claude_code_marketplace/step16_cli_complete.jpeg')} style={{ width: '800px', height: 'auto' }} />
## API Reference
### Public Endpoint (No Auth Required)
#### GET `/claude-code/marketplace.json`
Returns the marketplace catalog for Claude Code discovery.
```bash
curl http://localhost:4000/claude-code/marketplace.json
```
**Response:**
```json
{
"name": "litellm",
"owner": {
"name": "LiteLLM",
"email": "support@litellm.ai"
},
"plugins": [
{
"name": "my-plugin",
"source": {
"source": "github",
"repo": "org/my-plugin"
},
"version": "1.0.0",
"description": "My awesome plugin",
"category": "productivity",
"keywords": ["automation", "tools"]
}
]
}
```
### Admin Endpoints (Auth Required)
#### POST `/claude-code/plugins`
Register a new plugin.
```bash
curl -X POST http://localhost:4000/claude-code/plugins \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{
"name": "my-plugin",
"source": {"source": "github", "repo": "org/my-plugin"},
"version": "1.0.0",
"description": "My awesome plugin",
"category": "productivity",
"keywords": ["automation", "tools"]
}'
```
#### GET `/claude-code/plugins`
List all registered plugins.
```bash
curl http://localhost:4000/claude-code/plugins \
-H "Authorization: Bearer sk-..."
```
#### POST `/claude-code/plugins/{name}/enable`
Enable a plugin.
```bash
curl -X POST http://localhost:4000/claude-code/plugins/my-plugin/enable \
-H "Authorization: Bearer sk-..."
```
#### POST `/claude-code/plugins/{name}/disable`
Disable a plugin.
```bash
curl -X POST http://localhost:4000/claude-code/plugins/my-plugin/disable \
-H "Authorization: Bearer sk-..."
```
#### DELETE `/claude-code/plugins/{name}`
Delete a plugin.
```bash
curl -X DELETE http://localhost:4000/claude-code/plugins/my-plugin \
-H "Authorization: Bearer sk-..."
```
## Plugin Source Formats
<Tabs>
<TabItem value="github" label="GitHub">
```json
{
"name": "my-plugin",
"source": {
"source": "github",
"repo": "organization/repository"
}
}
```
</TabItem>
<TabItem value="url" label="Git URL">
```json
{
"name": "my-plugin",
"source": {
"source": "url",
"url": "https://github.com/org/repo.git"
}
}
```
Use this format for GitLab, Bitbucket, or self-hosted git repositories.
</TabItem>
</Tabs>
## Example: Setting Up an Internal Plugin Marketplace
### 1. Create Internal Plugins
Structure your plugin repository:
```
my-company-plugin/
├── plugin.json # Plugin manifest
├── SKILL.md # Main skill file
├── skills/ # Additional skills
│ └── helper.md
└── README.md
```
### 2. Register Plugins via API
```bash
# Register your internal tools plugin
curl -X POST http://localhost:4000/claude-code/plugins \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "internal-tools",
"source": {"source": "github", "repo": "mycompany/internal-tools"},
"version": "1.0.0",
"description": "Internal development tools and utilities",
"author": {"name": "Platform Team", "email": "platform@mycompany.com"},
"category": "internal",
"keywords": ["internal", "tools", "utilities"]
}'
```
### 3. Share with Your Team
Send engineers the marketplace URL:
```bash
# One-time setup for each engineer
claude plugin marketplace add http://litellm.internal.company.com/claude-code/marketplace.json
# Install company plugins
claude plugin install internal-tools@litellm
```
## Troubleshooting
**Plugin not appearing in marketplace:**
- Verify the plugin is **enabled** in the admin UI
- Check that the plugin has a valid `source` field
**Installation fails:**
- Ensure the git repository is accessible from the engineer's machine
- For private repos, engineers need appropriate git credentials configured
**Database errors:**
- Verify LiteLLM proxy is connected to the database
- Check proxy logs for detailed error messages

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View file

@ -125,6 +125,7 @@ const sidebars = {
"tutorials/claude_code_websearch",
"tutorials/claude_mcp",
"tutorials/claude_non_anthropic_models",
"tutorials/claude_code_plugin_marketplace",
]
},
"tutorials/cost_tracking_coding",

View file

@ -4,14 +4,13 @@ LiteLLM Proxy uses this MCP Client to connnect to other MCP servers.
import asyncio
import base64
from datetime import timedelta
from typing import Awaitable, Callable, Dict, List, Optional, TypeVar, Union
import httpx
from mcp import ClientSession, ReadResourceResult, Resource, StdioServerParameters
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.streamable_http import streamable_http_client
from mcp.types import (
CallToolRequestParams as MCPCallToolRequestParams,
GetPromptRequestParams,
@ -80,6 +79,7 @@ class MCPClient:
) -> TSessionResult:
"""Open a session, run the provided coroutine, and clean up."""
transport_ctx = None
http_client: Optional[httpx.AsyncClient] = None
try:
if self.transport_type == MCPTransport.stdio:
@ -105,13 +105,15 @@ class MCPClient:
headers = self._get_auth_headers()
httpx_client_factory = self._create_httpx_client_factory()
verbose_logger.debug(
"litellm headers for streamablehttp_client: %s", headers
"litellm headers for streamable_http_client: %s", headers
)
transport_ctx = streamablehttp_client(
url=self.server_url,
timeout=timedelta(seconds=self.timeout),
http_client = httpx_client_factory(
headers=headers,
httpx_client_factory=httpx_client_factory,
timeout=httpx.Timeout(self.timeout),
)
transport_ctx = streamable_http_client(
url=self.server_url,
http_client=http_client,
)
if transport_ctx is None:
@ -128,6 +130,9 @@ class MCPClient:
"MCP client run_with_session failed for %s", self.server_url or "stdio"
)
raise
finally:
if http_client is not None:
await http_client.aclose()
def update_auth_value(self, mcp_auth_value: Union[str, Dict[str, str]]):
"""

View file

@ -1071,9 +1071,9 @@ def _extract_reasoning_content(message: dict) -> Tuple[Optional[str], Optional[s
"""
message_content = message.get("content")
if "reasoning_content" in message:
return message["reasoning_content"], message["content"]
return message["reasoning_content"], message_content
elif "reasoning" in message:
return message["reasoning"], message["content"]
return message["reasoning"], message_content
elif isinstance(message_content, str):
return _parse_content_for_reasoning(message_content)
return None, message_content

View file

@ -7255,4 +7255,4 @@ def __getattr__(name: str) -> Any:
global _encoding_cache
_encoding_cache = _encoding
return _encoding
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View file

@ -26720,15 +26720,15 @@
"tool_use_system_prompt_tokens": 159
},
"us.anthropic.claude-opus-4-5-20251101-v1:0": {
"cache_creation_input_token_cost": 6.25e-06,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"cache_creation_input_token_cost": 6.875e-06,
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 5.5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"output_cost_per_token": 2.75e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,

View file

@ -38,6 +38,7 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
)
from litellm.proxy._experimental.mcp_server.utils import (
MCP_TOOL_PREFIX_SEPARATOR,
add_server_prefix_to_name,
get_server_prefix,
is_tool_name_prefixed,
@ -61,6 +62,45 @@ from litellm.types.mcp_server.mcp_server_manager import (
MCPOAuthMetadata,
MCPServer,
)
from mcp.shared.tool_name_validation import SEP_986_URL, validate_tool_name
# Probe includes characters on both sides of the separator to mimic real prefixed tool names.
_separator_probe_tool_name = f"litellm{MCP_TOOL_PREFIX_SEPARATOR}probe"
_separator_probe = validate_tool_name(_separator_probe_tool_name)
if not _separator_probe.is_valid:
verbose_logger.warning(
"MCP tool prefix separator '%s' violates SEP-986. See %s",
MCP_TOOL_PREFIX_SEPARATOR,
SEP_986_URL,
)
def _warn_on_server_name_fields(
*,
server_id: str,
alias: Optional[str],
server_name: Optional[str],
):
def _warn(field_name: str, value: Optional[str]) -> None:
if not value:
return
result = validate_tool_name(value)
if result.is_valid:
return
warning_text = "; ".join(result.warnings) if result.warnings else "Validation failed"
verbose_logger.warning(
"MCP server '%s' has invalid %s '%s': %s",
server_id,
field_name,
value,
warning_text,
)
_warn("alias", alias)
_warn("server_name", server_name)
def _deserialize_json_dict(data: Any) -> Optional[Dict[str, str]]:
@ -209,6 +249,12 @@ class MCPServerManager:
alias=alias,
)
_warn_on_server_name_fields(
server_id=server_id,
alias=alias,
server_name=server_name,
)
auth_type = server_config.get("auth_type", None)
if server_url and auth_type is not None and auth_type == MCPAuth.oauth2:
mcp_oauth_metadata = await self._descovery_metadata(
@ -2099,6 +2145,11 @@ class MCPServerManager:
new_registry[server.server_id] = existing_server
continue
_warn_on_server_name_fields(
server_id=server.server_id,
alias=getattr(server, "alias", None),
server_name=getattr(server, "server_name", None),
)
verbose_logger.debug(
f"Building server from DB: {server.server_id} ({server.server_name})"
)

View file

@ -786,7 +786,6 @@ if MCP_AVAILABLE:
add_prefix=add_prefix,
raw_headers=raw_headers,
)
filtered_tools = filter_tools_by_allowed_tools(tools, server)
filtered_tools = await filter_tools_by_key_team_permissions(

View file

@ -310,24 +310,37 @@ async def list_plugins(
where = {"enabled": True} if enabled_only else {}
plugins = await prisma_client.db.litellm_claudecodeplugintable.find_many(
where=where,
order_by={"created_at": "desc"},
where=where
)
return ListPluginsResponse(
plugins=[
plugin_list = []
for p in plugins:
# Parse manifest to get additional fields
manifest = json.loads(p.manifest_json) if p.manifest_json else {}
plugin_list.append(
PluginListItem(
id=p.id,
name=p.name,
version=p.version,
description=p.description,
source=manifest.get("source", {}),
author=manifest.get("author"),
homepage=manifest.get("homepage"),
keywords=manifest.get("keywords"),
category=manifest.get("category"),
enabled=p.enabled,
created_at=p.created_at.isoformat() if p.created_at else None,
updated_at=p.updated_at.isoformat() if p.updated_at else None,
)
for p in plugins
],
count=len(plugins),
)
# Sort by created_at descending (newest first)
plugin_list.sort(key=lambda x: x.created_at or "", reverse=True)
return ListPluginsResponse(
plugins=plugin_list,
count=len(plugin_list),
)
except HTTPException:

View file

@ -37,7 +37,7 @@ from litellm._uuid import uuid
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
from litellm.proxy._experimental.mcp_server.utils import (
get_server_prefix,
validate_and_normalize_mcp_server_payload,
validate_and_normalize_mcp_server_payload as _base_validate_and_normalize_mcp_server_payload,
)
router = APIRouter(prefix="/v1/mcp", tags=["mcp"])
@ -56,6 +56,7 @@ except ImportError as e:
MCP_AVAILABLE = False
if MCP_AVAILABLE:
from mcp.shared.tool_name_validation import validate_tool_name
from litellm.proxy._experimental.mcp_server.db import (
create_mcp_server,
delete_mcp_server,
@ -97,6 +98,43 @@ if MCP_AVAILABLE:
server: MCPServer
expires_at: datetime
def _validate_mcp_server_name_fields(payload: Any) -> None:
candidates: List[tuple[str, Optional[str]]] = []
server_name = getattr(payload, "server_name", None)
alias = getattr(payload, "alias", None)
if server_name:
candidates.append(("server_name", server_name))
if alias:
candidates.append(("alias", alias))
for field_name, value in candidates:
if not value:
continue
validation_result = validate_tool_name(value)
if validation_result.is_valid:
continue
error_messages_text = (
f"Invalid MCP tool prefix '{value}' provided via {field_name}"
)
if validation_result.warnings:
error_messages_text = (
error_messages_text
+ "\n"
+ "\n".join(validation_result.warnings)
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": error_messages_text},
)
def validate_and_normalize_mcp_server_payload(payload: Any) -> None:
_base_validate_and_normalize_mcp_server_payload(payload)
_validate_mcp_server_name_fields(payload)
def _is_public_registry_enabled() -> bool:
from litellm.proxy.proxy_server import (
general_settings as proxy_general_settings,

View file

@ -51,6 +51,7 @@ from litellm.proxy._types import (
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
from litellm.proxy.utils import get_server_root_path
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
@ -1973,10 +1974,26 @@ class InitPassThroughEndpointHelpers:
_registered_pass_through_routes.clear()
@staticmethod
def get_registered_pass_through_endpoints_keys() -> List[str]:
def get_all_registered_pass_through_routes() -> List[str]:
"""Get all registered pass-through endpoints from the registry"""
return list(_registered_pass_through_routes.keys())
@staticmethod
def _build_full_path_with_root(path: str) -> str:
"""
Build full path by prepending server root path if needed.
Args:
path: The relative path to build
Returns:
Full path with server root prepended (if root is not "/")
"""
root_path = get_server_root_path()
if root_path == "/":
return path
return f"{root_path}{path}"
@staticmethod
def is_registered_pass_through_route(route: str) -> bool:
"""
@ -2003,7 +2020,9 @@ class InitPassThroughEndpointHelpers:
parts = key.split(":", 2) # Split into [endpoint_id, type, path]
if len(parts) == 3:
route_type = parts[1]
registered_path = parts[2]
registered_path = InitPassThroughEndpointHelpers._build_full_path_with_root(
parts[2]
)
if route_type == "exact" and route == registered_path:
return True
elif route_type == "subpath":
@ -2021,7 +2040,9 @@ class InitPassThroughEndpointHelpers:
parts = key.split(":", 2) # Split into [endpoint_id, type, path]
if len(parts) == 3:
route_type = parts[1]
registered_path = parts[2]
registered_path = InitPassThroughEndpointHelpers._build_full_path_with_root(
parts[2]
)
if route_type == "exact" and route == registered_path:
return _registered_pass_through_routes[key]
@ -2085,7 +2106,7 @@ async def initialize_pass_through_endpoints(
# mark the ones that are visited in the list
# remove the ones that are not visited from the list
registered_pass_through_endpoints = (
InitPassThroughEndpointHelpers.get_registered_pass_through_endpoints_keys()
InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
)
visited_endpoints = set()

View file

@ -76,6 +76,11 @@ class PluginListItem(BaseModel):
name: str
version: Optional[str]
description: Optional[str]
source: Dict[str, str]
author: Optional[PluginAuthor] = None
homepage: Optional[str] = None
keywords: Optional[List[str]] = None
category: Optional[str] = None
enabled: bool
created_at: Optional[str]
updated_at: Optional[str]

View file

@ -26720,15 +26720,15 @@
"tool_use_system_prompt_tokens": 159
},
"us.anthropic.claude-opus-4-5-20251101-v1:0": {
"cache_creation_input_token_cost": 6.25e-06,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"cache_creation_input_token_cost": 6.875e-06,
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 5.5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"output_cost_per_token": 2.75e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,

22
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
[[package]]
name = "a2a-sdk"
@ -2204,8 +2204,6 @@ files = [
{file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"},
{file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"},
{file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"},
{file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"},
@ -2215,8 +2213,6 @@ files = [
{file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"},
{file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"},
{file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"},
{file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"},
@ -2226,8 +2222,6 @@ files = [
{file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"},
{file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"},
{file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"},
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"},
@ -2237,8 +2231,6 @@ files = [
{file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"},
{file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"},
{file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"},
@ -2246,8 +2238,6 @@ files = [
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"},
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"},
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"},
{file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"},
{file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"},
{file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"},
@ -2257,8 +2247,6 @@ files = [
{file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"},
{file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
{file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
{file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
@ -3370,15 +3358,15 @@ files = [
[[package]]
name = "mcp"
version = "1.22.0"
version = "1.25.0"
description = "Model Context Protocol SDK"
optional = true
python-versions = ">=3.10"
groups = ["main"]
markers = "python_version >= \"3.10\" and extra == \"proxy\""
files = [
{file = "mcp-1.22.0-py3-none-any.whl", hash = "sha256:bed758e24df1ed6846989c909ba4e3df339a27b4f30f1b8b627862a4bade4e98"},
{file = "mcp-1.22.0.tar.gz", hash = "sha256:769b9ac90ed42134375b19e777a2858ca300f95f2e800982b3e2be62dfc0ba01"},
{file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"},
{file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"},
]
[package.dependencies]
@ -8003,4 +7991,4 @@ utils = ["numpydoc"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.9,<4.0"
content-hash = "2d6b3d8d44919c29315b5e645befbf745a276714a2454c563d460a6a001b90af"
content-hash = "3a929b2e1dc2b85edcf78f93b0c15eda2bf0cdf8d3e0e30778fc63178c650e40"

View file

@ -58,7 +58,7 @@ pynacl = {version = "^1.5.0", optional = true}
websockets = {version = "^15.0.1", optional = true}
boto3 = {version = "1.36.0", optional = true}
redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"}
mcp = {version = "^1.21.2", optional = true, python = ">=3.10"}
mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"}
a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"}
litellm-proxy-extras = {version = "0.4.23", optional = true}
rich = {version = "13.7.1", optional = true}

View file

@ -3,8 +3,8 @@
LiteLLM Health Check Client
A sentinel health check tool that tests all configured models on a LiteLLM proxy.
Similar to HRT's health check system, this script:
- Can read models from YAML config file (like HRT) or fetch from proxy API
This script:
- Can read models from YAML config file or fetch from proxy API
- Sends a simple test request to each model concurrently
- Reports health status for each model
- Supports both chat/completion and embedding models

View file

@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
# Add the project root to the path
sys.path.insert(0, os.path.abspath("../../.."))
import litellm.experimental_mcp_client.client as mcp_client_module
from litellm.experimental_mcp_client.client import MCPClient
from litellm.types.mcp import MCPAuth, MCPTransport
from mcp.types import Tool as MCPTool, CallToolResult as MCPCallToolResult
@ -82,8 +83,8 @@ class TestMCPClientUnitTests:
assert headers == {}
@pytest.mark.asyncio
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
@patch("litellm.experimental_mcp_client.client.ClientSession")
@patch.object(mcp_client_module, "streamable_http_client")
@patch.object(mcp_client_module, "ClientSession")
async def test_run_with_session(self, mock_session_class, mock_transport):
"""Test run_with_session establishes session with auth headers."""
# Setup mocks
@ -110,16 +111,15 @@ class TestMCPClientUnitTests:
# Verify transport was created with auth headers
call_args = mock_transport.call_args
assert call_args[1]["headers"] == {
"Authorization": "Bearer test_token",
}
http_client = call_args[1]["http_client"]
assert http_client.headers.get("Authorization") == "Bearer test_token"
# Verify session was initialized
mock_session_instance.initialize.assert_called_once()
@pytest.mark.asyncio
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
@patch("litellm.experimental_mcp_client.client.ClientSession")
@patch.object(mcp_client_module, "streamable_http_client")
@patch.object(mcp_client_module, "ClientSession")
async def test_list_tools(self, mock_session_class, mock_transport):
"""Test listing tools from the server."""
# Setup mocks
@ -156,8 +156,8 @@ class TestMCPClientUnitTests:
mock_session_instance.list_tools.assert_called_once()
@pytest.mark.asyncio
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
@patch("litellm.experimental_mcp_client.client.ClientSession")
@patch.object(mcp_client_module, "streamable_http_client")
@patch.object(mcp_client_module, "ClientSession")
async def test_call_tool(self, mock_session_class, mock_transport):
"""Test calling a tool."""
from mcp.types import CallToolRequestParams

View file

@ -9,6 +9,7 @@ import pytest
# Add the parent directory to the path so we can import litellm
sys.path.insert(0, "../../../")
import litellm.experimental_mcp_client.client as mcp_client_module
from litellm.experimental_mcp_client.client import MCPClient
from litellm.types.mcp import MCPStdioConfig, MCPTransport
@ -81,7 +82,7 @@ class TestMCPClient:
assert call_args.env == {"DEBUG": "1"}
@pytest.mark.asyncio
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
@patch.object(mcp_client_module, "streamable_http_client")
@patch.dict(
os.environ,
{
@ -90,12 +91,12 @@ class TestMCPClient:
},
)
async def test_mcp_client_ssl_configuration_from_env(
self, mock_streamablehttp_client
self, mock_streamable_http_client
):
"""Test that MCP client uses SSL configuration from environment variables"""
# Setup mocks
mock_transport = (MagicMock(), MagicMock())
mock_streamablehttp_client.return_value.__aenter__ = AsyncMock(
mock_streamable_http_client.return_value.__aenter__ = AsyncMock(
return_value=mock_transport
)
@ -121,27 +122,23 @@ class TestMCPClient:
await client.run_with_session(_operation)
# Verify streamablehttp_client was called
mock_streamablehttp_client.assert_called_once()
call_kwargs = mock_streamablehttp_client.call_args[1]
mock_streamable_http_client.assert_called_once()
call_kwargs = mock_streamable_http_client.call_args[1]
assert "http_client" in call_kwargs
http_client = call_kwargs["http_client"]
assert isinstance(http_client, httpx.AsyncClient)
# Verify httpx_client_factory was passed
assert "httpx_client_factory" in call_kwargs
httpx_factory = call_kwargs["httpx_client_factory"]
# Test the factory creates a client with proper SSL config
# When SSL_CERT_FILE is set, the factory should use get_ssl_configuration
# Test the factory still creates a client with proper SSL config
httpx_factory = client._create_httpx_client_factory()
test_client = httpx_factory(headers={"test": "header"})
# Verify the client was created successfully with SSL configuration
assert test_client is not None
assert isinstance(test_client, httpx.AsyncClient)
# Verify it has the expected properties
assert test_client.headers is not None
# Clean up
await test_client.aclose()
@pytest.mark.asyncio
@patch("litellm.experimental_mcp_client.client.sse_client")
@patch.object(mcp_client_module, "sse_client")
async def test_mcp_client_ssl_verify_parameter(self, mock_sse_client):
"""Test that MCP client uses ssl_verify parameter when provided"""
# Setup mocks
@ -192,12 +189,12 @@ class TestMCPClient:
await test_client.aclose()
@pytest.mark.asyncio
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
async def test_mcp_client_ssl_verify_custom_path(self, mock_streamablehttp_client):
@patch.object(mcp_client_module, "streamable_http_client")
async def test_mcp_client_ssl_verify_custom_path(self, mock_streamable_http_client):
"""Test that MCP client uses custom CA bundle path from ssl_verify parameter"""
# Setup mocks
mock_transport = (MagicMock(), MagicMock())
mock_streamablehttp_client.return_value.__aenter__ = AsyncMock(
mock_streamable_http_client.return_value.__aenter__ = AsyncMock(
return_value=mock_transport
)
@ -226,23 +223,18 @@ class TestMCPClient:
await client.run_with_session(_operation)
# Verify streamablehttp_client was called
mock_streamablehttp_client.assert_called_once()
call_kwargs = mock_streamablehttp_client.call_args[1]
mock_streamable_http_client.assert_called_once()
call_kwargs = mock_streamable_http_client.call_args[1]
assert "http_client" in call_kwargs
http_client = call_kwargs["http_client"]
assert isinstance(http_client, httpx.AsyncClient)
# Verify httpx_client_factory was passed
assert "httpx_client_factory" in call_kwargs
httpx_factory = call_kwargs["httpx_client_factory"]
# Test the factory creates a client with custom CA bundle path
# When ssl_verify is a path, the factory should use that path for SSL verification
httpx_factory = client._create_httpx_client_factory()
test_client = httpx_factory(headers={"test": "header"})
# Verify the client was created successfully
assert test_client is not None
assert isinstance(test_client, httpx.AsyncClient)
# Verify it has the expected properties
assert test_client.headers is not None
# Clean up
await test_client.aclose()

View file

@ -1,3 +1,6 @@
import importlib
import logging
import os
import sys
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
@ -29,6 +32,15 @@ from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata, MCPServer
def _reload_mcp_manager_module():
utils_module = sys.modules["litellm.proxy._experimental.mcp_server.utils"]
manager_module = sys.modules[
"litellm.proxy._experimental.mcp_server.mcp_server_manager"
]
importlib.reload(utils_module)
return importlib.reload(manager_module)
class TestMCPServerManager:
"""Test MCP Server Manager stdio functionality"""
@ -148,6 +160,90 @@ class TestMCPServerManager:
# When the header isn't provided, the key is omitted entirely
assert env == {}
@pytest.mark.asyncio
async def test_load_servers_from_config_warns_on_invalid_alias(self, caplog):
"""Invalid aliases from config should emit warnings during load."""
manager = MCPServerManager()
config = {
"validserver": {
"alias": "bad/name",
"url": "https://example.com",
"transport": MCPTransport.http,
}
}
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
await manager.load_servers_from_config(config)
assert any(
"invalid alias 'bad/name'" in message for message in caplog.messages
)
@pytest.mark.asyncio
async def test_load_servers_from_config_accepts_valid_alias(self, caplog):
"""Valid aliases should be accepted and populate the registry."""
manager = MCPServerManager()
config = {
"validserver": {
"alias": "friendly_alias",
"url": "https://example.com",
"transport": MCPTransport.http,
}
}
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
await manager.load_servers_from_config(config)
# No warnings logged for the valid alias
assert all("invalid alias" not in message for message in caplog.messages)
server = next(iter(manager.config_mcp_servers.values()))
assert server.alias == "friendly_alias"
assert server.server_name == "validserver"
def test_warns_when_custom_separator_invalid(self, monkeypatch, caplog):
"""Invalid MCP_TOOL_PREFIX_SEPARATOR values should log a warning."""
original_value = os.environ.get("MCP_TOOL_PREFIX_SEPARATOR")
monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", "/")
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
_reload_mcp_manager_module()
assert any("violates SEP-986" in message for message in caplog.messages)
# Restore original setting and ensure warning disappears
if original_value is None:
monkeypatch.delenv("MCP_TOOL_PREFIX_SEPARATOR", raising=False)
else:
monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", original_value)
caplog.clear()
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
_reload_mcp_manager_module()
assert all("violates SEP-986" not in message for message in caplog.messages)
def test_accepts_valid_custom_separator(self, monkeypatch, caplog):
"""Valid separators should not emit warnings during module import."""
original_value = os.environ.get("MCP_TOOL_PREFIX_SEPARATOR")
monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", "_")
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
_reload_mcp_manager_module()
assert all("violates SEP-986" not in message for message in caplog.messages)
if original_value is None:
monkeypatch.delenv("MCP_TOOL_PREFIX_SEPARATOR", raising=False)
else:
monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", original_value)
_reload_mcp_manager_module()
@pytest.mark.asyncio
async def test_list_tools_with_server_specific_auth_headers(self):
"""Test list_tools method with server-specific auth headers"""

View file

@ -2,14 +2,18 @@ import json
import os
import sys
import types
from types import SimpleNamespace
from datetime import datetime, timedelta
from typing import List, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from litellm._uuid import uuid
from litellm.proxy.management_endpoints import (
mcp_management_endpoints as mgmt_endpoints,
)
sys.path.insert(
0, os.path.abspath("../../../..")
@ -726,8 +730,6 @@ class TestTemporaryMCPSessionEndpoints:
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_cached_temporary_mcp_server",
return_value=None,
):
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
_get_cached_temporary_mcp_server_or_404("missing")
@ -1195,6 +1197,25 @@ class TestMCPRegistryEndpoint:
assert result[0]["server_id"] == "server-1"
assert result[0]["status"] == "healthy"
class TestManagementPayloadValidation:
def test_rejects_invalid_alias(self):
payload = SimpleNamespace(server_name="valid_server", alias="bad/name")
with pytest.raises(HTTPException) as exc_info:
mgmt_endpoints.validate_and_normalize_mcp_server_payload(payload)
assert exc_info.value.status_code == 400
error_message = exc_info.value.detail["error"]
assert "bad/name" in error_message
def test_accepts_valid_names(self):
payload = SimpleNamespace(server_name="valid_server", alias=None)
mgmt_endpoints.validate_and_normalize_mcp_server_payload(payload)
assert payload.alias == "valid_server"
@pytest.mark.asyncio
async def test_health_check_view_all_mode(self):
"""view_all mode should return health info for all MCP servers."""

View file

@ -1897,8 +1897,8 @@ async def test_add_litellm_data_to_request_adds_headers_to_metadata():
The fix ensures headers are available in data["metadata"]["headers"] so
guardrails can validate User-Agent, API keys, and other header-based checks.
"""
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
# Create mock request with headers including User-Agent
mock_request = MagicMock(spec=Request)
@ -1954,3 +1954,150 @@ async def test_add_litellm_data_to_request_adds_headers_to_metadata():
# Also verify proxy_server_request has headers (original location)
assert "proxy_server_request" in result
assert "headers" in result["proxy_server_request"]
def test_build_full_path_with_root_default():
"""
Test _build_full_path_with_root with default root path (/)
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
)
with patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path") as mock_get_root:
# Test with default root path
mock_get_root.return_value = "/"
result = InitPassThroughEndpointHelpers._build_full_path_with_root("/api/v1/endpoint")
assert result == "/api/v1/endpoint"
def test_build_full_path_with_root_custom():
"""
Test _build_full_path_with_root with custom root path
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
)
with patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path") as mock_get_root:
# Test with custom root path /proxy
mock_get_root.return_value = "/proxy"
result = InitPassThroughEndpointHelpers._build_full_path_with_root("/api/v1/endpoint")
assert result == "/proxy/api/v1/endpoint"
def test_build_full_path_with_root_nested():
"""
Test _build_full_path_with_root with nested root path
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
)
with patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path") as mock_get_root:
# Test with nested root path /api/v2
mock_get_root.return_value = "/api/v2"
result = InitPassThroughEndpointHelpers._build_full_path_with_root("/endpoint")
assert result == "/api/v2/endpoint"
def test_is_registered_pass_through_route_with_custom_root():
"""
Test is_registered_pass_through_route correctly handles server root path
When server has a custom root path like /proxy, the registered path
should be constructed by prepending the root to match incoming routes.
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
_registered_pass_through_routes,
)
# Clear the registry first
_registered_pass_through_routes.clear()
# Register a pass-through route with endpoint format: {endpoint_id}:exact:{path}
endpoint_id = "test-endpoint-123"
path = "/api/endpoint"
route_key = f"{endpoint_id}:exact:{path}"
_registered_pass_through_routes[route_key] = {
"target": "http://example.com",
"headers": {},
}
with patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path") as mock_get_root:
# Test with custom root path /proxy
mock_get_root.return_value = "/proxy"
# Should match when request route includes the root path
assert InitPassThroughEndpointHelpers.is_registered_pass_through_route("/proxy/api/endpoint") is True
# Should not match when request route doesn't include root path
assert InitPassThroughEndpointHelpers.is_registered_pass_through_route("/api/endpoint") is False
# Test with default root path
mock_get_root.return_value = "/"
# Should match with default root
assert InitPassThroughEndpointHelpers.is_registered_pass_through_route("/api/endpoint") is True
# Should not match with root prepended when root is /
assert InitPassThroughEndpointHelpers.is_registered_pass_through_route("/proxy/api/endpoint") is False
# Clean up
_registered_pass_through_routes.clear()
def test_get_registered_pass_through_route_with_custom_root():
"""
Test get_registered_pass_through_route correctly handles server root path
When server has a custom root path, the method should return the correct
endpoint configuration by matching the full path including the root.
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
_registered_pass_through_routes,
)
# Clear the registry first
_registered_pass_through_routes.clear()
# Register a pass-through route
endpoint_id = "test-endpoint-456"
path = "/chat/completions"
target_config = {
"target": "http://api.example.com/v1/chat/completions",
"headers": {"Authorization": "Bearer token123"},
"forward_headers": True,
}
route_key = f"{endpoint_id}:exact:{path}"
_registered_pass_through_routes[route_key] = target_config
with patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path") as mock_get_root:
# Test with custom root path /litellm
mock_get_root.return_value = "/litellm"
# Should return config when request route includes root path
result = InitPassThroughEndpointHelpers.get_registered_pass_through_route("/litellm/chat/completions")
assert result is not None
assert result["target"] == "http://api.example.com/v1/chat/completions"
assert result["headers"]["Authorization"] == "Bearer token123"
# Should return None when route doesn't match
result = InitPassThroughEndpointHelpers.get_registered_pass_through_route("/chat/completions")
assert result is None
# Test with default root path
mock_get_root.return_value = "/"
# Should return config with default root
result = InitPassThroughEndpointHelpers.get_registered_pass_through_route("/chat/completions")
assert result is not None
assert result["target"] == "http://api.example.com/v1/chat/completions"
# Clean up
_registered_pass_through_routes.clear()

View file

@ -120,6 +120,8 @@ const routeFor = (slug: string): string => {
return "experimental/api-playground";
case "tag-management":
return "experimental/tag-management";
case "claude-code-plugins":
return "experimental/claude-code-plugins";
case "usage": // "Old Usage"
return "experimental/old-usage";
@ -257,6 +259,13 @@ const menuItems: MenuItemCfg[] = [
icon: <TagsOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{
key: "27",
page: "claude-code-plugins",
label: "Claude Code Plugins",
icon: <ToolOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{ key: "4", page: "usage", label: "Old Usage", icon: <BarChartOutlined style={{ fontSize: 18 }} /> },
],
},

View file

@ -0,0 +1,17 @@
"use client";
import ClaudeCodePluginsPanel from "@/components/claude_code_plugins";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const ClaudeCodePluginsPage = () => {
const { accessToken, userRole } = useAuthorized();
return (
<ClaudeCodePluginsPanel
accessToken={accessToken}
userRole={userRole}
/>
);
};
export default ClaudeCodePluginsPage;

View file

@ -8,6 +8,7 @@ import AdminPanel from "@/components/admins";
import AgentsPanel from "@/components/agents";
import BudgetPanel from "@/components/budgets/budget_panel";
import CacheDashboard from "@/components/cache_dashboard";
import ClaudeCodePluginsPanel from "@/components/claude_code_plugins";
import { fetchTeams } from "@/components/common_components/fetch_teams";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { CostTrackingSettings } from "@/components/CostTrackingSettings";
@ -530,6 +531,8 @@ export default function CreateKeyPage() {
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tag-management" ? (
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "vector-stores" ? (
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "new_usage" ? (

View file

@ -0,0 +1,162 @@
import React, { useState, useEffect, useMemo } from "react";
import { Input } from "antd";
import { Card, TabGroup, TabList, Tab, TabPanels, TabPanel, Text } from "@tremor/react";
import { SearchOutlined } from "@ant-design/icons";
import { getClaudeCodeMarketplace } from "../networking";
import { ModelDataTable } from "../model_dashboard/table";
import { getMarketplaceTableColumns } from "./marketplace_table_columns";
import NotificationsManager from "../molecules/notifications_manager";
import {
MarketplaceResponse,
MarketplacePluginEntry,
} from "../claude_code_plugins/types";
import {
extractCategories,
filterPluginsBySearch,
filterPluginsByCategory,
} from "../claude_code_plugins/helpers";
interface ClaudeCodeMarketplaceTabProps {
publicPage?: boolean;
}
const ClaudeCodeMarketplaceTab: React.FC<ClaudeCodeMarketplaceTabProps> = ({
publicPage = false,
}) => {
const [marketplaceData, setMarketplaceData] =
useState<MarketplaceResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [selectedCategoryIndex, setSelectedCategoryIndex] = useState(0);
useEffect(() => {
fetchMarketplace();
}, []);
const fetchMarketplace = async () => {
setIsLoading(true);
try {
const data: MarketplaceResponse = await getClaudeCodeMarketplace();
console.log("Claude Code marketplace:", data);
setMarketplaceData(data);
} catch (error) {
console.error("Error fetching marketplace:", error);
} finally {
setIsLoading(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
NotificationsManager.success("Copied to clipboard!");
};
// Extract unique categories from plugins
const categories = useMemo(() => {
if (!marketplaceData) return ["All"];
return extractCategories(marketplaceData.plugins);
}, [marketplaceData]);
// Get selected category name
const selectedCategory = categories[selectedCategoryIndex] || "All";
// Filter plugins by search and category
const filteredPlugins = useMemo(() => {
if (!marketplaceData) return [];
let plugins = marketplaceData.plugins;
// Apply category filter
plugins = filterPluginsByCategory(plugins, selectedCategory);
// Apply search filter
plugins = filterPluginsBySearch(plugins, searchTerm);
return plugins;
}, [marketplaceData, selectedCategory, searchTerm]);
const columns = useMemo(
() => getMarketplaceTableColumns(copyToClipboard, publicPage),
[publicPage]
);
if (!marketplaceData && !isLoading) {
return (
<Card>
<div className="text-center p-12">
<Text className="text-gray-500">
Failed to load marketplace. Please try again later.
</Text>
</div>
</Card>
);
}
return (
<div className="space-y-4">
{/* Search Bar */}
<div className="max-w-md">
<Input
placeholder="Search plugins by name, description, or keywords..."
prefix={<SearchOutlined className="text-gray-400" />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
allowClear
size="large"
/>
</div>
{/* Category Tabs */}
<TabGroup index={selectedCategoryIndex} onIndexChange={setSelectedCategoryIndex}>
<TabList className="mb-4">
{categories.map((category) => {
// Count plugins in this category
const categoryPlugins = filterPluginsByCategory(
marketplaceData?.plugins || [],
category
);
const count = filterPluginsBySearch(
categoryPlugins,
searchTerm
).length;
return (
<Tab key={category}>
{category} {count > 0 && `(${count})`}
</Tab>
);
})}
</TabList>
<TabPanels>
{categories.map((category) => (
<TabPanel key={category}>
<Card>
{/* Plugin Table */}
<ModelDataTable
columns={columns}
data={filteredPlugins}
isLoading={isLoading}
defaultSorting={[{ id: "name", desc: false }]}
/>
</Card>
{/* Footer Info */}
<div className="mt-4 text-center space-y-2">
<Text className="text-sm text-gray-600">
Showing {filteredPlugins.length} of{" "}
{marketplaceData?.plugins.length || 0} plugin
{marketplaceData?.plugins.length !== 1 ? "s" : ""}
{searchTerm && ` matching "${searchTerm}"`}
{selectedCategory !== "All" && ` in ${selectedCategory}`}
</Text>
</div>
</TabPanel>
))}
</TabPanels>
</TabGroup>
</div>
);
};
export default ClaudeCodeMarketplaceTab;

View file

@ -5,6 +5,7 @@ import MakeModelPublicForm from "@/components/AIHub/forms/MakeModelPublicForm";
import { mcpHubColumns, MCPServerData } from "@/components/mcp_hub_table_columns";
import { modelHubColumns } from "@/components/model_hub_table_columns";
import UsefulLinksManagement from "@/components/AIHub/UsefulLinksManagement";
import ClaudeCodeMarketplaceTab from "@/components/AIHub/ClaudeCodeMarketplaceTab";
import { ModelDataTable } from "@/components/model_dashboard/table";
import ModelFilters from "@/components/model_filters";
import NotificationsManager from "@/components/molecules/notifications_manager";
@ -372,12 +373,13 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
</div>
)}
{/* Tab System for Model Hub, Agent Hub, and MCP Hub */}
{/* Tab System for Model Hub, Agent Hub, MCP Hub, and Plugin Marketplace */}
<TabGroup>
<TabList className="mb-4">
<Tab>Model Hub</Tab>
<Tab>Agent Hub</Tab>
<Tab>MCP Hub</Tab>
<Tab>Claude Code Plugin Marketplace</Tab>
</TabList>
<TabPanels>
@ -462,6 +464,11 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
</Text>
</div>
</TabPanel>
{/* Plugin Marketplace Tab */}
<TabPanel>
<ClaudeCodeMarketplaceTab publicPage={publicPage} />
</TabPanel>
</TabPanels>
</TabGroup>
</div>

View file

@ -0,0 +1,155 @@
import React from "react";
import { Card, Badge, Button, Text } from "@tremor/react";
import { Tooltip } from "antd";
import { CopyOutlined, ExternalLinkIcon } from "@heroicons/react/outline";
import { MarketplacePluginEntry } from "@/components/claude_code_plugins/types";
import {
formatInstallCommand,
getCategoryBadgeColor,
getSourceLink,
truncateText,
} from "@/components/claude_code_plugins/helpers";
import NotificationsManager from "@/components/molecules/notifications_manager";
interface PluginCardProps {
plugin: MarketplacePluginEntry;
}
const PluginCard: React.FC<PluginCardProps> = ({ plugin }) => {
const installCommand = formatInstallCommand(plugin);
const sourceLink = getSourceLink(plugin.source);
const categoryBadgeColor = getCategoryBadgeColor(plugin.category);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
NotificationsManager.success("Install command copied!");
};
// Limit keywords display to first 5
const displayKeywords = plugin.keywords?.slice(0, 5) || [];
const remainingKeywords = (plugin.keywords?.length || 0) - 5;
return (
<Card
className="hover:shadow-lg transition-shadow duration-200 h-full flex flex-col"
decoration="top"
decorationColor={categoryBadgeColor}
>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-semibold text-gray-900">
{plugin.name}
</h3>
{plugin.version && (
<Badge color="blue" size="xs">
v{plugin.version}
</Badge>
)}
{plugin.category && (
<Badge color={categoryBadgeColor} size="xs">
{plugin.category}
</Badge>
)}
</div>
</div>
{sourceLink && (
<Tooltip title="View source repository">
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-blue-500"
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="h-5 w-5" />
</a>
</Tooltip>
)}
</div>
{/* Description */}
<div className="mb-4 flex-1">
{plugin.description ? (
<Text className="text-sm text-gray-600 line-clamp-3">
{plugin.description}
</Text>
) : (
<Text className="text-sm text-gray-400 italic">
No description available
</Text>
)}
</div>
{/* Keywords */}
{displayKeywords.length > 0 && (
<div className="flex flex-wrap gap-1 mb-4">
{displayKeywords.map((keyword, index) => (
<Badge key={index} color="gray" size="xs" className="text-xs">
{keyword}
</Badge>
))}
{remainingKeywords > 0 && (
<Badge color="gray" size="xs" className="text-xs">
+{remainingKeywords} more
</Badge>
)}
</div>
)}
{/* Author */}
{plugin.author && (
<div className="mb-4">
<Text className="text-xs text-gray-500">
By {plugin.author.name}
{plugin.author.email && ` (${plugin.author.email})`}
</Text>
</div>
)}
{/* Homepage Link */}
{plugin.homepage && (
<div className="mb-4">
<a
href={plugin.homepage}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-500 hover:text-blue-700 flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
Visit homepage
<ExternalLinkIcon className="h-3 w-3" />
</a>
</div>
)}
{/* Install Command */}
<div className="mt-auto pt-4 border-t border-gray-100">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 overflow-hidden">
<Text className="text-xs text-gray-500 mb-1">Install command</Text>
<Tooltip title={installCommand}>
<code className="block text-xs bg-gray-50 px-2 py-1 rounded font-mono text-gray-700 truncate">
{installCommand}
</code>
</Tooltip>
</div>
<Tooltip title="Copy install command">
<Button
size="xs"
variant="secondary"
icon={CopyOutlined}
onClick={(e) => {
e.stopPropagation();
copyToClipboard(installCommand);
}}
/>
</Tooltip>
</div>
</div>
</Card>
);
};
export default PluginCard;

View file

@ -0,0 +1,178 @@
import { ColumnDef } from "@tanstack/react-table";
import { Button, Badge, Text } from "@tremor/react";
import { Tooltip } from "antd";
import { CopyOutlined } from "@ant-design/icons";
import { MarketplacePluginEntry } from "@/components/claude_code_plugins/types";
import {
formatInstallCommand,
getCategoryBadgeColor,
getSourceDisplayText,
} from "@/components/claude_code_plugins/helpers";
export const getMarketplaceTableColumns = (
copyToClipboard: (text: string) => void,
publicPage: boolean = false,
): ColumnDef<MarketplacePluginEntry>[] => {
const allColumns: ColumnDef<MarketplacePluginEntry>[] = [
{
header: "Plugin Name",
accessorKey: "name",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const plugin = row.original;
const installCommand = formatInstallCommand(plugin);
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Text className="font-medium text-sm">{plugin.name}</Text>
<Tooltip title="Copy install command">
<CopyOutlined
onClick={() => copyToClipboard(installCommand)}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
{/* Show description on mobile */}
<div className="md:hidden">
<Text className="text-xs text-gray-600">
{plugin.description || "No description"}
</Text>
</div>
</div>
);
},
},
{
header: "Description",
accessorKey: "description",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const plugin = row.original;
return (
<Text className="text-xs line-clamp-2">
{plugin.description || "-"}
</Text>
);
},
meta: {
className: "hidden md:table-cell",
},
},
{
header: "Version",
accessorKey: "version",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const plugin = row.original;
return plugin.version ? (
<Badge color="blue" size="sm">
v{plugin.version}
</Badge>
) : (
<Text className="text-xs text-gray-400">-</Text>
);
},
meta: {
className: "hidden lg:table-cell",
},
},
{
header: "Category",
accessorKey: "category",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const plugin = row.original;
const badgeColor = getCategoryBadgeColor(plugin.category);
return plugin.category ? (
<Badge color={badgeColor} size="sm">
{plugin.category}
</Badge>
) : (
<Badge color="gray" size="sm">
Uncategorized
</Badge>
);
},
meta: {
className: "hidden lg:table-cell",
},
},
{
header: "Source",
accessorKey: "source",
enableSorting: false,
cell: ({ row }) => {
const plugin = row.original;
const sourceText = getSourceDisplayText(plugin.source);
return <Text className="text-xs text-gray-600">{sourceText}</Text>;
},
meta: {
className: "hidden xl:table-cell",
},
},
{
header: "Keywords",
accessorKey: "keywords",
enableSorting: false,
cell: ({ row }) => {
const plugin = row.original;
const keywords = plugin.keywords?.slice(0, 3) || [];
const remaining = (plugin.keywords?.length || 0) - 3;
return (
<div className="flex flex-wrap gap-1">
{keywords.map((keyword, index) => (
<Badge key={index} color="gray" size="xs">
{keyword}
</Badge>
))}
{remaining > 0 && (
<Badge color="gray" size="xs">
+{remaining}
</Badge>
)}
</div>
);
},
meta: {
className: "hidden xl:table-cell",
},
},
{
header: "Install Command",
id: "install_command",
enableSorting: false,
cell: ({ row }) => {
const plugin = row.original;
const installCommand = formatInstallCommand(plugin);
return (
<div className="flex items-center space-x-2">
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono truncate max-w-[200px]">
{installCommand}
</code>
<Tooltip title="Copy command">
<Button
size="xs"
variant="secondary"
icon={CopyOutlined}
onClick={() => copyToClipboard(installCommand)}
/>
</Tooltip>
</div>
);
},
},
];
return allColumns;
};

View file

@ -0,0 +1,169 @@
import React, { useState, useEffect } from "react";
import { Button } from "@tremor/react";
import { Modal } from "antd";
import {
getClaudeCodePluginsList,
deleteClaudeCodePlugin,
} from "./networking";
import AddPluginForm from "./claude_code_plugins/add_plugin_form";
import PluginTable from "./claude_code_plugins/plugin_table";
import { isAdminRole } from "@/utils/roles";
import PluginInfoView from "./claude_code_plugins/plugin_info";
import NotificationsManager from "./molecules/notifications_manager";
import { Plugin, ListPluginsResponse } from "./claude_code_plugins/types";
interface ClaudeCodePluginsPanelProps {
accessToken: string | null;
userRole?: string;
}
const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({
accessToken,
userRole,
}) => {
const [pluginsList, setPluginsList] = useState<Plugin[]>([]);
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [pluginToDelete, setPluginToDelete] = useState<{
name: string;
displayName: string;
} | null>(null);
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(
null
);
const isAdmin = userRole ? isAdminRole(userRole) : false;
const fetchPlugins = async () => {
if (!accessToken) {
return;
}
setIsLoading(true);
try {
const response: ListPluginsResponse = await getClaudeCodePluginsList(
accessToken,
false // Get all plugins (enabled and disabled)
);
console.log(`Claude Code plugins: ${JSON.stringify(response)}`);
setPluginsList(response.plugins);
} catch (error) {
console.error("Error fetching Claude Code plugins:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchPlugins();
}, [accessToken]);
const handleAddPlugin = () => {
if (selectedPluginId) {
setSelectedPluginId(null);
}
setIsAddModalVisible(true);
};
const handleCloseModal = () => {
setIsAddModalVisible(false);
};
const handleSuccess = () => {
fetchPlugins();
};
const handleDeleteClick = (pluginName: string, displayName: string) => {
setPluginToDelete({ name: pluginName, displayName });
};
const handleDeleteConfirm = async () => {
if (!pluginToDelete || !accessToken) return;
setIsDeleting(true);
try {
await deleteClaudeCodePlugin(accessToken, pluginToDelete.name);
NotificationsManager.success(
`Plugin "${pluginToDelete.displayName}" deleted successfully`
);
fetchPlugins();
} catch (error) {
console.error("Error deleting plugin:", error);
NotificationsManager.error("Failed to delete plugin");
} finally {
setIsDeleting(false);
setPluginToDelete(null);
}
};
const handleDeleteCancel = () => {
setPluginToDelete(null);
};
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<div className="flex flex-col gap-2 mb-4">
<h1 className="text-2xl font-bold">Claude Code Plugins</h1>
<p className="text-sm text-gray-600">
Manage Claude Code marketplace plugins. Add, enable, disable, or
delete plugins that will be available in your marketplace catalog.
Enabled plugins will appear in the public marketplace at{" "}
<code className="bg-gray-100 px-1 rounded">/claude-code/marketplace.json</code>.
</p>
<div className="mt-2">
<Button onClick={handleAddPlugin} disabled={!accessToken || !isAdmin}>
+ Add New Plugin
</Button>
</div>
</div>
{selectedPluginId ? (
<PluginInfoView
pluginId={selectedPluginId}
onClose={() => setSelectedPluginId(null)}
accessToken={accessToken}
isAdmin={isAdmin}
onPluginUpdated={fetchPlugins}
/>
) : (
<PluginTable
pluginsList={pluginsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
accessToken={accessToken}
onPluginUpdated={fetchPlugins}
isAdmin={isAdmin}
onPluginClick={(id) => setSelectedPluginId(id)}
/>
)}
<AddPluginForm
visible={isAddModalVisible}
onClose={handleCloseModal}
accessToken={accessToken}
onSuccess={handleSuccess}
/>
{pluginToDelete && (
<Modal
title="Delete Plugin"
open={pluginToDelete !== null}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
confirmLoading={isDeleting}
okText="Delete"
okButtonProps={{ danger: true }}
>
<p>
Are you sure you want to delete plugin:{" "}
<strong>{pluginToDelete.displayName}</strong>?
</p>
<p>This action cannot be undone.</p>
</Modal>
)}
</div>
);
};
export default ClaudeCodePluginsPanel;

View file

@ -0,0 +1,328 @@
import React, { useState } from "react";
import { Modal, Form, Input, Select, message } from "antd";
import { Button } from "@tremor/react";
import { registerClaudeCodePlugin } from "../networking";
import {
validatePluginName,
isValidSemanticVersion,
isValidEmail,
isValidUrl,
parseKeywords,
} from "./helpers";
const { TextArea } = Input;
const { Option } = Select;
interface AddPluginFormProps {
visible: boolean;
onClose: () => void;
accessToken: string | null;
onSuccess: () => void;
}
const PREDEFINED_CATEGORIES = [
"Development",
"Productivity",
"Learning",
"Security",
"Data & Analytics",
"Integration",
"Testing",
"Documentation",
];
const AddPluginForm: React.FC<AddPluginFormProps> = ({
visible,
onClose,
accessToken,
onSuccess,
}) => {
const [form] = Form.useForm();
const [isSubmitting, setIsSubmitting] = useState(false);
const [sourceType, setSourceType] = useState<"github" | "url">("github");
const handleSubmit = async (values: any) => {
if (!accessToken) {
message.error("No access token available");
return;
}
// Validate plugin name
if (!validatePluginName(values.name)) {
message.error(
"Plugin name must be kebab-case (lowercase letters, numbers, and hyphens only)"
);
return;
}
// Validate semantic version if provided
if (values.version && !isValidSemanticVersion(values.version)) {
message.error(
"Version must be in semantic versioning format (e.g., 1.0.0)"
);
return;
}
// Validate email if provided
if (values.authorEmail && !isValidEmail(values.authorEmail)) {
message.error("Invalid email format");
return;
}
// Validate homepage URL if provided
if (values.homepage && !isValidUrl(values.homepage)) {
message.error("Invalid homepage URL format");
return;
}
setIsSubmitting(true);
try {
// Build plugin data
const pluginData: any = {
name: values.name.trim(),
source:
sourceType === "github"
? {
source: "github",
repo: values.repo.trim(),
}
: {
source: "url",
url: values.url.trim(),
},
};
// Add optional fields
if (values.version) {
pluginData.version = values.version.trim();
}
if (values.description) {
pluginData.description = values.description.trim();
}
if (values.authorName || values.authorEmail) {
pluginData.author = {};
if (values.authorName) {
pluginData.author.name = values.authorName.trim();
}
if (values.authorEmail) {
pluginData.author.email = values.authorEmail.trim();
}
}
if (values.homepage) {
pluginData.homepage = values.homepage.trim();
}
if (values.category) {
pluginData.category = values.category;
}
if (values.keywords) {
pluginData.keywords = parseKeywords(values.keywords);
}
await registerClaudeCodePlugin(accessToken, pluginData);
message.success("Plugin registered successfully");
form.resetFields();
setSourceType("github");
onSuccess();
onClose();
} catch (error) {
console.error("Error registering plugin:", error);
message.error("Failed to register plugin");
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
form.resetFields();
setSourceType("github");
onClose();
};
const handleSourceTypeChange = (value: "github" | "url") => {
setSourceType(value);
// Clear repo/url fields when switching
form.setFieldsValue({ repo: undefined, url: undefined });
};
return (
<Modal
title="Add New Claude Code Plugin"
open={visible}
onCancel={handleCancel}
footer={null}
width={700}
className="top-8"
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
className="mt-4"
>
{/* Plugin Name */}
<Form.Item
label="Plugin Name"
name="name"
rules={[
{ required: true, message: "Please enter plugin name" },
{
pattern: /^[a-z0-9-]+$/,
message:
"Name must be kebab-case (lowercase, numbers, hyphens only)",
},
]}
tooltip="Unique identifier in kebab-case format (e.g., my-awesome-plugin)"
>
<Input placeholder="my-awesome-plugin" className="rounded-lg" />
</Form.Item>
{/* Source Type */}
<Form.Item
label="Source Type"
name="sourceType"
initialValue="github"
rules={[{ required: true, message: "Please select source type" }]}
>
<Select onChange={handleSourceTypeChange} className="rounded-lg">
<Option value="github">GitHub</Option>
<Option value="url">URL</Option>
</Select>
</Form.Item>
{/* GitHub Repository */}
{sourceType === "github" && (
<Form.Item
label="GitHub Repository"
name="repo"
rules={[
{ required: true, message: "Please enter repository" },
{
pattern: /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/,
message: "Repository must be in format: org/repo",
},
]}
tooltip="Format: organization/repository (e.g., anthropics/claude-code)"
>
<Input placeholder="anthropics/claude-code" className="rounded-lg" />
</Form.Item>
)}
{/* Git URL */}
{sourceType === "url" && (
<Form.Item
label="Git URL"
name="url"
rules={[{ required: true, message: "Please enter git URL" }]}
tooltip="Full git URL to the repository"
>
<Input
type="url"
placeholder="https://github.com/org/repo.git"
className="rounded-lg"
/>
</Form.Item>
)}
{/* Version */}
<Form.Item
label="Version (Optional)"
name="version"
tooltip="Semantic version (e.g., 1.0.0)"
>
<Input placeholder="1.0.0" className="rounded-lg" />
</Form.Item>
{/* Description */}
<Form.Item
label="Description (Optional)"
name="description"
tooltip="Brief description of what the plugin does"
>
<TextArea
rows={3}
placeholder="A plugin that helps with..."
maxLength={500}
className="rounded-lg"
/>
</Form.Item>
{/* Category */}
<Form.Item
label="Category (Optional)"
name="category"
tooltip="Select a category or enter a custom one"
>
<Select
placeholder="Select or type a category"
allowClear
showSearch
optionFilterProp="children"
className="rounded-lg"
>
{PREDEFINED_CATEGORIES.map((cat) => (
<Option key={cat} value={cat}>
{cat}
</Option>
))}
</Select>
</Form.Item>
{/* Keywords */}
<Form.Item
label="Keywords (Optional)"
name="keywords"
tooltip="Comma-separated list of keywords for search"
>
<Input placeholder="search, web, api" className="rounded-lg" />
</Form.Item>
{/* Author Name */}
<Form.Item
label="Author Name (Optional)"
name="authorName"
tooltip="Name of the plugin author or organization"
>
<Input placeholder="Your Name or Organization" className="rounded-lg" />
</Form.Item>
{/* Author Email */}
<Form.Item
label="Author Email (Optional)"
name="authorEmail"
rules={[{ type: "email", message: "Please enter a valid email" }]}
tooltip="Contact email for the plugin author"
>
<Input type="email" placeholder="author@example.com" className="rounded-lg" />
</Form.Item>
{/* Homepage */}
<Form.Item
label="Homepage (Optional)"
name="homepage"
rules={[{ type: "url", message: "Please enter a valid URL" }]}
tooltip="URL to the plugin's homepage or documentation"
>
<Input type="url" placeholder="https://example.com" className="rounded-lg" />
</Form.Item>
{/* Submit Buttons */}
<Form.Item className="mb-0 mt-6">
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? "Registering..." : "Register Plugin"}
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default AddPluginForm;

View file

@ -0,0 +1,264 @@
/**
* Helper utilities for Claude Code Marketplace
*/
import { PluginSource, MarketplacePluginEntry } from "./types";
/**
* Generate install command for Claude Code CLI
* Format: /plugin marketplace add org/repo OR /plugin marketplace add url
*/
export const formatInstallCommand = (plugin: {
name: string;
source: PluginSource;
}): string => {
if (plugin.source.source === "github" && plugin.source.repo) {
return `/plugin marketplace add ${plugin.source.repo}`;
} else if (plugin.source.source === "url" && plugin.source.url) {
return `/plugin marketplace add ${plugin.source.url}`;
}
// Fallback to plugin name
return `/plugin marketplace add ${plugin.name}`;
};
/**
* Extract unique categories from plugins list
* Returns array with "All" first, then sorted categories, then "Other"
*/
export const extractCategories = (
plugins: Array<{ category?: string }>
): string[] => {
const categories = new Set<string>();
plugins.forEach((p) => {
if (p.category && p.category.trim() !== "") {
categories.add(p.category);
}
});
const sortedCategories = Array.from(categories).sort();
// Return: All, sorted categories, Other
return ["All", ...sortedCategories, "Other"];
};
/**
* Validate plugin name format (kebab-case)
* Must be lowercase letters, numbers, and hyphens only
*/
export const validatePluginName = (name: string): boolean => {
if (!name || name.trim() === "") {
return false;
}
// Regex: lowercase letters, numbers, hyphens
return /^[a-z0-9-]+$/.test(name);
};
/**
* Get human-readable source display text
*/
export const getSourceDisplayText = (source: PluginSource): string => {
if (source.source === "github" && source.repo) {
return `GitHub: ${source.repo}`;
} else if (source.source === "url" && source.url) {
return source.url;
}
return "Unknown source";
};
/**
* Get clickable link for plugin source
*/
export const getSourceLink = (source: PluginSource): string | null => {
if (source.source === "github" && source.repo) {
return `https://github.com/${source.repo}`;
} else if (source.source === "url" && source.url) {
return source.url;
}
return null;
};
/**
* Get badge color based on category
*/
export const getCategoryBadgeColor = (
category?: string
): "blue" | "green" | "purple" | "red" | "orange" | "yellow" | "gray" => {
if (!category) {
return "gray";
}
const categoryLower = category.toLowerCase();
if (categoryLower.includes("development") || categoryLower.includes("dev")) {
return "blue";
} else if (
categoryLower.includes("productivity") ||
categoryLower.includes("workflow")
) {
return "green";
} else if (
categoryLower.includes("learning") ||
categoryLower.includes("education")
) {
return "purple";
} else if (
categoryLower.includes("security") ||
categoryLower.includes("safety")
) {
return "red";
} else if (
categoryLower.includes("data") ||
categoryLower.includes("analytics")
) {
return "orange";
} else if (
categoryLower.includes("integration") ||
categoryLower.includes("api")
) {
return "yellow";
}
return "gray";
};
/**
* Format date to readable string
*/
export const formatDateString = (dateString?: string): string => {
if (!dateString) {
return "N/A";
}
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
} catch (error) {
return "Invalid date";
}
};
/**
* Truncate text with ellipsis
*/
export const truncateText = (text: string, maxLength: number): string => {
if (!text || text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength) + "...";
};
/**
* Filter plugins by search term
* Searches in: name, description, keywords
*/
export const filterPluginsBySearch = (
plugins: MarketplacePluginEntry[],
searchTerm: string
): MarketplacePluginEntry[] => {
if (!searchTerm || searchTerm.trim() === "") {
return plugins;
}
const term = searchTerm.toLowerCase().trim();
return plugins.filter((plugin) => {
const nameMatch = plugin.name.toLowerCase().includes(term);
const descriptionMatch =
plugin.description?.toLowerCase().includes(term) || false;
const keywordsMatch =
plugin.keywords?.some((keyword) =>
keyword.toLowerCase().includes(term)
) || false;
return nameMatch || descriptionMatch || keywordsMatch;
});
};
/**
* Filter plugins by category
*/
export const filterPluginsByCategory = (
plugins: MarketplacePluginEntry[],
category: string
): MarketplacePluginEntry[] => {
if (category === "All") {
return plugins;
}
if (category === "Other") {
return plugins.filter((p) => !p.category || p.category.trim() === "");
}
return plugins.filter((p) => p.category === category);
};
/**
* Validate semantic version format (basic check)
*/
export const isValidSemanticVersion = (version?: string): boolean => {
if (!version) {
return true; // Version is optional
}
// Basic semver check: X.Y.Z
const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
return semverRegex.test(version);
};
/**
* Validate email format
*/
export const isValidEmail = (email?: string): boolean => {
if (!email) {
return true; // Email is optional
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
/**
* Validate URL format
*/
export const isValidUrl = (url?: string): boolean => {
if (!url) {
return true; // URL is optional
}
try {
new URL(url);
return true;
} catch {
return false;
}
};
/**
* Parse keywords from comma-separated string
*/
export const parseKeywords = (keywordsString: string): string[] => {
if (!keywordsString || keywordsString.trim() === "") {
return [];
}
return keywordsString
.split(",")
.map((kw) => kw.trim())
.filter((kw) => kw !== "");
};
/**
* Format keywords array to comma-separated string
*/
export const formatKeywords = (keywords?: string[]): string => {
if (!keywords || keywords.length === 0) {
return "";
}
return keywords.join(", ");
};

View file

@ -0,0 +1,350 @@
import React, { useState, useEffect } from "react";
import {
Card,
Title,
Text,
Button,
Badge,
Grid,
} from "@tremor/react";
import { Spin, Switch, Tooltip, Descriptions } from "antd";
import { ArrowLeftIcon, ExternalLinkIcon } from "@heroicons/react/outline";
import { CopyOutlined } from "@ant-design/icons";
import {
getClaudeCodePluginDetails,
enableClaudeCodePlugin,
disableClaudeCodePlugin,
} from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { Plugin } from "./types";
import {
formatInstallCommand,
getSourceDisplayText,
getSourceLink,
getCategoryBadgeColor,
formatDateString,
formatKeywords,
} from "./helpers";
interface PluginInfoViewProps {
pluginId: string;
onClose: () => void;
accessToken: string | null;
isAdmin: boolean;
onPluginUpdated: () => void;
}
const PluginInfoView: React.FC<PluginInfoViewProps> = ({
pluginId,
onClose,
accessToken,
isAdmin,
onPluginUpdated,
}) => {
const [plugin, setPlugin] = useState<Plugin | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isToggling, setIsToggling] = useState(false);
useEffect(() => {
fetchPluginInfo();
}, [pluginId, accessToken]);
const fetchPluginInfo = async () => {
if (!accessToken) return;
setIsLoading(true);
try {
// The backend expects plugin name, not ID
// We'll need to find the plugin by ID from the list
// For now, assume pluginId is actually the plugin name
const data = await getClaudeCodePluginDetails(
accessToken,
pluginId as string
);
setPlugin(data.plugin);
} catch (error) {
console.error("Error fetching plugin info:", error);
NotificationsManager.error("Failed to load plugin information");
} finally {
setIsLoading(false);
}
};
const handleToggleEnabled = async () => {
if (!accessToken || !plugin) return;
setIsToggling(true);
try {
if (plugin.enabled) {
await disableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" disabled`);
} else {
await enableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" enabled`);
}
onPluginUpdated();
fetchPluginInfo();
} catch (error) {
NotificationsManager.error("Failed to toggle plugin status");
} finally {
setIsToggling(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
NotificationsManager.success("Copied to clipboard!");
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<Spin size="large" />
</div>
);
}
if (!plugin) {
return (
<div className="p-8 text-center text-gray-500">
<p>Plugin not found</p>
<Button className="mt-4" onClick={onClose}>
Go Back
</Button>
</div>
);
}
const installCommand = formatInstallCommand(plugin);
const sourceLink = getSourceLink(plugin.source);
const categoryBadgeColor = getCategoryBadgeColor(plugin.category);
return (
<div className="space-y-4">
{/* Header with Back Button */}
<div className="flex items-center gap-3 mb-6">
<ArrowLeftIcon
className="h-5 w-5 cursor-pointer text-gray-500 hover:text-gray-700"
onClick={onClose}
/>
<h2 className="text-2xl font-bold">{plugin.name}</h2>
{plugin.version && (
<Badge color="blue" size="xs">
v{plugin.version}
</Badge>
)}
{plugin.category && (
<Badge color={categoryBadgeColor} size="xs">
{plugin.category}
</Badge>
)}
<Badge color={plugin.enabled ? "green" : "gray"} size="xs">
{plugin.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
{/* Install Command */}
<Card>
<div className="flex items-center justify-between">
<div className="flex-1">
<Text className="text-gray-600 text-xs mb-2">Install Command</Text>
<div className="font-mono bg-gray-100 px-3 py-2 rounded text-sm">
{installCommand}
</div>
</div>
<Tooltip title="Copy install command">
<Button
size="xs"
variant="secondary"
icon={CopyOutlined}
onClick={() => copyToClipboard(installCommand)}
className="ml-4"
>
Copy
</Button>
</Tooltip>
</div>
</Card>
{/* Plugin Details */}
<Card>
<Title>Plugin Details</Title>
<Grid numColsSm={2} numColsLg={3} className="gap-6 mt-4">
{/* Plugin ID */}
<div>
<Text className="text-gray-600 text-xs">Plugin ID</Text>
<div className="flex items-center gap-2 mt-1">
<Text className="font-mono text-xs">{plugin.id}</Text>
<CopyOutlined
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
onClick={() => copyToClipboard(plugin.id)}
/>
</div>
</div>
{/* Name */}
<div>
<Text className="text-gray-600 text-xs">Name</Text>
<Text className="font-semibold mt-1">{plugin.name}</Text>
</div>
{/* Version */}
<div>
<Text className="text-gray-600 text-xs">Version</Text>
<Text className="font-semibold mt-1">
{plugin.version || "N/A"}
</Text>
</div>
{/* Source */}
<div className="col-span-2">
<Text className="text-gray-600 text-xs">Source</Text>
<div className="flex items-center gap-2 mt-1">
<Text className="font-semibold">
{getSourceDisplayText(plugin.source)}
</Text>
{sourceLink && (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
>
<ExternalLinkIcon className="h-4 w-4" />
</a>
)}
</div>
</div>
{/* Category */}
<div>
<Text className="text-gray-600 text-xs">Category</Text>
<div className="mt-1">
{plugin.category ? (
<Badge color={categoryBadgeColor} size="xs">
{plugin.category}
</Badge>
) : (
<Text className="text-gray-400">Uncategorized</Text>
)}
</div>
</div>
{/* Enabled Status */}
{isAdmin && (
<div className="col-span-3">
<Text className="text-gray-600 text-xs">Status</Text>
<div className="flex items-center gap-3 mt-2">
<Switch
checked={plugin.enabled}
loading={isToggling}
onChange={handleToggleEnabled}
/>
<Text className="text-sm">
{plugin.enabled
? "Plugin is enabled and visible in marketplace"
: "Plugin is disabled and hidden from marketplace"}
</Text>
</div>
</div>
)}
</Grid>
</Card>
{/* Description */}
{plugin.description && (
<Card>
<Title>Description</Title>
<Text className="mt-2">{plugin.description}</Text>
</Card>
)}
{/* Keywords */}
{plugin.keywords && plugin.keywords.length > 0 && (
<Card>
<Title>Keywords</Title>
<div className="flex flex-wrap gap-2 mt-2">
{plugin.keywords.map((keyword, index) => (
<Badge key={index} color="gray" size="xs">
{keyword}
</Badge>
))}
</div>
</Card>
)}
{/* Author Information */}
{plugin.author && (
<Card>
<Title>Author Information</Title>
<Grid numColsSm={2} className="gap-4 mt-4">
{plugin.author.name && (
<div>
<Text className="text-gray-600 text-xs">Name</Text>
<Text className="font-semibold mt-1">
{plugin.author.name}
</Text>
</div>
)}
{plugin.author.email && (
<div>
<Text className="text-gray-600 text-xs">Email</Text>
<Text className="font-semibold mt-1">
<a
href={`mailto:${plugin.author.email}`}
className="text-blue-500 hover:text-blue-700"
>
{plugin.author.email}
</a>
</Text>
</div>
)}
</Grid>
</Card>
)}
{/* Additional Links */}
{plugin.homepage && (
<Card>
<Title>Homepage</Title>
<a
href={plugin.homepage}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700 flex items-center gap-2 mt-2"
>
{plugin.homepage}
<ExternalLinkIcon className="h-4 w-4" />
</a>
</Card>
)}
{/* Timestamps */}
<Card>
<Title>Metadata</Title>
<Grid numColsSm={2} className="gap-4 mt-4">
<div>
<Text className="text-gray-600 text-xs">Created At</Text>
<Text className="font-semibold mt-1">
{formatDateString(plugin.created_at)}
</Text>
</div>
<div>
<Text className="text-gray-600 text-xs">Updated At</Text>
<Text className="font-semibold mt-1">
{formatDateString(plugin.updated_at)}
</Text>
</div>
{plugin.created_by && (
<div className="col-span-2">
<Text className="text-gray-600 text-xs">Created By</Text>
<Text className="font-semibold mt-1">{plugin.created_by}</Text>
</div>
)}
</Grid>
</Card>
</div>
);
};
export default PluginInfoView;

View file

@ -0,0 +1,351 @@
import React, { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Button,
Badge,
} from "@tremor/react";
import {
SwitchVerticalIcon,
ChevronUpIcon,
ChevronDownIcon,
TrashIcon,
} from "@heroicons/react/outline";
import { Tooltip, Switch } from "antd";
import { CopyOutlined } from "@ant-design/icons";
import { Plugin } from "./types";
import {
getCategoryBadgeColor,
formatDateString,
} from "./helpers";
import {
enableClaudeCodePlugin,
disableClaudeCodePlugin,
} from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from "@tanstack/react-table";
interface PluginTableProps {
pluginsList: Plugin[];
isLoading: boolean;
onDeleteClick: (pluginName: string, displayName: string) => void;
accessToken: string | null;
onPluginUpdated: () => void;
isAdmin: boolean;
onPluginClick: (pluginId: string) => void;
}
const PluginTable: React.FC<PluginTableProps> = ({
pluginsList,
isLoading,
onDeleteClick,
accessToken,
onPluginUpdated,
isAdmin,
onPluginClick,
}) => {
const [sorting, setSorting] = useState<SortingState>([
{ id: "created_at", desc: true },
]);
const [togglingPlugin, setTogglingPlugin] = useState<string | null>(null);
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleString();
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
NotificationsManager.success("Copied to clipboard!");
};
const handleToggleEnabled = async (plugin: Plugin) => {
if (!accessToken) return;
setTogglingPlugin(plugin.id);
try {
if (plugin.enabled) {
await disableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" disabled`);
} else {
await enableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" enabled`);
}
onPluginUpdated();
} catch (error) {
NotificationsManager.error("Failed to toggle plugin status");
} finally {
setTogglingPlugin(null);
}
};
const columns: ColumnDef<Plugin>[] = [
{
header: "Plugin Name",
accessorKey: "name",
cell: ({ row }) => {
const plugin = row.original;
const name = plugin.name || "";
return (
<div className="flex items-center gap-2">
<Tooltip title={name}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate min-w-[150px] justify-start"
onClick={() => onPluginClick(plugin.id)}
>
{name}
</Button>
</Tooltip>
<Tooltip title="Copy Plugin ID">
<CopyOutlined
onClick={(e) => {
e.stopPropagation();
copyToClipboard(plugin.id);
}}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
);
},
},
{
header: "Version",
accessorKey: "version",
cell: ({ row }) => {
const version = row.original.version || "N/A";
return <span className="text-xs text-gray-600">{version}</span>;
},
},
{
header: "Description",
accessorKey: "description",
cell: ({ row }) => {
const description = row.original.description || "No description";
return (
<Tooltip title={description}>
<span className="text-xs text-gray-600 block max-w-[300px] truncate">
{description}
</span>
</Tooltip>
);
},
},
{
header: "Category",
accessorKey: "category",
cell: ({ row }) => {
const category = row.original.category;
if (!category) {
return (
<Badge color="gray" className="text-xs font-normal" size="xs">
Uncategorized
</Badge>
);
}
const badgeColor = getCategoryBadgeColor(category);
return (
<Badge color={badgeColor} className="text-xs font-normal" size="xs">
{category}
</Badge>
);
},
},
{
header: "Enabled",
accessorKey: "enabled",
cell: ({ row }) => {
const plugin = row.original;
return (
<div className="flex items-center gap-2">
<Badge
color={plugin.enabled ? "green" : "gray"}
className="text-xs font-normal"
size="xs"
>
{plugin.enabled ? "Yes" : "No"}
</Badge>
{isAdmin && (
<Tooltip
title={plugin.enabled ? "Disable plugin" : "Enable plugin"}
>
<Switch
size="small"
checked={plugin.enabled}
loading={togglingPlugin === plugin.id}
onChange={() => handleToggleEnabled(plugin)}
/>
</Tooltip>
)}
</div>
);
},
},
{
header: "Created At",
accessorKey: "created_at",
cell: ({ row }) => {
const plugin = row.original;
return (
<Tooltip title={plugin.created_at}>
<span className="text-xs">{formatDate(plugin.created_at)}</span>
</Tooltip>
);
},
},
...(isAdmin
? [
{
header: "Actions",
id: "actions",
enableSorting: false,
cell: ({ row }: any) => {
const plugin = row.original;
return (
<div className="flex items-center gap-1">
<Tooltip title="Delete plugin">
<Button
size="xs"
variant="light"
color="red"
onClick={(e) => {
e.stopPropagation();
onDeleteClick(plugin.name, plugin.name);
}}
icon={TrashIcon}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
/>
</Tooltip>
</div>
);
},
},
]
: []),
];
const table = useReactTable({
data: pluginsList,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSorting: true,
});
return (
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className={`py-1 h-8 ${
header.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
{header.column.getCanSort() && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: (
<ChevronUpIcon className="h-4 w-4 text-blue-500" />
),
desc: (
<ChevronDownIcon className="h-4 w-4 text-blue-500" />
),
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
)}
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>Loading...</p>
</div>
</TableCell>
</TableRow>
) : pluginsList && pluginsList.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
cell.column.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No plugins found. Add one to get started.</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
export default PluginTable;

View file

@ -0,0 +1,113 @@
/**
* TypeScript types for Claude Code Marketplace
* Matches backend API types from /litellm/types/proxy/claude_code_endpoints.py
*/
export interface PluginSource {
source: "github" | "url";
repo?: string; // Format: "org/repo" for GitHub
url?: string; // Full URL for other sources
}
export interface PluginAuthor {
name: string;
email?: string;
}
export interface Plugin {
id: string;
name: string; // kebab-case
version?: string; // semantic version
description?: string;
source: PluginSource;
author?: PluginAuthor;
homepage?: string;
keywords?: string[];
category?: string;
enabled: boolean;
created_at?: string;
updated_at?: string;
created_by?: string;
}
export interface PluginListItem {
id: string;
name: string;
version?: string;
description?: string;
source: PluginSource;
author?: PluginAuthor;
homepage?: string;
keywords?: string[];
category?: string;
enabled: boolean;
created_at?: string;
updated_at?: string;
created_by?: string;
}
export interface ListPluginsResponse {
plugins: PluginListItem[];
count: number;
}
export interface RegisterPluginRequest {
name: string;
source: PluginSource;
version?: string;
description?: string;
author?: PluginAuthor;
homepage?: string;
keywords?: string[];
category?: string;
}
export interface RegisterPluginResponse {
plugin: Plugin;
action: "created" | "updated";
message: string;
}
// Public marketplace types
export interface MarketplacePluginEntry {
name: string;
source: PluginSource;
version?: string;
description?: string;
author?: PluginAuthor;
homepage?: string;
keywords?: string[];
category?: string;
}
export interface MarketplaceOwner {
name: string;
email?: string;
}
export interface MarketplaceResponse {
name: string; // Marketplace name (e.g., "litellm")
owner: MarketplaceOwner;
plugins: MarketplacePluginEntry[];
}
// UI-specific types
export interface CategoryTab {
key: string;
label: string;
count: number;
}
export interface PluginFormData {
name: string;
sourceType: "github" | "url";
repo: string;
url: string;
version: string;
description: string;
authorName: string;
authorEmail: string;
homepage: string;
category: string;
keywords: string; // Comma-separated string, will be split into array
}

View file

@ -256,6 +256,13 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
icon: <TagsOutlined />,
roles: all_admin_roles,
},
{
key: "claude-code-plugins",
page: "claude-code-plugins",
label: "Claude Code Plugins",
icon: <ToolOutlined />,
roles: all_admin_roles,
},
{
key: "4",
page: "usage",

View file

@ -425,7 +425,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
MCP Server Name
<Tooltip title="Best practice: Use a descriptive name that indicates the server's purpose (e.g., 'GitHub_MCP', 'Email_Service'). Hyphens '-' are not allowed; use underscores '_' instead.">
<Tooltip title="Best practice: Use a descriptive name that indicates the server's purpose (e.g., 'GitHub_MCP', 'Email_Service'). Hyphens '-' are not allowed; use underscores '_' instead. Names must comply with SEP-986 and will be rejected if invalid (https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names).">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>

View file

@ -8280,3 +8280,278 @@ export const updateUiSettings = async (accessToken: string, settings: Record<str
const data = await response.json();
return data;
};
// ============================================================
// Claude Code Marketplace Networking Functions
// ============================================================
/**
* Get public marketplace catalog (no authentication required)
* Returns marketplace.json for Claude Code CLI discovery
*/
export const getClaudeCodeMarketplace = async () => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/marketplace.json`
: `/claude-code/marketplace.json`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch Claude Code marketplace:", error);
throw error;
}
};
/**
* List all Claude Code plugins (admin only)
* @param accessToken - Admin access token
* @param enabledOnly - If true, only return enabled plugins (default: false)
*/
export const getClaudeCodePluginsList = async (
accessToken: string,
enabledOnly: boolean = false
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins?enabled_only=${enabledOnly}`
: `/claude-code/plugins?enabled_only=${enabledOnly}`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to fetch Claude Code plugins list:", error);
throw error;
}
};
/**
* Get details for a specific Claude Code plugin (admin only)
* @param accessToken - Admin access token
* @param pluginName - Name of the plugin
*/
export const getClaudeCodePluginDetails = async (
accessToken: string,
pluginName: string
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins/${pluginName}`
: `/claude-code/plugins/${pluginName}`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error(`Failed to fetch plugin "${pluginName}":`, error);
throw error;
}
};
/**
* Register or update a Claude Code plugin (admin only)
* @param accessToken - Admin access token
* @param pluginData - Plugin registration data
*/
export const registerClaudeCodePlugin = async (
accessToken: string,
pluginData: {
name: string;
source: { source: string; repo?: string; url?: string };
version?: string;
description?: string;
author?: { name: string; email?: string };
homepage?: string;
keywords?: string[];
category?: string;
}
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins`
: `/claude-code/plugins`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(pluginData),
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to register Claude Code plugin:", error);
throw error;
}
};
/**
* Enable a Claude Code plugin (admin only)
* @param accessToken - Admin access token
* @param pluginName - Name of the plugin to enable
*/
export const enableClaudeCodePlugin = async (
accessToken: string,
pluginName: string
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins/${pluginName}/enable`
: `/claude-code/plugins/${pluginName}/enable`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error(`Failed to enable plugin "${pluginName}":`, error);
throw error;
}
};
/**
* Disable a Claude Code plugin (admin only)
* @param accessToken - Admin access token
* @param pluginName - Name of the plugin to disable
*/
export const disableClaudeCodePlugin = async (
accessToken: string,
pluginName: string
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins/${pluginName}/disable`
: `/claude-code/plugins/${pluginName}/disable`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error(`Failed to disable plugin "${pluginName}":`, error);
throw error;
}
};
/**
* Delete a Claude Code plugin (admin only)
* @param accessToken - Admin access token
* @param pluginName - Name of the plugin to delete
*/
export const deleteClaudeCodePlugin = async (
accessToken: string,
pluginName: string
) => {
try {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/claude-code/plugins/${pluginName}`
: `/claude-code/plugins/${pluginName}`;
const response = await fetch(url, {
method: "DELETE",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
const errorMessage = deriveErrorMessage(JSON.parse(errorData));
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error(`Failed to delete plugin "${pluginName}":`, error);
throw error;
}
};