This commit is contained in:
devin-ai-integration[bot] 2026-10-04 21:36:03 +00:00 • committed by GitHub
commit 01bca5c408
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 3132 additions and 110 deletions

View file

@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS "LiteLLM_MCPToolVersion" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"tool_name" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"input_schema" JSONB NOT NULL DEFAULT '{}',
"change_kind" TEXT NOT NULL,
"changes" JSONB NOT NULL DEFAULT '[]',
"changelog" TEXT,
"deprecated_at" TIMESTAMP(3),
"sunset_date" TIMESTAMP(3),
"deprecation_note" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" TEXT,
CONSTRAINT "LiteLLM_MCPToolVersion_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "LiteLLM_MCPToolVersion_server_id_idx" ON "LiteLLM_MCPToolVersion"("server_id");
CREATE UNIQUE INDEX IF NOT EXISTS "LiteLLM_MCPToolVersion_server_id_tool_name_version_key"
ON "LiteLLM_MCPToolVersion"("server_id", "tool_name", "version");

View file

@ -427,6 +427,26 @@ model LiteLLM_MCPServerTable {
@@index([approval_status])
}
model LiteLLM_MCPToolVersion {
id String @id @default(uuid())
server_id String
tool_name String
version Int
description String @default("")
input_schema Json @default("{}")
change_kind String
changes Json @default("[]")
changelog String?
deprecated_at DateTime?
sunset_date DateTime?
deprecation_note String?
created_at DateTime @default(now())
created_by String?
@@unique([server_id, tool_name, version])
@@index([server_id])
}
// Named collection of {server_id, tool_name} pairs that can be granted to keys/teams
model LiteLLM_MCPToolsetTable {
toolset_id String @id @default(uuid())

View file

@ -45,6 +45,7 @@ from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import (
MCPServerOAuthClientRepository,
MCPServerRepository,
MCPToolVersionRepository,
MCPUserCredentialsRepository,
PrismaTableRepository,
)
@ -54,7 +55,12 @@ from litellm.repositories.verification_token_repository import (
)
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.mcp import MCPCredentials, MCPTransportType, MCPUpstreamProtocol, validate_mcp_protocol_transport
from litellm.types.mcp_server.mcp_server_manager import MCPInfo, PinnedMCPTool
from litellm.types.mcp_server.mcp_server_manager import (
MCPInfo,
MCPToolDeprecationRequest,
MCPToolVersion,
PinnedMCPTool,
)
if TYPE_CHECKING:
from prisma import models as prisma_db_models
@ -484,6 +490,13 @@ def _mcp_server_table_actions(
return table
def _mcp_tool_version_table_actions(
prisma_client: PrismaClient,
) -> "TableActions[prisma_db_models.LiteLLM_MCPToolVersion]":
table: Final[TableActions[prisma_db_models.LiteLLM_MCPToolVersion]] = MCPToolVersionRepository(prisma_client).table
return table
def _verification_token_table_actions(
prisma_client: PrismaClient,
) -> "TableActions[prisma_db_models.LiteLLM_VerificationToken]":
@ -514,6 +527,9 @@ def _db_transaction_manager(prisma_client: PrismaClient) -> _UserEnvVarsTransact
return manager
_MCP_PIN_ADVISORY_LOCK_SQL: Final = "SELECT pg_advisory_xact_lock(hashtext($1)) IS NULL AS locked"
def _identifier_where(value: str, exclude_server_id: str | None) -> "prisma_db_types.LiteLLM_MCPServerTableWhereInput":
own_row_guard: Final = ({"NOT": [{"server_id": exclude_server_id}]},) if exclude_server_id is not None else ()
where: Final[prisma_db_types.LiteLLM_MCPServerTableWhereInput] = {
@ -918,6 +934,15 @@ async def delete_mcp_server(
},
)
if deleted_server is not None:
try:
await _mcp_tool_version_table_actions(prisma_client).delete_many(where={"server_id": server_id})
except Exception as e:
verbose_proxy_logger.warning(
"MCP server %s deleted but tool version cleanup failed; "
"orphaned rows can be removed on a later delete: %s",
server_id,
e,
)
credential_user_ids: list[str] = []
try:
credential_rows: Sequence[prisma_db_models.LiteLLM_MCPUserCredentials] = await _user_credential_actions(
@ -2177,23 +2202,112 @@ async def set_mcp_server_pinned_tools(
server_id: str,
pinned_tools: Mapping[str, PinnedMCPTool] | None,
touched_by: str,
changelog: str | None = None,
) -> LiteLLM_MCPServerTable | None:
"""Replace the server's pinned catalog; ``None`` unpins. Only this write path sets the pin."""
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
if await _db_find_mcp_server_row(prisma_client, server_id) is None:
return None
snapshot: Final = {name: tool.model_dump() for name, tool in (pinned_tools or {}).items()}
updated: Final = await _db_update_mcp_server_row(
prisma_client,
server_id,
{"pinned_tools": safe_dumps(snapshot), "updated_by": touched_by},
)
table: Final = LiteLLM_MCPServerTable.model_validate(updated.model_dump())
decrypt_global_env_var_values(table.env_vars)
async with prisma_client.tx() as tx:
_ = await tx.execute_raw(_MCP_PIN_ADVISORY_LOCK_SQL, f"mcp_pin:{server_id}")
server_row: Final = await tx.litellm_mcpservertable.find_unique(where={"server_id": server_id})
if server_row is None:
return None
if pinned_tools is not None:
from litellm.proxy._experimental.mcp_server.tool_versioning import plan_tool_versions
existing_versions: Final = await _list_mcp_tool_versions_from_actions(tx.litellm_mcptoolversion, server_id)
latest_versions: Final = {row.tool_name: row for row in reversed(existing_versions)}
planned_versions: Final = plan_tool_versions(latest_versions, pinned_tools)
version_rows: Final[tuple[dict[str, object], ...]] = tuple(
{
"server_id": server_id,
"tool_name": planned.tool_name,
"version": planned.version,
"description": planned.tool.description,
"input_schema": safe_dumps(planned.tool.input_schema),
"change_kind": planned.change_kind,
"changes": safe_dumps([change.model_dump() for change in planned.changes]),
"changelog": changelog,
"created_by": touched_by,
}
for planned in planned_versions
)
if version_rows:
await tx.litellm_mcptoolversion.create_many(data=version_rows)
snapshot: Final = {name: tool.model_dump() for name, tool in (pinned_tools or {}).items()}
updated: Final = await tx.litellm_mcpservertable.update(
where={"server_id": server_id},
data={"pinned_tools": safe_dumps(snapshot), "updated_by": touched_by},
)
if updated is None:
raise ValueError(f"MCP server not found, passed server_id={server_id}")
table: Final = LiteLLM_MCPServerTable.model_validate(updated.model_dump())
decrypt_global_env_var_values(table.env_vars)
from litellm.proxy.common_utils.config_sync_pubsub import publish_config_change_for_object_type
await publish_config_change_for_object_type("litellm_mcpservertable")
return table
async def _list_mcp_tool_versions_from_actions(
table: "TableActions[prisma_db_models.LiteLLM_MCPToolVersion]",
server_id: str,
) -> list[MCPToolVersion]:
rows: Final[Sequence[prisma_db_models.LiteLLM_MCPToolVersion]] = await table.find_many(
where={"server_id": server_id},
order=[{"tool_name": "asc"}, {"version": "desc"}],
)
return [MCPToolVersion.model_validate(row.model_dump()) for row in rows]
async def list_mcp_tool_versions(prisma_client: PrismaClient, server_id: str) -> list[MCPToolVersion]:
return await _list_mcp_tool_versions_from_actions(
_mcp_tool_version_table_actions(prisma_client),
server_id,
)
async def set_mcp_tool_version_deprecation(
prisma_client: PrismaClient,
server_id: str,
tool_name: str,
version: int,
deprecation: MCPToolDeprecationRequest | None,
) -> MCPToolVersion | None:
unique_where: Final = {
"server_id_tool_name_version": {
"server_id": server_id,
"tool_name": tool_name,
"version": version,
}
}
async with prisma_client.tx() as tx:
_ = await tx.execute_raw(_MCP_PIN_ADVISORY_LOCK_SQL, f"mcp_pin:{server_id}")
row: Final[prisma_db_models.LiteLLM_MCPToolVersion | None] = await tx.litellm_mcptoolversion.find_unique(
where=unique_where
)
if row is None:
return None
data: Final[dict[str, object]] = (
{
"deprecated_at": None,
"sunset_date": None,
"deprecation_note": None,
}
if deprecation is None
else {
"deprecated_at": row.deprecated_at or datetime.now(timezone.utc),
"sunset_date": deprecation.sunset_date,
"deprecation_note": deprecation.deprecation_note,
}
)
updated: Final[prisma_db_models.LiteLLM_MCPToolVersion | None] = await tx.litellm_mcptoolversion.update(
where=unique_where,
data=data,
)
return MCPToolVersion.model_validate(updated.model_dump()) if updated is not None else None
async def reject_mcp_server(
prisma_client: PrismaClient,
server_id: str,

View file

@ -0,0 +1,262 @@
import json
from collections.abc import Mapping
from dataclasses import dataclass
from itertools import chain
from typing import Final, cast
from litellm.types.mcp_server.mcp_server_manager import (
MCPToolChange,
MCPToolChangeKind,
MCPToolVersion,
PinnedMCPTool,
)
@dataclass(frozen=True, slots=True)
class PlannedToolVersion:
tool_name: str
version: int
tool: PinnedMCPTool
change_kind: MCPToolChangeKind
changes: tuple[MCPToolChange, ...]
def _properties(input_schema: Mapping[str, object]) -> Mapping[str, object]:
properties: Final[object] = input_schema.get("properties")
return properties if isinstance(properties, Mapping) else {}
def _required(input_schema: Mapping[str, object]) -> frozenset[str]:
required: Final[object] = input_schema.get("required")
if not isinstance(required, list):
return frozenset()
return frozenset(value for value in required if isinstance(value, str))
def _mapping_without_documentation(schema: Mapping[str, object]) -> dict[str, object]:
mapping_keys: Final = frozenset(("properties", "patternProperties", "$defs", "definitions", "dependentSchemas"))
list_keys: Final = frozenset(("items", "prefixItems", "anyOf", "oneOf", "allOf"))
schema_keys: Final = frozenset(
(
"items",
"additionalProperties",
"unevaluatedProperties",
"contains",
"not",
"if",
"then",
"else",
"propertyNames",
)
)
return {
key: (
{
child_key: _schema_without_documentation(child)
for child_key, child in cast( # cast-ok: schema maps contain nested schemas
Mapping[str, object], item
).items()
}
if key in mapping_keys and isinstance(item, Mapping)
else [
_schema_without_documentation(child)
for child in cast( # cast-ok: schema array positions contain objects
list[object], item
)
]
if key in list_keys and isinstance(item, list)
else _schema_without_documentation(
cast( # cast-ok: schema keyword values are object schemas
Mapping[str, object], item
)
)
if key in schema_keys and isinstance(item, Mapping)
else item
)
for key, item in schema.items()
if key not in ("description", "title")
}
def _schema_without_documentation(value: object) -> object:
if not isinstance(value, Mapping):
return value
return _mapping_without_documentation(
cast(Mapping[str, object], value) # cast-ok: MCP schemas use string-keyed JSON objects
)
def _type_label(parameter_schema: object) -> str:
if not isinstance(parameter_schema, Mapping) or "type" not in parameter_schema:
return "any"
return json.dumps(parameter_schema["type"])
def _classify_parameter(
name: str,
previous_properties: Mapping[str, object],
current_properties: Mapping[str, object],
previous_required: frozenset[str],
current_required: frozenset[str],
) -> tuple[MCPToolChange, ...]:
in_previous: Final = name in previous_properties
in_current: Final = name in current_properties
required_change: Final[tuple[MCPToolChange, ...]] = (
(MCPToolChange(breaking=True, summary=f'Parameter "{name}" is now required'),)
if name not in previous_required and name in current_required
else (
(MCPToolChange(breaking=False, summary=f'Parameter "{name}" is now optional'),)
if name in previous_required and name not in current_required
else ()
)
)
if not in_previous and not in_current:
return required_change
if in_previous and not in_current:
return (MCPToolChange(breaking=True, summary=f'Removed parameter "{name}"'),)
if in_current and not in_previous:
is_required: Final = name in current_required
summary: Final = f'Added required parameter "{name}"' if is_required else f'Added optional parameter "{name}"'
return (MCPToolChange(breaking=is_required, summary=summary),)
previous_parameter: Final = previous_properties[name]
current_parameter: Final = current_properties[name]
previous_type: Final = _type_label(previous_parameter)
current_type: Final = _type_label(current_parameter)
schema_change: Final[tuple[MCPToolChange, ...]] = (
(
MCPToolChange(
breaking=True,
summary=f'Parameter "{name}" type changed from {previous_type} to {current_type}',
),
)
if previous_type != current_type
else (
(MCPToolChange(breaking=True, summary=f'Parameter "{name}" schema changed'),)
if _schema_without_documentation(previous_parameter) != _schema_without_documentation(current_parameter)
else (
(MCPToolChange(breaking=False, summary=f'Parameter "{name}" description changed'),)
if previous_parameter != current_parameter
else ()
)
)
)
return schema_change + required_change
def classify_tool_change(previous: PinnedMCPTool, current: PinnedMCPTool) -> tuple[MCPToolChange, ...]:
previous_properties: Final = _properties(previous.input_schema)
current_properties: Final = _properties(current.input_schema)
previous_required: Final = _required(previous.input_schema)
current_required: Final = _required(current.input_schema)
parameter_names: Final = sorted(
set(previous_properties) | set(current_properties) | previous_required | current_required
)
parameter_changes: Final = tuple(
chain.from_iterable(
_classify_parameter(
name,
previous_properties,
current_properties,
previous_required,
current_required,
)
for name in parameter_names
)
)
excluded_top_level_keys: Final = frozenset(("properties", "required", "description", "title"))
previous_top_level_raw: Final = {
key: value for key, value in previous.input_schema.items() if key not in excluded_top_level_keys
}
current_top_level_raw: Final = {
key: value for key, value in current.input_schema.items() if key not in excluded_top_level_keys
}
previous_top_level: Final = {
key: value
for key, value in _mapping_without_documentation(previous.input_schema).items()
if key not in excluded_top_level_keys
}
current_top_level: Final = {
key: value
for key, value in _mapping_without_documentation(current.input_schema).items()
if key not in excluded_top_level_keys
}
description_changes: Final = (
(MCPToolChange(breaking=False, summary="Description changed"),)
if previous.description != current.description
else ()
)
input_schema_description_changes: Final = (
(MCPToolChange(breaking=False, summary="Input schema description changed"),)
if previous.input_schema.get("description") != current.input_schema.get("description")
else ()
)
input_schema_title_changes: Final = (
(MCPToolChange(breaking=False, summary="Input schema title changed"),)
if previous.input_schema.get("title") != current.input_schema.get("title")
else ()
)
schema_changes: Final = (
(MCPToolChange(breaking=True, summary="Input schema changed"),)
if previous_top_level != current_top_level
else (
(MCPToolChange(breaking=False, summary="Input schema documentation changed"),)
if previous_top_level_raw != current_top_level_raw
else ()
)
)
return (
description_changes
+ input_schema_description_changes
+ input_schema_title_changes
+ parameter_changes
+ schema_changes
)
def _plan_tool_version(
name: str,
latest: Mapping[str, MCPToolVersion],
snapshot: Mapping[str, PinnedMCPTool],
) -> tuple[PlannedToolVersion, ...]:
has_snapshot: Final = name in snapshot
has_latest: Final = name in latest
if has_snapshot and not has_latest:
return (PlannedToolVersion(name, 1, snapshot[name], "initial", ()),)
if has_snapshot and has_latest:
current_tool: Final = snapshot[name]
latest_version: Final = latest[name]
is_restored: Final = latest_version.change_kind == "removed"
changes: Final = (
(MCPToolChange(breaking=False, summary="Tool restored"),) if is_restored else ()
) + classify_tool_change(
PinnedMCPTool(description=latest_version.description, input_schema=latest_version.input_schema),
current_tool,
)
if not changes:
return ()
change_kind: Final[MCPToolChangeKind] = (
"breaking" if any(change.breaking for change in changes) else "non_breaking"
)
return (PlannedToolVersion(name, latest_version.version + 1, current_tool, change_kind, changes),)
if has_latest and latest[name].change_kind != "removed":
previous_version: Final = latest[name]
return (
PlannedToolVersion(
name,
previous_version.version + 1,
PinnedMCPTool(description=previous_version.description, input_schema=previous_version.input_schema),
"removed",
(MCPToolChange(breaking=True, summary="Tool removed"),),
),
)
return ()
def plan_tool_versions(
latest: Mapping[str, MCPToolVersion],
snapshot: Mapping[str, PinnedMCPTool],
) -> tuple[PlannedToolVersion, ...]:
names: Final = sorted(set(latest) | set(snapshot))
return tuple(chain.from_iterable(_plan_tool_version(name, latest, snapshot) for name in names))

View file

@ -38036,6 +38036,170 @@
"title": "MCPSubmissionsSummary",
"type": "object"
},
"MCPToolChange": {
"additionalProperties": false,
"properties": {
"breaking": {
"title": "Breaking",
"type": "boolean"
},
"summary": {
"title": "Summary",
"type": "string"
}
},
"required": [
"breaking",
"summary"
],
"title": "MCPToolChange",
"type": "object"
},
"MCPToolDeprecationRequest": {
"additionalProperties": false,
"properties": {
"deprecation_note": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Deprecation Note"
},
"sunset_date": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Sunset Date"
}
},
"title": "MCPToolDeprecationRequest",
"type": "object"
},
"MCPToolVersion": {
"properties": {
"change_kind": {
"enum": [
"initial",
"non_breaking",
"breaking",
"removed"
],
"title": "Change Kind",
"type": "string"
},
"changelog": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Changelog"
},
"changes": {
"default": [],
"items": {
"$ref": "#/components/schemas/MCPToolChange"
},
"title": "Changes",
"type": "array"
},
"created_at": {
"format": "date-time",
"title": "Created At",
"type": "string"
},
"created_by": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Created By"
},
"deprecated_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Deprecated At"
},
"deprecation_note": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Deprecation Note"
},
"description": {
"default": "",
"title": "Description",
"type": "string"
},
"input_schema": {
"additionalProperties": true,
"title": "Input Schema",
"type": "object"
},
"server_id": {
"title": "Server Id",
"type": "string"
},
"sunset_date": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Sunset Date"
},
"tool_name": {
"title": "Tool Name",
"type": "string"
},
"version": {
"title": "Version",
"type": "integer"
}
},
"required": [
"server_id",
"tool_name",
"version",
"change_kind",
"created_at"
],
"title": "MCPToolVersion",
"type": "object"
},
"MCPToolsetTool": {
"properties": {
"server_id": {
@ -38800,6 +38964,24 @@
"title": "NewMCPToolsetRequest",
"type": "object"
},
"PinMCPServerToolsRequest": {
"additionalProperties": false,
"properties": {
"changelog": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Changelog"
}
},
"title": "PinMCPServerToolsRequest",
"type": "object"
},
"PinnedMCPTool": {
"additionalProperties": false,
"description": "One tool of an admin-pinned catalog: the description and input schema tools/list keeps serving.",
@ -40450,6 +40632,23 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PinMCPServerToolsRequest"
},
{
"type": "null"
}
],
"title": "Payload"
}
}
}
},
"responses": {
"200": {
"content": {
@ -40545,6 +40744,198 @@
]
}
},
"/v1/mcp/server/{server_id}/tool-versions": {
"get": {
"description": "Returns the version history of the tools recorded for an MCP server.",
"operationId": "fetch_mcp_server_tool_versions_v1_mcp_server__server_id__tool_versions_get",
"parameters": [
{
"in": "path",
"name": "server_id",
"required": true,
"schema": {
"title": "Server Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/MCPToolVersion"
},
"title": "Response Fetch Mcp Server Tool Versions V1 Mcp Server Server Id Tool Versions Get",
"type": "array"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Fetch Mcp Server Tool Versions",
"tags": [
"mcp_management"
]
}
},
"/v1/mcp/server/{server_id}/tools/{tool_name}/versions/{version}/deprecation": {
"delete": {
"description": "Clear deprecation and sunset metadata for an MCP tool version.",
"operationId": "clear_tool_version_deprecation_v1_mcp_server__server_id__tools__tool_name__versions__version__deprecation_delete",
"parameters": [
{
"in": "path",
"name": "server_id",
"required": true,
"schema": {
"title": "Server Id",
"type": "string"
}
},
{
"in": "path",
"name": "tool_name",
"required": true,
"schema": {
"title": "Tool Name",
"type": "string"
}
},
{
"in": "path",
"name": "version",
"required": true,
"schema": {
"title": "Version",
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MCPToolVersion"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Clear Tool Version Deprecation",
"tags": [
"mcp_management"
]
},
"put": {
"description": "Set deprecation and sunset metadata for an MCP tool version.",
"operationId": "set_tool_version_deprecation_v1_mcp_server__server_id__tools__tool_name__versions__version__deprecation_put",
"parameters": [
{
"in": "path",
"name": "server_id",
"required": true,
"schema": {
"title": "Server Id",
"type": "string"
}
},
{
"in": "path",
"name": "tool_name",
"required": true,
"schema": {
"title": "Tool Name",
"type": "string"
}
},
{
"in": "path",
"name": "version",
"required": true,
"schema": {
"title": "Version",
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MCPToolDeprecationRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MCPToolVersion"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Set Tool Version Deprecation",
"tags": [
"mcp_management"
]
}
},
"/v1/mcp/server/{server_id}/user-credential": {
"delete": {
"description": "Delete the calling user's stored API key for a BYOK MCP server. A proxy admin may pass user_id to revoke another user's stored key.",

View file

@ -157,6 +157,7 @@ if MCP_AVAILABLE:
get_user_env_vars,
get_user_env_vars_bulk,
get_user_oauth_credential,
list_mcp_tool_versions,
list_server_user_credentials,
list_user_oauth_credentials,
mcp_oauth_token_identity,
@ -164,6 +165,7 @@ if MCP_AVAILABLE:
purge_user_oauth_credentials_for_server,
reject_mcp_server,
set_mcp_server_pinned_tools,
set_mcp_tool_version_deprecation,
store_user_credential,
store_user_oauth_credential,
update_mcp_server,
@ -183,6 +185,7 @@ if MCP_AVAILABLE:
)
from litellm.proxy._experimental.mcp_server.server_resolution import (
MCPServerTargetCatalog,
ResolvedMCPServer,
authorize_mcp_server,
resolve_mcp_server,
)
@ -246,7 +249,24 @@ if MCP_AVAILABLE:
normalize_upstream_header_name,
validate_mcp_protocol_transport,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer, PinnedMCPTool
from litellm.types.mcp_server.mcp_server_manager import (
MCPServer,
MCPToolDeprecationRequest,
MCPToolVersion,
PinMCPServerToolsRequest,
PinnedMCPTool,
)
async def _get_allowed_tool_names_for_server(
server_id: str,
user_api_key_dict: UserAPIKeyAuth,
) -> list[str] | None:
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler
return await MCPRequestHandler.get_allowed_tools_for_server(
server_id=server_id,
user_api_key_auth=user_api_key_dict,
)
@dataclass
class _TemporaryMCPServerEntry:
@ -1558,7 +1578,8 @@ if MCP_AVAILABLE:
async def pin_mcp_server_tools(
server_id: str,
request: Request,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
payload: PinMCPServerToolsRequest | None = None,
) -> dict[str, PinnedMCPTool]:
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
raise HTTPException(
@ -1583,7 +1604,8 @@ if MCP_AVAILABLE:
"error": f"MCP server '{server_id}' exposes no tools that pass the guardrails; nothing to pin."
},
)
await _store_pinned_tools(server_id, snapshot, user_api_key_dict)
changelog: Final = (payload.changelog or "").strip() if payload is not None else ""
await _store_pinned_tools(server_id, snapshot, user_api_key_dict, changelog=changelog or None)
return snapshot
@router.delete(
@ -1612,15 +1634,30 @@ if MCP_AVAILABLE:
return {"server_id": server_id, "status": "unpinned"}
async def _store_pinned_tools(
server_id: str, pinned_tools: Mapping[str, PinnedMCPTool] | None, user_api_key_dict: UserAPIKeyAuth
server_id: str,
pinned_tools: Mapping[str, PinnedMCPTool] | None,
user_api_key_dict: UserAPIKeyAuth,
changelog: str | None = None,
) -> None:
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
record: Final = await set_mcp_server_pinned_tools(
prisma_client,
server_id,
pinned_tools,
touched_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
)
touched_by: Final = user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME
try:
record: Final = await set_mcp_server_pinned_tools(
prisma_client,
server_id,
pinned_tools,
touched_by=touched_by,
changelog=changelog,
)
except Exception as e:
if not isinstance(e, UniqueViolationError):
raise
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"error": f"Another pin of MCP server '{server_id}' recorded tool versions at the same time; retry the pin."
},
) from e
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -1720,32 +1757,12 @@ if MCP_AVAILABLE:
await global_mcp_server_manager.reload_servers_from_database()
return _redact_mcp_credentials(rejected)
@router.get(
"/server/{server_id}",
description="Returns the mcp server info",
dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_MCPServerTable,
)
async def fetch_mcp_server(
async def _resolve_and_authorize_mcp_server(
request: Request,
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
include_reachability: Annotated[
bool,
Query(description="Allow the 'reachable' status for responding servers whose authentication is unchecked."),
] = False,
):
"""
Get the info on the mcp server specified by the `server_id`
Parameters:
- server_id: str - Required. The unique identifier of the mcp server to get info on.
```
curl --location 'http://localhost:4000/v1/mcp/server/server_id' \
--header 'Authorization: Bearer your_api_key_here'
```
"""
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
user_api_key_dict: UserAPIKeyAuth,
prisma_client: "PrismaClient",
) -> tuple[ResolvedMCPServer, bool]:
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
client_ip: Final = IPAddressUtils.get_mcp_client_ip(request)
@ -1780,6 +1797,139 @@ if MCP_AVAILABLE:
and global_mcp_server_manager.get_mcp_server_by_id(resolved.table.server_id) is not None
),
)
return authorized, is_restricted_virtual_key
@router.get(
"/server/{server_id}/tool-versions",
description="Returns the version history of the tools recorded for an MCP server.",
dependencies=[Depends(user_api_key_auth)],
response_model=list[MCPToolVersion],
)
async def fetch_mcp_server_tool_versions(
request: Request,
server_id: str,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> list[MCPToolVersion]:
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
authorized: Final[tuple[ResolvedMCPServer, bool]] = await _resolve_and_authorize_mcp_server(
request,
server_id,
user_api_key_dict,
prisma_client,
)
if authorized[0].source != "db":
return []
resolved_server_id: Final = authorized[0].table.server_id
allowed: Final = await _get_allowed_tool_names_for_server(
server_id=resolved_server_id,
user_api_key_dict=user_api_key_dict,
)
versions: Final = await list_mcp_tool_versions(prisma_client, resolved_server_id)
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler
return [version for version in versions if MCPRequestHandler.tool_is_granted(version.tool_name, allowed)]
@router.put(
"/server/{server_id}/tools/{tool_name}/versions/{version}/deprecation",
description="Set deprecation and sunset metadata for an MCP tool version.",
dependencies=[Depends(user_api_key_auth)],
response_model=MCPToolVersion,
)
@management_endpoint_wrapper
async def set_tool_version_deprecation(
server_id: str,
tool_name: str,
version: int,
payload: MCPToolDeprecationRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> MCPToolVersion:
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Admin access required to update MCP tool version deprecation."},
)
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
result: Final[MCPToolVersion | None] = await set_mcp_tool_version_deprecation(
prisma_client,
server_id,
tool_name,
version,
payload,
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": f"MCP tool '{tool_name}' version {version} not found for server '{server_id}'."},
)
return result
@router.delete(
"/server/{server_id}/tools/{tool_name}/versions/{version}/deprecation",
description="Clear deprecation and sunset metadata for an MCP tool version.",
dependencies=[Depends(user_api_key_auth)],
response_model=MCPToolVersion,
)
@management_endpoint_wrapper
async def clear_tool_version_deprecation(
server_id: str,
tool_name: str,
version: int,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> MCPToolVersion:
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Admin access required to update MCP tool version deprecation."},
)
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
result: Final[MCPToolVersion | None] = await set_mcp_tool_version_deprecation(
prisma_client,
server_id,
tool_name,
version,
None,
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": f"MCP tool '{tool_name}' version {version} not found for server '{server_id}'."},
)
return result
@router.get(
"/server/{server_id}",
description="Returns the mcp server info",
dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_MCPServerTable,
)
async def fetch_mcp_server(
request: Request,
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
include_reachability: Annotated[
bool,
Query(description="Allow the 'reachable' status for responding servers whose authentication is unchecked."),
] = False,
):
"""
Get the info on the mcp server specified by the `server_id`
Parameters:
- server_id: str - Required. The unique identifier of the mcp server to get info on.
```
curl --location 'http://localhost:4000/v1/mcp/server/server_id' \
--header 'Authorization: Bearer your_api_key_here'
```
"""
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
authorized_result: Final[tuple[ResolvedMCPServer, bool]] = await _resolve_and_authorize_mcp_server(
request,
server_id,
user_api_key_dict,
prisma_client,
)
authorized: Final = authorized_result[0]
is_restricted_virtual_key: Final = authorized_result[1]
mcp_server: Final = authorized.table
from_db: Final = authorized.source == "db"

View file

@ -427,6 +427,26 @@ model LiteLLM_MCPServerTable {
@@index([approval_status])
}
model LiteLLM_MCPToolVersion {
id String @id @default(uuid())
server_id String
tool_name String
version Int
description String @default("")
input_schema Json @default("{}")
change_kind String
changes Json @default("[]")
changelog String?
deprecated_at DateTime?
sunset_date DateTime?
deprecation_note String?
created_at DateTime @default(now())
created_by String?
@@unique([server_id, tool_name, version])
@@index([server_id])
}
// Named collection of {server_id, tool_name} pairs that can be granted to keys/teams
model LiteLLM_MCPToolsetTable {
toolset_id String @id @default(uuid())

View file

@ -71,6 +71,10 @@ class MCPServerRepository(PrismaTableRepository["prisma_models.LiteLLM_MCPServer
table_name = "litellm_mcpservertable"
class MCPToolVersionRepository(PrismaTableRepository["prisma_models.LiteLLM_MCPToolVersion"]):
table_name = "litellm_mcptoolversion"
class ManagedObjectRepository(PrismaTableRepository["prisma_models.LiteLLM_ManagedObjectTable"]):
table_name = "litellm_managedobjecttable"

View file

@ -78,6 +78,47 @@ class PinnedMCPTool(BaseModel):
input_schema: dict[str, object] = Field(default_factory=dict)
MCPToolChangeKind = Literal["initial", "non_breaking", "breaking", "removed"]
class MCPToolChange(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
breaking: bool
summary: str
class MCPToolVersion(BaseModel):
model_config = ConfigDict(frozen=True)
server_id: str
tool_name: str
version: int
description: str = ""
input_schema: dict[str, object] = Field(default_factory=dict)
change_kind: MCPToolChangeKind
changes: tuple[MCPToolChange, ...] = ()
changelog: str | None = None
deprecated_at: datetime | None = None
sunset_date: datetime | None = None
deprecation_note: str | None = None
created_at: datetime
created_by: str | None = None
class PinMCPServerToolsRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
changelog: str | None = None
class MCPToolDeprecationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
sunset_date: datetime | None = None
deprecation_note: str | None = None
_PINNED_TOOLS: Final[TypeAdapter[dict[str, PinnedMCPTool] | None]] = TypeAdapter(dict[str, PinnedMCPTool] | None)

View file

@ -427,6 +427,26 @@ model LiteLLM_MCPServerTable {
@@index([approval_status])
}
model LiteLLM_MCPToolVersion {
id String @id @default(uuid())
server_id String
tool_name String
version Int
description String @default("")
input_schema Json @default("{}")
change_kind String
changes Json @default("[]")
changelog String?
deprecated_at DateTime?
sunset_date DateTime?
deprecation_note String?
created_at DateTime @default(now())
created_by String?
@@unique([server_id, tool_name, version])
@@index([server_id])
}
// Named collection of {server_id, tool_name} pairs that can be granted to keys/teams
model LiteLLM_MCPToolsetTable {
toolset_id String @id @default(uuid())

View file

@ -874,6 +874,7 @@ def _mock_env_vars_prisma(row=None):
prisma.db.litellm_mcpuserenvvars.upsert = AsyncMock()
prisma.db.litellm_mcpuserenvvars.delete_many = AsyncMock()
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock()
prisma.db.litellm_mcptoolversion.delete_many = AsyncMock()
return prisma
@ -1254,6 +1255,38 @@ async def test_delete_mcp_server_succeeds_when_orphan_cleanup_fails():
prisma.db.litellm_mcpuserenvvars.delete_many.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_mcp_server_removes_tool_version_rows():
from unittest.mock import AsyncMock
from litellm.proxy._experimental.mcp_server.db import delete_mcp_server
prisma: Final = _mock_env_vars_prisma()
prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=object())
await delete_mcp_server(prisma, "srv-1")
prisma.db.litellm_mcptoolversion.delete_many.assert_awaited_once_with(where={"server_id": "srv-1"})
@pytest.mark.asyncio
async def test_delete_mcp_server_succeeds_when_tool_version_cleanup_fails():
from unittest.mock import AsyncMock
from litellm.proxy._experimental.mcp_server.db import delete_mcp_server
deleted: Final = object()
prisma: Final = _mock_env_vars_prisma()
prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=deleted)
prisma.db.litellm_mcptoolversion.delete_many = AsyncMock(side_effect=RuntimeError("connection pool exhausted"))
result: Final = await delete_mcp_server(prisma, "srv-1")
assert result is deleted
prisma.db.litellm_mcptoolversion.delete_many.assert_awaited_once_with(where={"server_id": "srv-1"})
prisma.db.litellm_mcpuserenvvars.delete_many.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_mcp_server_removes_orphaned_user_credentials():
"""Deleting a server must also drop every user's stored BYOK/OAuth credential

View file

@ -9,19 +9,28 @@ must be cleared rather than left at its stored value.
"""
import json
from unittest.mock import AsyncMock, MagicMock
from datetime import datetime, timezone
from typing import Final
from unittest.mock import AsyncMock, MagicMock, call
import pytest
from prisma import Json, models
from fastapi import HTTPException
from prisma import Json, models
from prisma.errors import UniqueViolationError
from litellm.proxy._experimental.mcp_server.db import (
_MCP_PIN_ADVISORY_LOCK_SQL,
create_mcp_server,
set_mcp_server_pinned_tools,
update_mcp_server,
)
from litellm.proxy._types import NewMCPServerRequest, UpdateMCPServerRequest
from litellm.types.mcp_server.mcp_server_manager import PinnedMCPTool
from litellm.types.mcp_server.mcp_server_manager import (
MCPToolChangeKind,
MCPToolDeprecationRequest,
MCPToolVersion,
PinnedMCPTool,
)
def _credentials_cleared(value) -> bool:
@ -37,16 +46,44 @@ def _mock_prisma():
mock_prisma.db.litellm_mcpservertable.create = AsyncMock(return_value=row)
mock_prisma.db.litellm_mcpservertable.find_first = AsyncMock(return_value=None)
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=row)
mock_prisma.db.litellm_mcptoolversion = AsyncMock()
mock_prisma.db.litellm_mcptoolversion.create_many = AsyncMock(return_value=0)
mock_prisma.db.litellm_mcptoolversion.find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_mcptoolversion.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_mcptoolversion.update = AsyncMock(return_value=None)
mock_prisma.db.litellm_mcptoolversion.delete_many = AsyncMock(return_value=0)
tx_client = MagicMock()
tx_client.execute_raw = AsyncMock()
tx_client.litellm_mcpservertable = mock_prisma.db.litellm_mcpservertable
tx_client.litellm_mcpservertable = AsyncMock()
tx_client.litellm_mcpservertable.update = AsyncMock(return_value=row)
tx_client.litellm_mcpservertable.find_unique = AsyncMock(return_value=None)
tx_client.litellm_mcptoolversion = AsyncMock()
tx_client.litellm_mcptoolversion.create_many = AsyncMock(return_value=0)
tx_client.litellm_mcptoolversion.find_many = AsyncMock(return_value=[])
tx = MagicMock()
tx.__aenter__ = AsyncMock(return_value=tx_client)
tx.__aexit__ = AsyncMock(return_value=False)
mock_prisma.db.tx = MagicMock(return_value=tx)
mock_prisma.tx = MagicMock(return_value=tx)
db_tx_client = MagicMock()
db_tx_client.execute_raw = AsyncMock()
db_tx_client.litellm_mcpservertable = mock_prisma.db.litellm_mcpservertable
db_tx = MagicMock()
db_tx.__aenter__ = AsyncMock(return_value=db_tx_client)
db_tx.__aexit__ = AsyncMock(return_value=False)
mock_prisma.db.tx = MagicMock(return_value=db_tx)
return mock_prisma
@pytest.fixture(autouse=True)
def mock_config_sync_publish(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:
publish: Final = AsyncMock()
monkeypatch.setattr(
"litellm.proxy.common_utils.config_sync_pubsub.publish_config_change_for_object_type",
publish,
)
return publish
async def _run_update(data: UpdateMCPServerRequest, fields_set=None) -> dict:
mock_prisma = _mock_prisma()
await update_mcp_server(mock_prisma, data, "test-user", fields_set=fields_set)
@ -1122,30 +1159,106 @@ async def test_register_and_update_bodies_never_write_pinned_tools():
@pytest.mark.asyncio
async def test_set_mcp_server_pinned_tools_writes_the_snapshot_and_null_clears_it():
mock_prisma = _mock_prisma()
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=MagicMock())
tx_client = mock_prisma.tx.return_value.__aenter__.return_value
tx_client.litellm_mcpservertable.find_unique = AsyncMock(return_value=MagicMock())
pinned = {"list_notes": PinnedMCPTool(description="List notes", input_schema={"type": "object"})}
record = await set_mcp_server_pinned_tools(mock_prisma, "test-server", pinned, "admin")
written = mock_prisma.db.litellm_mcpservertable.update.call_args[1]
written = tx_client.litellm_mcpservertable.update.call_args[1]
assert written["where"] == {"server_id": "test-server"}
assert json.loads(written["data"]["pinned_tools"]) == {
"list_notes": {"description": "List notes", "input_schema": {"type": "object"}}
}
assert written["data"]["updated_by"] == "admin"
assert record is not None and record.server_id == "test-server"
version_row: Final = tx_client.litellm_mcptoolversion.create_many.call_args.kwargs["data"][0]
assert isinstance(version_row["input_schema"], str)
assert json.loads(version_row["input_schema"]) == {"type": "object"}
assert isinstance(version_row["changes"], str)
assert json.loads(version_row["changes"]) == []
await set_mcp_server_pinned_tools(mock_prisma, "test-server", None, "admin")
assert mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]["pinned_tools"] == "{}"
assert tx_client.litellm_mcpservertable.update.call_args[1]["data"]["pinned_tools"] == "{}"
@pytest.mark.asyncio
async def test_set_mcp_server_pinned_tools_uses_one_transaction_for_the_pin(
mock_config_sync_publish: AsyncMock,
) -> None:
mock_prisma: Final = _mock_prisma()
calls: Final = MagicMock()
tx: Final = mock_prisma.tx.return_value
tx_client: Final = tx.__aenter__.return_value
updated_row: Final = tx_client.litellm_mcpservertable.update.return_value
tx_client.execute_raw.return_value = 1
tx_client.litellm_mcpservertable.find_unique.return_value = MagicMock()
tx_client.litellm_mcptoolversion.find_many.return_value = []
tx_client.litellm_mcptoolversion.create_many.return_value = 1
tx_client.litellm_mcpservertable.update.return_value = updated_row
tx.__aexit__.return_value = False
calls.attach_mock(tx_client.execute_raw, "lock")
calls.attach_mock(tx_client.litellm_mcpservertable.find_unique, "server_lookup")
calls.attach_mock(tx_client.litellm_mcptoolversion.find_many, "history_read")
calls.attach_mock(tx_client.litellm_mcptoolversion.create_many, "version_insert")
calls.attach_mock(tx_client.litellm_mcpservertable.update, "server_update")
calls.attach_mock(tx.__aexit__, "transaction_exit")
calls.attach_mock(mock_config_sync_publish, "publish")
await set_mcp_server_pinned_tools(
mock_prisma,
"test-server",
{"new": PinnedMCPTool()},
"admin",
)
tx_client.execute_raw.assert_awaited_once_with(_MCP_PIN_ADVISORY_LOCK_SQL, "mcp_pin:test-server")
tx_client.litellm_mcpservertable.find_unique.assert_awaited_once_with(where={"server_id": "test-server"})
tx_client.litellm_mcptoolversion.find_many.assert_awaited_once_with(
where={"server_id": "test-server"},
order=[{"tool_name": "asc"}, {"version": "desc"}],
)
tx_client.litellm_mcptoolversion.create_many.assert_awaited_once()
tx_client.litellm_mcpservertable.update.assert_awaited_once()
assert [entry[0] for entry in calls.mock_calls] == [
"lock",
"server_lookup",
"history_read",
"version_insert",
"server_update",
"transaction_exit",
"publish",
]
assert calls.mock_calls[0] == call.lock(_MCP_PIN_ADVISORY_LOCK_SQL, "mcp_pin:test-server")
assert mock_prisma.db.litellm_mcpservertable.find_unique.await_count == 0
assert mock_prisma.db.litellm_mcpservertable.update.await_count == 0
assert mock_prisma.db.litellm_mcptoolversion.find_many.await_count == 0
assert mock_prisma.db.litellm_mcptoolversion.create_many.await_count == 0
mock_config_sync_publish.assert_awaited_once_with("litellm_mcpservertable")
@pytest.mark.asyncio
async def test_set_mcp_server_pinned_tools_does_not_publish_when_snapshot_update_fails(
mock_config_sync_publish: AsyncMock,
) -> None:
mock_prisma: Final = _mock_prisma()
tx_client: Final = mock_prisma.tx.return_value.__aenter__.return_value
tx_client.litellm_mcpservertable.find_unique = AsyncMock(return_value=MagicMock())
tx_client.litellm_mcpservertable.update = AsyncMock(side_effect=RuntimeError("update failed"))
with pytest.raises(RuntimeError, match="update failed"):
await set_mcp_server_pinned_tools(mock_prisma, "test-server", None, "admin")
mock_config_sync_publish.assert_not_awaited()
@pytest.mark.asyncio
async def test_set_mcp_server_pinned_tools_on_a_missing_server_writes_nothing():
mock_prisma = _mock_prisma()
mock_prisma.db.litellm_mcpservertable.find_unique.return_value = None
tx_client = mock_prisma.tx.return_value.__aenter__.return_value
assert await set_mcp_server_pinned_tools(mock_prisma, "ghost", None, "admin") is None
mock_prisma.db.litellm_mcpservertable.update.assert_not_awaited()
tx_client.litellm_mcpservertable.update.assert_not_awaited()
@pytest.mark.asyncio
@ -1181,3 +1294,207 @@ async def test_protocol_update_preserves_missing_server_without_writing(clear_al
result = await update_mcp_server(prisma, payload, "admin")
assert result is None
table.update.assert_not_awaited()
def _mcp_tool_version(
tool_name: str,
version: int,
change_kind: MCPToolChangeKind,
description: str = "",
input_schema: dict[str, object] | None = None,
deprecated_at: datetime | None = None,
) -> MCPToolVersion:
return MCPToolVersion(
server_id="test-server",
tool_name=tool_name,
version=version,
description=description,
input_schema=input_schema if input_schema is not None else {},
change_kind=change_kind,
changes=(),
created_at=datetime(2026, 10, 2, tzinfo=timezone.utc),
deprecated_at=deprecated_at,
)
@pytest.mark.asyncio
async def test_set_mcp_server_pinned_tools_records_only_changed_versions_before_updating_pin():
mock_prisma: Final = _mock_prisma()
tx_client: Final = mock_prisma.tx.return_value.__aenter__.return_value
tx_client.litellm_mcpservertable.find_unique = AsyncMock(return_value=MagicMock())
tx_client.litellm_mcptoolversion.find_many = AsyncMock(
return_value=[
_mcp_tool_version("changed", 2, "non_breaking", input_schema={"properties": {"q": {"type": "string"}}}),
_mcp_tool_version("changed", 1, "initial"),
_mcp_tool_version("stable", 1, "initial", description="Stable"),
]
)
pinned: Final = {
"changed": PinnedMCPTool(
description="Changed",
input_schema={"properties": {"q": {"type": "integer"}}},
),
"stable": PinnedMCPTool(description="Stable"),
}
await set_mcp_server_pinned_tools(
mock_prisma,
"test-server",
pinned,
"admin-user",
changelog="Updated the changed tool",
)
inserted_rows: Final = tx_client.litellm_mcptoolversion.create_many.call_args.kwargs["data"]
expected_changes: Final = [
{"breaking": False, "summary": "Description changed"},
{
"breaking": True,
"summary": 'Parameter "q" type changed from "string" to "integer"',
},
]
assert inserted_rows == (
{
"server_id": "test-server",
"tool_name": "changed",
"version": 3,
"description": "Changed",
"input_schema": inserted_rows[0]["input_schema"],
"change_kind": "breaking",
"changes": inserted_rows[0]["changes"],
"changelog": "Updated the changed tool",
"created_by": "admin-user",
},
)
assert isinstance(inserted_rows[0]["input_schema"], str)
assert json.loads(inserted_rows[0]["input_schema"]) == {"properties": {"q": {"type": "integer"}}}
assert isinstance(inserted_rows[0]["changes"], str)
assert json.loads(inserted_rows[0]["changes"]) == expected_changes
assert tx_client.litellm_mcpservertable.update.await_count == 1
@pytest.mark.asyncio
async def test_set_mcp_server_pinned_tools_does_not_update_pin_when_version_insert_fails():
mock_prisma: Final = _mock_prisma()
tx_client: Final = mock_prisma.tx.return_value.__aenter__.return_value
tx_client.litellm_mcpservertable.find_unique = AsyncMock(return_value=MagicMock())
tx_client.litellm_mcptoolversion.create_many = AsyncMock(
side_effect=UniqueViolationError({}, message="duplicate version")
)
with pytest.raises(UniqueViolationError):
await set_mcp_server_pinned_tools(
mock_prisma,
"test-server",
{"new": PinnedMCPTool()},
"admin",
)
tx_client.litellm_mcptoolversion.create_many.assert_awaited_once()
tx_client.litellm_mcpservertable.update.assert_not_awaited()
@pytest.mark.asyncio
async def test_list_mcp_tool_versions_orders_tools_and_versions():
mock_prisma: Final = _mock_prisma()
rows: Final = [
_mcp_tool_version("alpha", 2, "breaking"),
_mcp_tool_version("alpha", 1, "initial"),
_mcp_tool_version("beta", 1, "initial"),
]
mock_prisma.db.litellm_mcptoolversion.find_many = AsyncMock(return_value=rows)
from litellm.proxy._experimental.mcp_server.db import list_mcp_tool_versions
assert await list_mcp_tool_versions(mock_prisma, "test-server") == rows
mock_prisma.db.litellm_mcptoolversion.find_many.assert_awaited_once_with(
where={"server_id": "test-server"},
order=[{"tool_name": "asc"}, {"version": "desc"}],
)
@pytest.mark.asyncio
async def test_set_mcp_tool_version_deprecation_preserves_existing_timestamp():
mock_prisma: Final = _mock_prisma()
calls: Final = MagicMock()
tx_client: Final = mock_prisma.tx.return_value.__aenter__.return_value
deprecated_at: Final = datetime(2026, 10, 1, tzinfo=timezone.utc)
existing: Final = _mcp_tool_version("alpha", 2, "breaking", deprecated_at=deprecated_at)
updated: Final = existing.model_copy(
update={
"sunset_date": datetime(2026, 12, 31, tzinfo=timezone.utc),
"deprecation_note": "Switch to v3",
}
)
tx_client.execute_raw = AsyncMock(return_value=1)
tx_client.litellm_mcptoolversion.find_unique = AsyncMock(return_value=existing)
tx_client.litellm_mcptoolversion.update = AsyncMock(return_value=updated)
calls.attach_mock(tx_client.execute_raw, "lock")
calls.attach_mock(tx_client.litellm_mcptoolversion.find_unique, "lookup")
calls.attach_mock(tx_client.litellm_mcptoolversion.update, "update")
from litellm.proxy._experimental.mcp_server.db import set_mcp_tool_version_deprecation
result: Final = await set_mcp_tool_version_deprecation(
mock_prisma,
"test-server",
"alpha",
2,
MCPToolDeprecationRequest(
sunset_date=datetime(2026, 12, 31, tzinfo=timezone.utc),
deprecation_note="Switch to v3",
),
)
assert result == updated
tx_client.litellm_mcptoolversion.update.assert_awaited_once_with(
where={
"server_id_tool_name_version": {
"server_id": "test-server",
"tool_name": "alpha",
"version": 2,
}
},
data={
"deprecated_at": deprecated_at,
"sunset_date": datetime(2026, 12, 31, tzinfo=timezone.utc),
"deprecation_note": "Switch to v3",
},
)
assert [entry[0] for entry in calls.mock_calls] == ["lock", "lookup", "update"]
assert calls.mock_calls[0] == call.lock(_MCP_PIN_ADVISORY_LOCK_SQL, "mcp_pin:test-server")
mock_prisma.db.litellm_mcptoolversion.find_unique.assert_not_awaited()
mock_prisma.db.litellm_mcptoolversion.update.assert_not_awaited()
@pytest.mark.asyncio
async def test_set_mcp_tool_version_deprecation_clears_fields_or_returns_none_for_missing_row():
mock_prisma: Final = _mock_prisma()
tx_client: Final = mock_prisma.tx.return_value.__aenter__.return_value
existing: Final = _mcp_tool_version(
"alpha",
2,
"breaking",
deprecated_at=datetime(2026, 10, 1, tzinfo=timezone.utc),
)
tx_client.litellm_mcptoolversion.find_unique = AsyncMock(return_value=existing)
tx_client.litellm_mcptoolversion.update = AsyncMock(
return_value=existing.model_copy(update={"deprecated_at": None, "sunset_date": None, "deprecation_note": None})
)
from litellm.proxy._experimental.mcp_server.db import set_mcp_tool_version_deprecation
result: Final = await set_mcp_tool_version_deprecation(mock_prisma, "test-server", "alpha", 2, None)
assert result is not None
assert (result.deprecated_at, result.sunset_date, result.deprecation_note) == (None, None, None)
assert tx_client.litellm_mcptoolversion.update.call_args.kwargs["data"] == {
"deprecated_at": None,
"sunset_date": None,
"deprecation_note": None,
}
tx_client.litellm_mcptoolversion.find_unique = AsyncMock(return_value=None)
assert await set_mcp_tool_version_deprecation(mock_prisma, "test-server", "missing", 3, None) is None
tx_client.litellm_mcptoolversion.update.assert_awaited_once()
mock_prisma.db.litellm_mcptoolversion.find_unique.assert_not_awaited()

View file

@ -0,0 +1,359 @@
from datetime import datetime, timezone
from typing import Final
import pytest
from litellm.proxy._experimental.mcp_server.tool_versioning import (
PlannedToolVersion,
classify_tool_change,
plan_tool_versions,
)
from litellm.types.mcp_server.mcp_server_manager import (
MCPToolChange,
MCPToolChangeKind,
MCPToolVersion,
PinnedMCPTool,
)
def _tool(
description: str = "",
input_schema: dict[str, object] | None = None,
) -> PinnedMCPTool:
return PinnedMCPTool(description=description, input_schema=input_schema if input_schema is not None else {})
def _version(
tool_name: str,
version: int,
change_kind: MCPToolChangeKind,
description: str = "",
input_schema: dict[str, object] | None = None,
) -> MCPToolVersion:
return MCPToolVersion(
server_id="srv",
tool_name=tool_name,
version=version,
description=description,
input_schema=input_schema if input_schema is not None else {},
change_kind=change_kind,
created_at=datetime(2026, 10, 2, tzinfo=timezone.utc),
)
@pytest.mark.parametrize(
("previous", "current", "expected"),
[
(
_tool("Before"),
_tool("After"),
(MCPToolChange(breaking=False, summary="Description changed"),),
),
(
_tool(input_schema={"properties": {"a": {"type": "string"}}}),
_tool(input_schema={"properties": {"a": {"type": "string"}}, "required": ["a"]}),
(MCPToolChange(breaking=True, summary='Parameter "a" is now required'),),
),
(
_tool(input_schema={"type": "object"}),
_tool(input_schema={"type": "object", "required": ["token"]}),
(MCPToolChange(breaking=True, summary='Parameter "token" is now required'),),
),
(
_tool(input_schema={"type": "object", "required": ["token"]}),
_tool(input_schema={"type": "object"}),
(MCPToolChange(breaking=False, summary='Parameter "token" is now optional'),),
),
(
_tool(input_schema={"properties": {"a": {"type": "string"}}}),
_tool(input_schema={"properties": {"b": {"type": "string"}}}),
(
MCPToolChange(breaking=True, summary='Removed parameter "a"'),
MCPToolChange(breaking=False, summary='Added optional parameter "b"'),
),
),
(
_tool(input_schema={"properties": {}}),
_tool(input_schema={"properties": {"a": {"type": "string"}}, "required": ["a"]}),
(MCPToolChange(breaking=True, summary='Added required parameter "a"'),),
),
(
_tool(input_schema={"properties": {"a": {"type": "string"}}}),
_tool(input_schema={"properties": {"a": {"type": "integer"}}}),
(
MCPToolChange(
breaking=True,
summary='Parameter "a" type changed from "string" to "integer"',
),
),
),
(
_tool(input_schema={"properties": {"a": {}}}),
_tool(input_schema={"properties": {"a": {"type": "string"}}}),
(MCPToolChange(breaking=True, summary='Parameter "a" type changed from any to "string"'),),
),
(
_tool(input_schema={"properties": {"a": {"type": "string", "enum": ["a"]}}}),
_tool(input_schema={"properties": {"a": {"type": "string", "enum": ["a", "b"]}}}),
(MCPToolChange(breaking=True, summary='Parameter "a" schema changed'),),
),
(
_tool(input_schema={"properties": {"a": {"type": "string", "description": "Before"}}}),
_tool(input_schema={"properties": {"a": {"type": "string", "description": "After"}}}),
(MCPToolChange(breaking=False, summary='Parameter "a" description changed'),),
),
(
_tool(input_schema={"properties": {"a": {"type": "string"}}, "required": ["a"]}),
_tool(input_schema={"properties": {"a": {"type": "string"}}}),
(MCPToolChange(breaking=False, summary='Parameter "a" is now optional'),),
),
(
_tool(input_schema={"properties": {"a": {"type": "string", "title": "Before"}}}),
_tool(input_schema={"properties": {"a": {"type": "string", "title": "After"}}}),
(MCPToolChange(breaking=False, summary='Parameter "a" description changed'),),
),
(
_tool(
input_schema={
"properties": {
"filter": {
"type": "object",
"properties": {"term": {"type": "string", "description": "Before"}},
}
}
}
),
_tool(
input_schema={
"properties": {
"filter": {"type": "object", "properties": {"term": {"type": "string", "description": "After"}}}
}
}
),
(MCPToolChange(breaking=False, summary='Parameter "filter" description changed'),),
),
(
_tool(
input_schema={"properties": {"filter": {"type": "object", "properties": {"term": {"type": "string"}}}}}
),
_tool(
input_schema={"properties": {"filter": {"type": "object", "properties": {"term": {"type": "integer"}}}}}
),
(MCPToolChange(breaking=True, summary='Parameter "filter" schema changed'),),
),
(
_tool(
input_schema={"properties": {"filter": {"type": "object", "properties": {"title": {"type": "string"}}}}}
),
_tool(input_schema={"properties": {"filter": {"type": "object", "properties": {}}}}),
(MCPToolChange(breaking=True, summary='Parameter "filter" schema changed'),),
),
(
_tool(
input_schema={
"properties": {"items": {"type": "array", "items": {"type": "string", "description": "Before"}}}
}
),
_tool(
input_schema={
"properties": {"items": {"type": "array", "items": {"type": "string", "description": "After"}}}
}
),
(MCPToolChange(breaking=False, summary='Parameter "items" description changed'),),
),
(
_tool(input_schema={"properties": None}),
_tool(input_schema={"properties": ["invalid"]}),
(),
),
(
_tool(input_schema={"type": "object"}),
_tool(input_schema={"type": "array"}),
(MCPToolChange(breaking=True, summary="Input schema changed"),),
),
(
_tool(input_schema={"type": "object", "description": "Before"}),
_tool(input_schema={"type": "object", "description": "After"}),
(MCPToolChange(breaking=False, summary="Input schema description changed"),),
),
(
_tool(input_schema={"type": "object", "title": "Before"}),
_tool(input_schema={"type": "object", "title": "After"}),
(MCPToolChange(breaking=False, summary="Input schema title changed"),),
),
(
_tool(input_schema={"type": "object", "$defs": {"filter": {"type": "string", "description": "Before"}}}),
_tool(input_schema={"type": "object", "$defs": {"filter": {"type": "string", "description": "After"}}}),
(MCPToolChange(breaking=False, summary="Input schema documentation changed"),),
),
(
_tool(input_schema={"type": "object", "$defs": {"filter": {"type": "string"}}}),
_tool(input_schema={"type": "object", "$defs": {"filter": {"type": "integer"}}}),
(MCPToolChange(breaking=True, summary="Input schema changed"),),
),
],
)
def test_classify_tool_change(
previous: PinnedMCPTool,
current: PinnedMCPTool,
expected: tuple[MCPToolChange, ...],
) -> None:
assert classify_tool_change(previous, current) == expected
def test_schema_draft_change_is_breaking_and_recorded() -> None:
previous_schema: Final = {"$schema": "http://json-schema.org/draft-07/schema#"}
current: Final = _tool(input_schema={"$schema": "https://json-schema.org/draft/2020-12/schema"})
expected_change: Final = MCPToolChange(breaking=True, summary="Input schema changed")
previous: Final = _tool(input_schema=previous_schema)
latest: Final = {"tool": _version("tool", 3, "initial", input_schema=previous_schema)}
assert classify_tool_change(previous, current) == (expected_change,)
assert plan_tool_versions(latest, {"tool": current}) == (
PlannedToolVersion("tool", 4, current, "breaking", (expected_change,)),
)
def test_classify_tool_change_reports_type_changes_before_schema_changes():
previous: Final = _tool(
description="Before",
input_schema={
"properties": {
"z": {"type": "string"},
"a": {"type": "string", "enum": ["a"]},
},
"type": "object",
},
)
current: Final = _tool(
description="After",
input_schema={
"properties": {
"z": {"type": "integer"},
"a": {"type": "integer", "enum": ["b"]},
},
"type": "array",
},
)
assert classify_tool_change(previous, current) == (
MCPToolChange(breaking=False, summary="Description changed"),
MCPToolChange(
breaking=True,
summary='Parameter "a" type changed from "string" to "integer"',
),
MCPToolChange(
breaking=True,
summary='Parameter "z" type changed from "string" to "integer"',
),
MCPToolChange(breaking=True, summary="Input schema changed"),
)
def test_plan_tool_versions_creates_initial_versions_and_sorts_by_name():
snapshot: Final = {"z": _tool("Z"), "a": _tool("A")}
assert plan_tool_versions({}, snapshot) == (
PlannedToolVersion("a", 1, _tool("A"), "initial", ()),
PlannedToolVersion("z", 1, _tool("Z"), "initial", ()),
)
def test_plan_tool_versions_skips_unchanged_tools():
latest: Final = {"a": _version("a", 3, "non_breaking", "A")}
assert plan_tool_versions(latest, {"a": _tool("A")}) == ()
@pytest.mark.parametrize(
("current", "expected_kind", "expected_change"),
[
(
_tool(input_schema={"properties": {"a": {"type": "integer"}}}),
"breaking",
MCPToolChange(
breaking=True,
summary='Parameter "a" type changed from "string" to "integer"',
),
),
(
_tool("After", input_schema={"properties": {"a": {"type": "string"}}}),
"non_breaking",
MCPToolChange(breaking=False, summary="Description changed"),
),
],
)
def test_plan_tool_versions_bumps_only_changed_tools(
current: PinnedMCPTool,
expected_kind: MCPToolChangeKind,
expected_change: MCPToolChange,
) -> None:
latest: Final = {
"a": _version("a", 4, "initial", input_schema={"properties": {"a": {"type": "string"}}}),
"stable": _version("stable", 2, "initial"),
}
snapshot: Final = {"a": current, "stable": _tool()}
assert plan_tool_versions(latest, snapshot) == (
PlannedToolVersion("a", 5, current, expected_kind, (expected_change,)),
)
def test_plan_tool_versions_marks_required_only_parameter_change_as_breaking():
latest: Final = {
"a": _version("a", 1, "initial", input_schema={"type": "object"}),
}
snapshot: Final = {
"a": _tool(input_schema={"type": "object", "required": ["token"]}),
}
planned: Final = plan_tool_versions(latest, snapshot)
assert planned[0].change_kind == "breaking"
def test_plan_tool_versions_marks_removed_tools_only_once_and_restores_changed_tools():
latest: Final = {
"gone": _version(
"gone",
1,
"initial",
input_schema={"properties": {"a": {"type": "string"}}},
)
}
assert plan_tool_versions(latest, {}) == (
PlannedToolVersion(
"gone",
2,
_tool(input_schema={"properties": {"a": {"type": "string"}}}),
"removed",
(MCPToolChange(breaking=True, summary="Tool removed"),),
),
)
assert plan_tool_versions({"gone": _version("gone", 2, "removed", "Before")}, {}) == ()
assert plan_tool_versions(
{
"gone": _version(
"gone",
2,
"removed",
input_schema={"properties": {"a": {"type": "string"}}},
)
},
{"gone": _tool(input_schema={"properties": {"a": {"type": "integer"}}})},
) == (
PlannedToolVersion(
"gone",
3,
_tool(input_schema={"properties": {"a": {"type": "integer"}}}),
"breaking",
(
MCPToolChange(breaking=False, summary="Tool restored"),
MCPToolChange(
breaking=True,
summary='Parameter "a" type changed from "string" to "integer"',
),
),
),
)

View file

@ -1,41 +1,38 @@
import asyncio
import json
import logging
import os
import sys
import types
import json
import logging
from collections.abc import Iterator, Mapping
from contextlib import ExitStack, contextmanager
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Final, List, Literal, Optional, cast
from typing import Final, Literal, cast
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from pydantic import BaseModel, TypeAdapter, ValidationError
from respx import MockRouter
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from prisma.errors import UniqueViolationError
from pydantic import BaseModel, TypeAdapter, ValidationError
from respx import MockRouter
import litellm
from litellm._uuid import uuid
from litellm.caching.caching import DualCache
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy.utils import ProxyLogging
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.models.access_group import LiteLLM_AccessGroupTable
from litellm.models.organization import LiteLLM_OrganizationTable
from litellm.models.team import LiteLLM_TeamTable
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy.management_endpoints import (
mcp_management_endpoints as mgmt_endpoints,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerConfig, MCPServerManager
from litellm.proxy._types import (
LiteLLM_ObjectPermissionTable,
LiteLLM_MCPServerTable,
LiteLLM_ObjectPermissionTable,
LitellmUserRoles,
MakeMCPServersPublicRequest,
MCPTransport,
@ -44,17 +41,26 @@ from litellm.proxy._types import (
UpdateMCPServerRequest,
UserAPIKeyAuth,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerConfig, MCPServerManager
from litellm.proxy.management_endpoints import (
mcp_management_endpoints as mgmt_endpoints,
)
from litellm.proxy.utils import ProxyLogging
from litellm.types.mcp import MCPAuth, MCPCredentials
from litellm.types.mcp_server.mcp_server_manager import MCPServer, PinnedMCPTool
from litellm.types.mcp_server.mcp_server_manager import (
MCPServer,
MCPToolDeprecationRequest,
MCPToolVersion,
PinMCPServerToolsRequest,
PinnedMCPTool,
)
def generate_mock_mcp_server_db_record(
server_id: Optional[str] = None,
server_id: str | None = None,
alias: str = "Test DB Server",
url: str = "https://db-server.example.com/mcp",
transport: str = "sse",
auth_type: Optional[str] = None,
auth_type: str | None = None,
) -> LiteLLM_MCPServerTable:
"""Generate a mock MCP server record from database"""
now = datetime.now()
@ -72,11 +78,11 @@ def generate_mock_mcp_server_db_record(
def generate_mock_mcp_server_config_record(
server_id: Optional[str] = None,
server_id: str | None = None,
name: str = "Test Config Server",
url: str = "https://config-server.example.com/mcp",
transport: str = "http",
auth_type: Optional[str] = None,
auth_type: str | None = None,
) -> MCPServer:
"""Generate a mock MCP server record from config.yaml"""
return MCPServer(
@ -107,7 +113,7 @@ def generate_mock_user_api_key_auth(
user_role: LitellmUserRoles = LitellmUserRoles.PROXY_ADMIN,
user_id: str = "test_user_id",
api_key: str = "test_api_key",
team_id: Optional[str] = None,
team_id: str | None = None,
) -> UserAPIKeyAuth:
"""Generate a mock UserAPIKeyAuth object"""
return UserAPIKeyAuth(
@ -118,7 +124,7 @@ def generate_mock_user_api_key_auth(
)
def generate_mock_team_record(team_id: str, team_alias: str, organization_id: str, mcp_servers: List[str]):
def generate_mock_team_record(team_id: str, team_alias: str, organization_id: str, mcp_servers: list[str]):
"""Generate a mock team record with object permissions"""
return MagicMock(
team_id=team_id,
@ -131,8 +137,8 @@ def generate_mock_team_record(team_id: str, team_alias: str, organization_id: st
def setup_mock_prisma_client(
mock_prisma_client: MagicMock,
team_records: List[MagicMock],
mcp_servers: List[LiteLLM_MCPServerTable],
team_records: list[MagicMock],
mcp_servers: list[LiteLLM_MCPServerTable],
):
"""Helper to set up a mock prisma client with proper async behavior"""
mock_prisma_client.db = MagicMock()
@ -219,9 +225,7 @@ async def test_mcp_publication_list_and_detail_derive_current_status(
@pytest.mark.parametrize("approval_status", ("pending_review", "rejected", "draft", "active"))
@pytest.mark.parametrize("strict", (False, True))
def test_mcp_publication_projection_excludes_unregistered_lifecycle_records(
approval_status: str, strict: bool
) -> None:
def test_mcp_publication_projection_excludes_unregistered_lifecycle_records(approval_status: str, strict: bool) -> None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
record: Final = LiteLLM_MCPServerTable(
@ -2238,10 +2242,10 @@ class TestTemporaryMCPSessionEndpoints:
async def test_create_draft_mcp_server_prunes_drafts_past_their_lifetime(self):
"""Regression: abandoned OAuth sessions accumulated forever. Verified against a live
proxy, where 12 drafts aged past the lifetime were still present and a 13th was added."""
from litellm.proxy._experimental.mcp_server.db import create_draft_mcp_server
from datetime import timezone
from litellm.proxy._experimental.mcp_server.db import create_draft_mcp_server
now = datetime.now(timezone.utc)
stale_one = generate_mock_mcp_server_db_record(server_id="stale-1")
stale_one.updated_at = now - timedelta(hours=1)
@ -4512,8 +4516,7 @@ async def test_health_checks_probe_shared_servers_once_across_auth_contexts(
for server_id in ("shared", "first", "second", "denied")
}
routes: Final = {
server_id: respx_mock.get(server.url).respond(401)
for server_id, server in manager.registry.items()
server_id: respx_mock.get(server.url).respond(401) for server_id, server in manager.registry.items()
}
contexts: Final = [
UserAPIKeyAuth(
@ -4526,15 +4529,9 @@ async def test_health_checks_probe_shared_servers_once_across_auth_contexts(
for index, grants in enumerate((("shared", "first"), ("shared", "second")))
]
with (
patch.object(
mgmt_endpoints, "global_mcp_server_manager", manager
),
patch.object(
mcp_server_manager, "global_mcp_server_manager", manager
),
patch.object(
mgmt_endpoints, "build_effective_auth_contexts", AsyncMock(return_value=contexts)
),
patch.object(mgmt_endpoints, "global_mcp_server_manager", manager),
patch.object(mcp_server_manager, "global_mcp_server_manager", manager),
patch.object(mgmt_endpoints, "build_effective_auth_contexts", AsyncMock(return_value=contexts)),
patch("litellm.proxy.proxy_server.general_settings", {"user_mcp_management_mode": "restricted"}),
):
result: Final = await mgmt_endpoints.health_check_servers(
@ -4624,14 +4621,14 @@ async def test_health_reachability_requires_explicit_api_opt_in(
assert response.status_code == 200, response.text
rows: Final = (
[HealthResponse.model_validate_json(response.content)]
if detail else TypeAdapter(list[HealthResponse]).validate_json(response.content)
if detail
else TypeAdapter(list[HealthResponse]).validate_json(response.content)
)
expected_status: Final = "reachable" if flag == "true" else "unknown"
assert [row.model_dump() for row in rows] == [{"server_id": server.server_id, "status": expected_status}]
assert route.call_count == 1
legacy_parser: Final = (
LegacyHealthResponse.model_validate_json
if detail else TypeAdapter(list[LegacyHealthResponse]).validate_json
LegacyHealthResponse.model_validate_json if detail else TypeAdapter(list[LegacyHealthResponse]).validate_json
)
if flag == "true":
with pytest.raises(ValidationError, match="literal_error"):
@ -8549,7 +8546,11 @@ class TestPinMCPServerTools:
"""POST/DELETE /v1/mcp/server/{server_id}/pin snapshot and clear the served tool catalog."""
@staticmethod
def _pin_patches(stored, store_mock, manager):
def _pin_patches(
stored: LiteLLM_MCPServerTable | None,
store_mock: AsyncMock,
manager: MagicMock,
):
return (
patch("litellm.proxy.management_endpoints.mcp_management_endpoints.MCP_AVAILABLE", True),
patch(
@ -8560,14 +8561,18 @@ class TestPinMCPServerTools:
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(return_value=stored),
),
patch("litellm.proxy.management_endpoints.mcp_management_endpoints.set_mcp_server_pinned_tools", store_mock),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.set_mcp_server_pinned_tools", store_mock
),
patch("litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", manager),
patch("litellm.proxy._experimental.mcp_server.rest_endpoints.global_mcp_server_manager", manager),
patch.dict(
sys.modules,
{
"litellm.proxy.proxy_server": types.SimpleNamespace(
proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()), general_settings={}, llm_router=None
proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()),
general_settings={},
llm_router=None,
)
},
),
@ -8643,7 +8648,7 @@ class TestPinMCPServerTools:
assert listing["raw_headers"] == request.headers
assert listing["client_ip"] == "10.1.2.3"
assert store_mock.await_args.args[1:] == ("srv-1", expected)
assert store_mock.await_args.kwargs == {"touched_by": "admin"}
assert store_mock.await_args.kwargs == {"touched_by": "admin", "changelog": None}
manager.update_server.assert_awaited_once_with(stored)
manager.reload_servers_from_database.assert_awaited_once()
@ -8663,7 +8668,7 @@ class TestPinMCPServerTools:
assert result == {"server_id": "srv-1", "status": "unpinned"}
assert store_mock.await_args.args[1:] == ("srv-1", None)
assert store_mock.await_args.kwargs == {"touched_by": "admin"}
assert store_mock.await_args.kwargs == {"touched_by": "admin", "changelog": None}
manager._get_tools_from_server.assert_not_awaited()
manager.reload_servers_from_database.assert_awaited_once()
@ -8752,6 +8757,363 @@ class TestPinMCPServerTools:
store_mock.assert_not_awaited()
class TestMCPToolVersionEndpoints:
@pytest.mark.asyncio
async def test_pin_passes_trimmed_changelog_and_pin_without_body_remains_valid(self):
stored: Final = generate_mock_mcp_server_db_record(server_id="srv-1")
store_mock: Final = AsyncMock(return_value=stored)
manager: Final = TestPinMCPServerTools._manager([("list_notes", "List notes", {})])
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin")
with ExitStack() as stack:
for patcher in TestPinMCPServerTools._pin_patches(stored, store_mock, manager):
stack.enter_context(patcher)
result: Final = await mgmt_endpoints.pin_mcp_server_tools(
server_id="srv-1",
request=_make_mock_request(),
payload=PinMCPServerToolsRequest(changelog=" Updated schema "),
user_api_key_dict=admin,
)
assert result == {"list_notes": PinnedMCPTool(description="List notes", input_schema={})}
assert store_mock.await_args.kwargs == {"touched_by": "admin", "changelog": "Updated schema"}
@pytest.mark.asyncio
async def test_get_tool_versions_returns_rows_for_authorized_database_server(self):
prisma: Final = MagicMock()
server: Final = generate_mock_mcp_server_db_record(server_id="srv-1")
resolved: Final = mgmt_endpoints.ResolvedMCPServer(table=server, runtime=None, source="db")
version: Final = MCPToolVersion(
server_id="srv-1",
tool_name="tool_a",
version=1,
description="List notes",
input_schema={},
change_kind="initial",
created_at=datetime(2026, 10, 2, tzinfo=timezone.utc),
)
rows: Final = [version, version.model_copy(update={"tool_name": "tool_b"})]
list_mock: Final = AsyncMock(return_value=rows)
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
allowed_mock: Final = AsyncMock(return_value=None)
with (
patch.object(mgmt_endpoints, "get_prisma_client_or_throw", return_value=prisma),
patch.object(mgmt_endpoints, "resolve_mcp_server", AsyncMock(return_value=resolved)),
patch.object(mgmt_endpoints, "authorize_mcp_server", AsyncMock(return_value=resolved)),
patch.object(mgmt_endpoints, "list_mcp_tool_versions", list_mock),
patch.object(mgmt_endpoints, "_get_allowed_tool_names_for_server", allowed_mock),
patch.object(mgmt_endpoints, "_user_has_admin_view", return_value=True),
patch.object(mgmt_endpoints, "_is_restricted_virtual_key_request", return_value=False),
patch.object(mgmt_endpoints, "_get_user_mcp_management_mode", return_value="restricted"),
patch.object(mgmt_endpoints, "global_mcp_server_manager", MagicMock()),
):
result: Final = await mgmt_endpoints.fetch_mcp_server_tool_versions(
request=_make_mock_request(),
server_id="srv-1",
user_api_key_dict=admin,
)
assert result == rows
list_mock.assert_awaited_once_with(prisma, "srv-1")
allowed_mock.assert_awaited_once_with(server_id="srv-1", user_api_key_dict=admin)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("allowed", "expected_names"),
((["tool_a"], ["tool_a"]), ([], []), (None, ["tool_a", "tool_b"])),
)
async def test_get_tool_versions_filters_rows_by_tool_grants(
self,
allowed: list[str] | None,
expected_names: list[str],
) -> None:
prisma: Final = MagicMock()
server: Final = generate_mock_mcp_server_db_record(server_id="srv-1")
resolved: Final = mgmt_endpoints.ResolvedMCPServer(table=server, runtime=None, source="db")
rows: Final = [
MCPToolVersion(
server_id="srv-1",
tool_name="tool_a",
version=1,
description="Tool A",
input_schema={},
change_kind="initial",
created_at=datetime(2026, 10, 2, tzinfo=timezone.utc),
),
MCPToolVersion(
server_id="srv-1",
tool_name="tool_b",
version=1,
description="Tool B",
input_schema={},
change_kind="initial",
created_at=datetime(2026, 10, 2, tzinfo=timezone.utc),
),
]
list_mock: Final = AsyncMock(return_value=rows)
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
allowed_mock: Final = AsyncMock(return_value=allowed)
with (
patch.object(mgmt_endpoints, "get_prisma_client_or_throw", return_value=prisma),
patch.object(
mgmt_endpoints, "_resolve_and_authorize_mcp_server", AsyncMock(return_value=(resolved, False))
),
patch.object(mgmt_endpoints, "list_mcp_tool_versions", list_mock),
patch.object(mgmt_endpoints, "_get_allowed_tool_names_for_server", allowed_mock),
):
result: Final = await mgmt_endpoints.fetch_mcp_server_tool_versions(
request=_make_mock_request(),
server_id="srv-1",
user_api_key_dict=admin,
)
assert [version.tool_name for version in result] == expected_names
allowed_mock.assert_awaited_once_with(server_id="srv-1", user_api_key_dict=admin)
@pytest.mark.asyncio
async def test_get_tool_versions_returns_empty_for_authorized_config_server(self):
resolved: Final = mgmt_endpoints.ResolvedMCPServer(
table=generate_mock_mcp_server_db_record(server_id="config-server"),
runtime=None,
source="registry",
)
list_mock: Final = AsyncMock()
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
with (
patch.object(mgmt_endpoints, "get_prisma_client_or_throw", return_value=MagicMock()),
patch.object(
mgmt_endpoints,
"_resolve_and_authorize_mcp_server",
AsyncMock(return_value=(resolved, False)),
),
patch.object(mgmt_endpoints, "list_mcp_tool_versions", list_mock),
):
result: Final = await mgmt_endpoints.fetch_mcp_server_tool_versions(
request=_make_mock_request(),
server_id="config-server",
user_api_key_dict=admin,
)
assert result == []
list_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_tool_versions_has_same_non_admin_missing_server_response_as_fetch(self):
prisma: Final = MagicMock()
not_found: Final = HTTPException(
status_code=404,
detail={"error": "MCP Server with id missing not found"},
)
resolve_mock: Final = AsyncMock(return_value=None)
authorize_mock: Final = AsyncMock(side_effect=not_found)
user: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.INTERNAL_USER)
with (
patch.object(mgmt_endpoints, "get_prisma_client_or_throw", return_value=prisma),
patch.object(mgmt_endpoints, "resolve_mcp_server", resolve_mock),
patch.object(mgmt_endpoints, "authorize_mcp_server", authorize_mock),
patch.object(mgmt_endpoints, "_user_has_admin_view", return_value=False),
patch.object(mgmt_endpoints, "_is_restricted_virtual_key_request", return_value=False),
patch.object(mgmt_endpoints, "_get_user_mcp_management_mode", return_value="restricted"),
patch.object(mgmt_endpoints, "global_mcp_server_manager", MagicMock()),
):
with pytest.raises(HTTPException) as versions_error:
await mgmt_endpoints.fetch_mcp_server_tool_versions(
request=_make_mock_request(),
server_id="missing",
user_api_key_dict=user,
)
with pytest.raises(HTTPException) as server_error:
await mgmt_endpoints.fetch_mcp_server(
request=_make_mock_request(),
server_id="missing",
user_api_key_dict=user,
)
assert (versions_error.value.status_code, versions_error.value.detail) == (
server_error.value.status_code,
server_error.value.detail,
)
assert versions_error.value.status_code == 404
assert authorize_mock.await_count == 2
@pytest.mark.asyncio
async def test_put_tool_version_deprecation_is_admin_only_and_returns_updated_version(self):
prisma: Final = MagicMock()
request: Final = MCPToolDeprecationRequest(
sunset_date=datetime(2026, 12, 31, tzinfo=timezone.utc),
deprecation_note="Use version 2",
)
version: Final = MCPToolVersion(
server_id="srv-1",
tool_name="list_notes",
version=1,
change_kind="initial",
created_at=datetime(2026, 10, 2, tzinfo=timezone.utc),
deprecated_at=datetime(2026, 10, 2, tzinfo=timezone.utc),
sunset_date=request.sunset_date,
deprecation_note=request.deprecation_note,
)
set_mock: Final = AsyncMock(return_value=version)
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
with (
patch.object(mgmt_endpoints, "get_prisma_client_or_throw", return_value=prisma),
patch.object(mgmt_endpoints, "set_mcp_tool_version_deprecation", set_mock),
):
result: Final = await mgmt_endpoints.set_tool_version_deprecation(
server_id="srv-1",
tool_name="list_notes",
version=1,
payload=request,
user_api_key_dict=admin,
)
assert result == version
set_mock.assert_awaited_once_with(prisma, "srv-1", "list_notes", 1, request)
@pytest.mark.asyncio
async def test_put_tool_version_deprecation_rejects_non_admin_and_unknown_versions(self):
non_admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.INTERNAL_USER)
with pytest.raises(HTTPException) as forbidden:
await mgmt_endpoints.set_tool_version_deprecation(
server_id="srv-1",
tool_name="list_notes",
version=1,
payload=MCPToolDeprecationRequest(),
user_api_key_dict=non_admin,
)
assert forbidden.value.status_code == 403
assert forbidden.value.detail == {"error": "Admin access required to update MCP tool version deprecation."}
with (
patch.object(mgmt_endpoints, "get_prisma_client_or_throw", return_value=MagicMock()),
patch.object(mgmt_endpoints, "set_mcp_tool_version_deprecation", AsyncMock(return_value=None)),
):
with pytest.raises(HTTPException) as missing:
await mgmt_endpoints.set_tool_version_deprecation(
server_id="srv-1",
tool_name="missing",
version=9,
payload=MCPToolDeprecationRequest(),
user_api_key_dict=generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert missing.value.status_code == 404
assert missing.value.detail == {"error": "MCP tool 'missing' version 9 not found for server 'srv-1'."}
@pytest.mark.asyncio
async def test_delete_tool_version_deprecation_is_admin_only_and_clears_metadata(self):
prisma: Final = MagicMock()
version: Final = MCPToolVersion(
server_id="srv-1",
tool_name="list_notes",
version=1,
change_kind="initial",
created_at=datetime(2026, 10, 2, tzinfo=timezone.utc),
)
clear_mock: Final = AsyncMock(return_value=version)
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
with (
patch.object(mgmt_endpoints, "get_prisma_client_or_throw", return_value=prisma),
patch.object(mgmt_endpoints, "set_mcp_tool_version_deprecation", clear_mock),
):
result: Final = await mgmt_endpoints.clear_tool_version_deprecation(
server_id="srv-1",
tool_name="list_notes",
version=1,
user_api_key_dict=admin,
)
assert result == version
clear_mock.assert_awaited_once_with(prisma, "srv-1", "list_notes", 1, None)
with pytest.raises(HTTPException) as forbidden:
await mgmt_endpoints.clear_tool_version_deprecation(
server_id="srv-1",
tool_name="list_notes",
version=1,
user_api_key_dict=generate_mock_user_api_key_auth(user_role=LitellmUserRoles.INTERNAL_USER),
)
assert forbidden.value.status_code == 403
@pytest.mark.asyncio
async def test_delete_tool_version_deprecation_returns_404_for_unknown_version(self):
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
with (
patch.object(mgmt_endpoints, "get_prisma_client_or_throw", return_value=MagicMock()),
patch.object(mgmt_endpoints, "set_mcp_tool_version_deprecation", AsyncMock(return_value=None)),
):
with pytest.raises(HTTPException) as missing:
await mgmt_endpoints.clear_tool_version_deprecation(
server_id="srv-1",
tool_name="missing",
version=9,
user_api_key_dict=admin,
)
assert missing.value.status_code == 404
assert missing.value.detail == {"error": "MCP tool 'missing' version 9 not found for server 'srv-1'."}
@pytest.mark.asyncio
async def test_pin_concurrent_version_insert_conflict_returns_409_without_updating_server_row(self):
prisma: Final = MagicMock()
tx_client: Final = MagicMock()
tx_client.execute_raw = AsyncMock()
tx_client.litellm_mcpservertable.find_unique = AsyncMock(return_value=MagicMock())
tx_client.litellm_mcpservertable.update = AsyncMock()
tx_client.litellm_mcptoolversion.find_many = AsyncMock(return_value=[])
tx_client.litellm_mcptoolversion.create_many = AsyncMock(
side_effect=UniqueViolationError({}, message="unique version")
)
tx: Final = MagicMock()
tx.__aenter__ = AsyncMock(return_value=tx_client)
tx.__aexit__ = AsyncMock(return_value=False)
prisma.tx = MagicMock(return_value=tx)
stored: Final = generate_mock_mcp_server_db_record(server_id="srv-1")
manager: Final = TestPinMCPServerTools._manager([("list_notes", "List notes", {})])
admin: Final = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin")
try:
with (
patch.object(mgmt_endpoints, "MCP_AVAILABLE", True),
patch.object(mgmt_endpoints, "get_prisma_client_or_throw", return_value=prisma),
patch.object(mgmt_endpoints, "get_mcp_server", AsyncMock(return_value=stored)),
patch.object(mgmt_endpoints, "global_mcp_server_manager", manager),
patch("litellm.proxy._experimental.mcp_server.rest_endpoints.global_mcp_server_manager", manager),
patch.dict(
sys.modules,
{
"litellm.proxy.proxy_server": types.SimpleNamespace(
proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()),
general_settings={},
llm_router=None,
)
},
),
):
with pytest.raises(HTTPException) as conflict:
await mgmt_endpoints.pin_mcp_server_tools(
server_id="srv-1",
request=_make_mock_request(),
user_api_key_dict=admin,
)
finally:
ProxyLogging._callback_capabilities_cache.clear()
assert conflict.value.status_code == 409
assert conflict.value.detail == {
"error": "Another pin of MCP server 'srv-1' recorded tool versions at the same time; retry the pin."
}
tx_client.litellm_mcptoolversion.create_many.assert_awaited_once()
tx_client.litellm_mcpservertable.update.assert_not_awaited()
@dataclass(frozen=True)
class _ResolutionEffects:
byok_store: AsyncMock = field(default_factory=AsyncMock)
@ -9406,14 +9768,14 @@ class TestMCPServerResolutionCharacterization:
second_id: Final = "lit3974_second_credential"
prisma, manager, caller = await self._resolution_case("db_runtime", "allowed", first_id)
ids: Final = (first_id, second_id)
rows: Final = tuple(
generate_mock_mcp_server_db_record(server_id=sid, alias=f"alias-{sid}") for sid in ids
)
rows: Final = tuple(generate_mock_mcp_server_db_record(server_id=sid, alias=f"alias-{sid}") for sid in ids)
prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=list(rows))
auth: Final = caller.model_copy(
update={"object_permission": LiteLLM_ObjectPermissionTable(
object_permission_id="lit3974_multiple_credentials", mcp_servers=list(ids)
)}
update={
"object_permission": LiteLLM_ObjectPermissionTable(
object_permission_id="lit3974_multiple_credentials", mcp_servers=list(ids)
)
}
)
manager.config_mcp_servers = {
**manager.config_mcp_servers,

View file

@ -0,0 +1,224 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as networking from "@/components/networking";
import type { MCPToolVersion } from "@/components/networking";
import { mcpServersKeys } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { MCPToolVersionsPanel } from "./MCPToolVersionsPanel";
vi.mock("@/components/networking", () => ({
getMCPToolVersions: vi.fn(),
pinMCPServerTools: vi.fn(),
setMCPToolVersionDeprecation: vi.fn(),
clearMCPToolVersionDeprecation: vi.fn(),
}));
const toolVersions: MCPToolVersion[] = [
{
server_id: "srv-1",
tool_name: "list_notes",
version: 2,
description: "List notes",
input_schema: {},
change_kind: "breaking",
changes: [{ breaking: true, summary: 'Parameter "limit" type changed from "string" to "integer"' }],
changelog: "Updated the limit parameter",
deprecated_at: "2026-10-02T00:00:00Z",
sunset_date: "2026-12-31T00:00:00Z",
deprecation_note: "Move to v3",
created_at: "2026-10-02T00:00:00Z",
created_by: "admin",
},
{
server_id: "srv-1",
tool_name: "list_notes",
version: 1,
description: "List notes",
input_schema: {},
change_kind: "initial",
changes: [],
changelog: null,
deprecated_at: null,
sunset_date: null,
deprecation_note: null,
created_at: "2026-10-01T00:00:00Z",
created_by: "admin",
},
{
server_id: "srv-1",
tool_name: "read_note",
version: 1,
description: "Read a note",
input_schema: {},
change_kind: "non_breaking",
changes: [{ breaking: false, summary: "Description changed" }],
changelog: null,
deprecated_at: null,
sunset_date: null,
deprecation_note: null,
created_at: "2026-10-01T00:00:00Z",
created_by: "admin",
},
];
const renderPanel = (isProxyAdmin = true, customHeaders?: Record<string, string>) =>
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })}>
<MCPToolVersionsPanel
serverId="srv-1"
accessToken="token"
isProxyAdmin={isProxyAdmin}
customHeaders={customHeaders}
/>
</QueryClientProvider>,
);
describe("MCPToolVersionsPanel", () => {
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(networking.getMCPToolVersions).mockResolvedValue(toolVersions);
vi.mocked(networking.pinMCPServerTools).mockResolvedValue({});
vi.mocked(networking.setMCPToolVersionDeprecation).mockResolvedValue(toolVersions[0]);
vi.mocked(networking.clearMCPToolVersionDeprecation).mockResolvedValue(toolVersions[0]);
});
it("shows breaking, non-breaking and deprecated sunset badges", async () => {
renderPanel();
expect(await screen.findByText("Breaking")).toBeInTheDocument();
expect(screen.getAllByText("Breaking").length).toBeGreaterThan(0);
expect(screen.getByText("Non-breaking")).toBeInTheDocument();
expect(screen.getByText("Deprecated, sunset 2026-12-31")).toBeInTheDocument();
});
it("expands version history newest first with changes and changelog", async () => {
renderPanel(false);
const toggle = await screen.findByRole("button", { name: "Show history for list_notes" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
await userEvent.click(toggle);
expect(screen.getByRole("button", { name: "Hide history for list_notes" })).toHaveAttribute(
"aria-expanded",
"true",
);
expect(screen.getByText('Parameter "limit" type changed from "string" to "integer"')).toBeInTheDocument();
expect(screen.getByText("Updated the limit parameter")).toBeInTheDocument();
expect(screen.getAllByRole("heading", { level: 4 }).map((heading) => heading.textContent)).toEqual(["v2", "v1"]);
expect(screen.getByText("Created 2026-10-02")).toBeInTheDocument();
});
it("hides pin and deprecation controls from non-admins", async () => {
renderPanel(false);
expect(await screen.findByRole("button", { name: "Show history for list_notes" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pin and record versions" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Show history for list_notes" }));
expect(screen.queryByRole("button", { name: "Deprecate list_notes v1" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit deprecation for list_notes v2" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove deprecation for list_notes v2" })).not.toBeInTheDocument();
});
it("sends the changelog when the admin pins tools", async () => {
vi.mocked(networking.getMCPToolVersions).mockResolvedValue([]);
renderPanel();
fireEvent.change(await screen.findByLabelText("Changelog"), {
target: { value: " Published first release " },
});
await userEvent.click(screen.getByRole("button", { name: "Pin and record versions" }));
expect(networking.pinMCPServerTools).toHaveBeenCalledWith(
"token",
"srv-1",
" Published first release ",
undefined,
);
});
it("forwards upstream headers when the admin pins tools", async () => {
vi.mocked(networking.getMCPToolVersions).mockResolvedValue([]);
const customHeaders: Record<string, string> = {
"x-mcp-weather-authorization": "Bearer t",
};
renderPanel(true, customHeaders);
await screen.findByLabelText("Changelog");
await userEvent.click(screen.getByRole("button", { name: "Pin and record versions" }));
expect(networking.pinMCPServerTools).toHaveBeenCalledWith("token", "srv-1", "", customHeaders);
});
it("invalidates all pinned catalog queries after pinning succeeds", async () => {
vi.mocked(networking.getMCPToolVersions).mockResolvedValue([]);
const invalidateQueries = vi.spyOn(QueryClient.prototype, "invalidateQueries");
renderPanel();
fireEvent.change(await screen.findByLabelText("Changelog"), {
target: { value: "Initial catalog" },
});
await userEvent.click(screen.getByRole("button", { name: "Pin and record versions" }));
await waitFor(() => expect(invalidateQueries).toHaveBeenCalledTimes(3));
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["mcpToolVersions", "srv-1"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["mcpTools", "srv-1"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: mcpServersKeys.all });
});
it("sends sunset dates at UTC midnight", async () => {
renderPanel();
await userEvent.click(await screen.findByRole("button", { name: "Show history for read_note" }));
await userEvent.click(screen.getByRole("button", { name: "Deprecate read_note v1" }));
fireEvent.change(screen.getByLabelText("Sunset date"), { target: { value: "2026-12-31" } });
fireEvent.change(screen.getByLabelText("Note"), { target: { value: "Move to v2" } });
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(networking.setMCPToolVersionDeprecation).toHaveBeenCalledWith(
"token",
{ serverId: "srv-1", toolName: "read_note", version: 1 },
{
sunset_date: "2026-12-31T00:00:00Z",
deprecation_note: "Move to v2",
},
);
});
it("prefills and edits an existing deprecation", async () => {
renderPanel();
await userEvent.click(await screen.findByRole("button", { name: "Show history for list_notes" }));
await userEvent.click(screen.getByRole("button", { name: "Edit deprecation for list_notes v2" }));
expect(screen.getByLabelText("Sunset date")).toHaveValue("2026-12-31");
expect(screen.getByLabelText("Note")).toHaveValue("Move to v3");
fireEvent.change(screen.getByLabelText("Sunset date"), { target: { value: "2027-01-15" } });
fireEvent.change(screen.getByLabelText("Note"), { target: { value: "Use v4" } });
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(networking.setMCPToolVersionDeprecation).toHaveBeenCalledWith(
"token",
{ serverId: "srv-1", toolName: "list_notes", version: 2 },
{
sunset_date: "2027-01-15T00:00:00Z",
deprecation_note: "Use v4",
},
);
});
it("shows loading separately from the empty state", () => {
vi.mocked(networking.getMCPToolVersions).mockReturnValue(new Promise(() => {}));
renderPanel();
expect(screen.getByRole("status", { name: "Loading tool versions" })).toBeInTheDocument();
expect(
screen.queryByText("No versions recorded yet. Pin the tool list to start tracking versions."),
).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,379 @@
"use client";
import { type FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { mcpServersKeys } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import {
clearMCPToolVersionDeprecation,
getMCPToolVersions,
pinMCPServerTools,
setMCPToolVersionDeprecation,
type MCPToolVersion,
} from "@/components/networking";
type MCPToolChangeKind = MCPToolVersion["change_kind"];
const CHANGE_KIND_LABELS: Record<MCPToolChangeKind, string> = {
initial: "Initial",
non_breaking: "Non-breaking",
breaking: "Breaking",
removed: "Removed",
};
const CHANGE_KIND_VARIANTS: Record<MCPToolChangeKind, "destructive" | "secondary" | "outline"> = {
initial: "outline",
non_breaking: "secondary",
breaking: "destructive",
removed: "destructive",
};
function formatUTCDate(value: string): string {
const parsed: Date = new Date(value);
return Number.isNaN(parsed.getTime()) ? value.slice(0, 10) : parsed.toISOString().slice(0, 10);
}
function ChangeKindBadge({ kind }: { kind: MCPToolChangeKind }) {
return <Badge variant={CHANGE_KIND_VARIANTS[kind]}>{CHANGE_KIND_LABELS[kind]}</Badge>;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export interface MCPToolVersionsPanelProps {
serverId: string;
accessToken: string | null;
isProxyAdmin: boolean;
customHeaders?: Record<string, string>;
}
export function MCPToolVersionsPanel({
serverId,
accessToken,
isProxyAdmin,
customHeaders,
}: MCPToolVersionsPanelProps) {
const queryClient = useQueryClient();
const queryKey: readonly ["mcpToolVersions", string] = ["mcpToolVersions", serverId];
const [changelog, setChangelog] = useState("");
const [expandedTool, setExpandedTool] = useState<string | null>(null);
const [editingVersion, setEditingVersion] = useState<string | null>(null);
const [sunsetDate, setSunsetDate] = useState("");
const [deprecationNote, setDeprecationNote] = useState("");
const {
data: versions,
error: loadError,
isLoading,
} = useQuery({
queryKey,
queryFn: () => {
if (!accessToken) throw new Error("An access token is required to load tool versions.");
return getMCPToolVersions(accessToken, serverId);
},
enabled: Boolean(accessToken),
});
const invalidateVersions = async () => queryClient.invalidateQueries({ queryKey });
const invalidatePinnedToolQueries = async (): Promise<void> => {
await Promise.all([
invalidateVersions(),
queryClient.invalidateQueries({ queryKey: ["mcpTools", serverId] }),
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all }),
]);
};
const pinMutation = useMutation({
mutationFn: () => {
if (!accessToken) throw new Error("An access token is required to pin tools.");
return pinMCPServerTools(accessToken, serverId, changelog, customHeaders);
},
onSuccess: invalidatePinnedToolQueries,
});
const deprecationMutation = useMutation({
mutationFn: ({
toolName,
version,
sunsetDate: value,
note,
}: {
toolName: string;
version: number;
sunsetDate: string;
note: string;
}) => {
if (!accessToken) throw new Error("An access token is required to update deprecation.");
return setMCPToolVersionDeprecation(
accessToken,
{ serverId, toolName, version },
{
sunset_date: value ? `${value}T00:00:00Z` : null,
deprecation_note: note.trim() || null,
},
);
},
onSuccess: async () => {
setEditingVersion(null);
await invalidateVersions();
},
});
const clearDeprecationMutation = useMutation({
mutationFn: ({ toolName, version }: { toolName: string; version: number }) => {
if (!accessToken) throw new Error("An access token is required to clear deprecation.");
return clearMCPToolVersionDeprecation(accessToken, { serverId, toolName, version });
},
onSuccess: invalidateVersions,
});
const beginDeprecation = (version: MCPToolVersion): void => {
setEditingVersion(`${version.tool_name}-${version.version}`);
setSunsetDate(version.sunset_date ? formatUTCDate(version.sunset_date) : "");
setDeprecationNote(version.deprecation_note ?? "");
};
const submitDeprecation = (event: FormEvent<HTMLFormElement>, toolName: string, version: number): void => {
event.preventDefault();
const variables = { toolName, version, sunsetDate, note: deprecationNote };
deprecationMutation.mutate(variables);
};
if (isLoading) {
return (
<section aria-labelledby="mcp-tool-versions-title" className="mt-4">
<h2 id="mcp-tool-versions-title" className="mb-3 text-lg font-semibold">
Tool versions
</h2>
<div role="status" aria-label="Loading tool versions" className="space-y-3">
<div className="space-y-2 rounded-lg border p-4">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-2/3" />
</div>
<div className="space-y-2 rounded-lg border p-4">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-2/3" />
</div>
</div>
</section>
);
}
if (loadError || !accessToken) {
return (
<section aria-labelledby="mcp-tool-versions-title" className="mt-4">
<h2 id="mcp-tool-versions-title" className="mb-3 text-lg font-semibold">
Tool versions
</h2>
<Alert variant="destructive">
<AlertTitle>Could not load tool versions</AlertTitle>
<AlertDescription>
{loadError ? errorMessage(loadError) : "An access token is required to load tool versions."}
</AlertDescription>
</Alert>
</section>
);
}
const orderedVersions: MCPToolVersion[] = versions ?? [];
const toolNames: string[] = [...new Set(orderedVersions.map((version) => version.tool_name))].sort((a, b) =>
a.localeCompare(b),
);
return (
<section aria-labelledby="mcp-tool-versions-title" className="mt-4 space-y-4">
<h2 id="mcp-tool-versions-title" className="text-lg font-semibold">
Tool versions
</h2>
{isProxyAdmin && (
<Card className="gap-3 p-4">
<label htmlFor="mcp-tool-version-changelog" className="text-sm font-medium">
Changelog
</label>
<Textarea
id="mcp-tool-version-changelog"
value={changelog}
onChange={(event) => setChangelog(event.currentTarget.value)}
rows={3}
/>
<p className="text-xs text-muted-foreground">
tools/list serves the pinned tool list until the next pin. Each changed tool gets a new version.
</p>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" disabled={pinMutation.isPending} onClick={() => pinMutation.mutate()}>
{pinMutation.isPending ? "Pinning..." : "Pin and record versions"}
</Button>
{pinMutation.error && (
<p role="alert" className="text-sm text-destructive">
{errorMessage(pinMutation.error)}
</p>
)}
</div>
</Card>
)}
{deprecationMutation.error && (
<Alert variant="destructive">
<AlertTitle>Could not update deprecation</AlertTitle>
<AlertDescription>{errorMessage(deprecationMutation.error)}</AlertDescription>
</Alert>
)}
{clearDeprecationMutation.error && (
<Alert variant="destructive">
<AlertTitle>Could not remove deprecation</AlertTitle>
<AlertDescription>{errorMessage(clearDeprecationMutation.error)}</AlertDescription>
</Alert>
)}
{orderedVersions.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-card p-8 text-center">
<p className="text-sm text-muted-foreground">
No versions recorded yet. Pin the tool list to start tracking versions.
</p>
</div>
) : (
<div className="space-y-3">
{toolNames.map((toolName) => {
const history: MCPToolVersion[] = orderedVersions
.filter((version) => version.tool_name === toolName)
.sort((left, right) => right.version - left.version);
const latest: MCPToolVersion | undefined = history[0];
if (!latest) return null;
const isExpanded: boolean = expandedTool === toolName;
return (
<Card key={toolName} aria-label={`${toolName} tool versions`} className="gap-3 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium">{toolName}</h3>
<Badge variant="outline">v{latest.version}</Badge>
<ChangeKindBadge kind={latest.change_kind} />
{latest.deprecated_at && (
<Badge variant="destructive">
Deprecated
{latest.sunset_date ? `, sunset ${formatUTCDate(latest.sunset_date)}` : ""}
</Badge>
)}
</div>
<Button
type="button"
variant="outline"
aria-expanded={isExpanded}
aria-controls={`mcp-tool-history-${toolName}`}
onClick={() => setExpandedTool(isExpanded ? null : toolName)}
>
{isExpanded ? `Hide history for ${toolName}` : `Show history for ${toolName}`}
</Button>
</div>
{isExpanded && (
<div id={`mcp-tool-history-${toolName}`} className="space-y-3 border-t pt-3">
{history.map((version) => {
const versionKey: string = `${toolName}-${version.version}`;
return (
<article key={versionKey} className="space-y-2 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<h4 className="font-medium">v{version.version}</h4>
<ChangeKindBadge kind={version.change_kind} />
<span className="text-xs text-muted-foreground">
Created {formatUTCDate(version.created_at)}
</span>
<span className="text-xs text-muted-foreground">
Created by {version.created_by ?? "Unknown"}
</span>
</div>
{version.changes.length > 0 && (
<ul className="space-y-1 text-sm">
{version.changes.map((change) => (
<li
key={`${versionKey}-${change.summary}`}
className="flex flex-wrap items-center gap-2"
>
<span>{change.summary}</span>
{change.breaking && <Badge variant="destructive">Breaking</Badge>}
</li>
))}
</ul>
)}
{version.changelog && (
<p className="text-sm">
Changelog: <span className="text-muted-foreground">{version.changelog}</span>
</p>
)}
{version.deprecated_at && (
<div className="space-y-1 text-sm">
<p>Deprecated {formatUTCDate(version.deprecated_at)}</p>
{version.sunset_date && <p>Sunset {formatUTCDate(version.sunset_date)}</p>}
{version.deprecation_note && <p>{version.deprecation_note}</p>}
</div>
)}
{isProxyAdmin && (
<div className="space-y-3">
{version.deprecated_at ? (
<div className="flex flex-wrap gap-3">
<Button type="button" variant="outline" onClick={() => beginDeprecation(version)}>
Edit deprecation for {toolName} v{version.version}
</Button>
<Button
type="button"
variant="outline"
disabled={clearDeprecationMutation.isPending}
onClick={() =>
clearDeprecationMutation.mutate({ toolName, version: version.version })
}
>
Remove deprecation for {toolName} v{version.version}
</Button>
</div>
) : (
<Button type="button" variant="outline" onClick={() => beginDeprecation(version)}>
Deprecate {toolName} v{version.version}
</Button>
)}
{editingVersion === versionKey && (
<form
className="grid gap-3 sm:max-w-md"
onSubmit={(event) => submitDeprecation(event, toolName, version.version)}
>
<label htmlFor={`sunset-date-${versionKey}`} className="text-sm font-medium">
Sunset date
</label>
<Input
id={`sunset-date-${versionKey}`}
type="date"
value={sunsetDate}
onChange={(event) => setSunsetDate(event.currentTarget.value)}
/>
<label htmlFor={`deprecation-note-${versionKey}`} className="text-sm font-medium">
Note
</label>
<Input
id={`deprecation-note-${versionKey}`}
value={deprecationNote}
onChange={(event) => setDeprecationNote(event.currentTarget.value)}
/>
<Button type="submit" disabled={deprecationMutation.isPending}>
{deprecationMutation.isPending ? "Saving..." : "Save"}
</Button>
</form>
)}
</div>
)}
</article>
);
})}
</div>
)}
</Card>
);
})}
</div>
)}
</section>
);
}

View file

@ -10,7 +10,12 @@ import type { MCPServer } from "@/components/mcp_tools/types";
import { mcpServersKeys } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
vi.mock(".", () => ({
MCPToolsViewer: () => <div>tools viewer</div>,
MCPToolsViewer: ({ canManageVersions }: { canManageVersions?: boolean }) => (
<div>
tools viewer
{canManageVersions !== undefined && <span>{String(canManageVersions)}</span>}
</div>
),
}));
vi.mock("./mcp_server_edit", () => ({
@ -29,6 +34,10 @@ vi.mock("@/components/networking", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/networking")>()),
fetchMCPServerUserCredentials: vi.fn(),
revokeMCPServerUserCredential: vi.fn(),
getMCPToolVersions: vi.fn().mockResolvedValue([]),
pinMCPServerTools: vi.fn(),
setMCPToolVersionDeprecation: vi.fn(),
clearMCPToolVersionDeprecation: vi.fn(),
}));
const baseServer = {
@ -131,6 +140,7 @@ describe("MCPServerView", () => {
await userEvent.click(screen.getByRole("tab", { name: "MCP Tools" }));
expect(await screen.findByText("tools viewer")).toBeInTheDocument();
expect(screen.getByText("true")).toBeInTheDocument();
});
it("shows the read-only settings summary before editing", async () => {

View file

@ -232,6 +232,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
userID={userID}
serverAlias={mcpServer.alias}
extraHeaders={mcpServer.extra_headers}
canManageVersions={canEdit}
/>
</TabsContent>

View file

@ -12,6 +12,7 @@ import {
getMcpOAuthMode,
} from "@/components/mcp_tools/types";
import { listMCPTools, callMCPTool, getMCPOAuthUserCredentialStatus } from "@/components/networking";
import { MCPToolVersionsPanel } from "./MCPToolVersionsPanel";
import { isTokenValid, getToken, removeToken } from "@/utils/mcpTokenStore";
import { sanitizeMcpAliasForHeader, buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils";
import { useToolsOAuthFlow } from "@/hooks/useToolsOAuthFlow";
@ -38,6 +39,7 @@ const MCPToolsViewer = ({
userID,
serverAlias,
extraHeaders,
canManageVersions,
}: MCPToolsViewerProps) => {
const [selectedTool, setSelectedTool] = useState<MCPTool | null>(null);
const [toolResult, setToolResult] = useState<MCPContent[] | null>(null);
@ -580,6 +582,14 @@ const MCPToolsViewer = ({
</div>
</div>
</Card>
{canManageVersions !== undefined && (
<MCPToolVersionsPanel
serverId={serverId}
accessToken={accessToken}
isProxyAdmin={canManageVersions}
customHeaders={buildCustomHeaders()}
/>
)}
</div>
);
};

View file

@ -405,6 +405,7 @@ export interface MCPToolsViewerProps {
userID: string | null;
serverAlias?: string | null;
extraHeaders?: string[] | null;
canManageVersions?: boolean;
}
export const MCP_REACHABLE_DESCRIPTION = "Server responded. Authentication and tools were not checked";

View file

@ -157,6 +157,30 @@ describe("modelInfoCall", () => {
});
});
describe("pinMCPServerTools", () => {
it("forwards custom upstream headers", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue(""),
} as any);
global.fetch = mockFetch as any;
await Networking.pinMCPServerTools("token", "srv-1", undefined, {
"x-mcp-weather-authorization": "Bearer t",
});
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/v1/mcp/server/srv-1/pin"),
expect.objectContaining({
headers: expect.objectContaining({
"x-mcp-weather-authorization": "Bearer t",
}),
}),
);
});
});
describe("UI config and public endpoints", () => {
const originalFetch = global.fetch;

View file

@ -4791,6 +4791,54 @@ export const deleteMCPServer = async (accessToken: string, serverId: string) =>
}
};
export type MCPToolVersion = components["schemas"]["MCPToolVersion"];
export type MCPToolDeprecationRequest = components["schemas"]["MCPToolDeprecationRequest"];
export type PinMCPServerToolsRequest = components["schemas"]["PinMCPServerToolsRequest"];
export type MCPToolVersionIdentity = {
serverId: string;
toolName: string;
version: number;
};
export const getMCPToolVersions = async (accessToken: string, serverId: string): Promise<MCPToolVersion[]> =>
apiClient.get<MCPToolVersion[]>(`/v1/mcp/server/${encodeURIComponent(serverId)}/tool-versions`, { accessToken });
export const pinMCPServerTools = async (
accessToken: string,
serverId: string,
changelog?: string,
customHeaders?: Record<string, string>,
): Promise<Record<string, components["schemas"]["PinnedMCPTool"]>> => {
const body: PinMCPServerToolsRequest = changelog?.trim() ? { changelog: changelog.trim() } : {};
return apiClient.post<Record<string, components["schemas"]["PinnedMCPTool"]>>(
`/v1/mcp/server/${encodeURIComponent(serverId)}/pin`,
{ accessToken, body, headers: customHeaders },
);
};
export const setMCPToolVersionDeprecation = async (
accessToken: string,
identity: MCPToolVersionIdentity,
deprecation: MCPToolDeprecationRequest,
): Promise<MCPToolVersion> => {
const { serverId, toolName, version } = identity;
return apiClient.put<MCPToolVersion>(
`/v1/mcp/server/${encodeURIComponent(serverId)}/tools/${encodeURIComponent(toolName)}/versions/${version}/deprecation`,
{ accessToken, body: deprecation },
);
};
export const clearMCPToolVersionDeprecation = async (
accessToken: string,
identity: MCPToolVersionIdentity,
): Promise<MCPToolVersion> => {
const { serverId, toolName, version } = identity;
return apiClient.delete<MCPToolVersion>(
`/v1/mcp/server/${encodeURIComponent(serverId)}/tools/${encodeURIComponent(toolName)}/versions/${version}/deprecation`,
{ accessToken },
);
};
export const fetchMCPToolsets = async (accessToken: string): Promise<any[]> => {
try {
return await apiClient.get(`/v1/mcp/toolset`, { accessToken });

View file

@ -20966,6 +20966,50 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/mcp/server/{server_id}/tool-versions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Fetch Mcp Server Tool Versions
* @description Returns the version history of the tools recorded for an MCP server.
*/
get: operations["fetch_mcp_server_tool_versions_v1_mcp_server__server_id__tool_versions_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/mcp/server/{server_id}/tools/{tool_name}/versions/{version}/deprecation": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Set Tool Version Deprecation
* @description Set deprecation and sunset metadata for an MCP tool version.
*/
put: operations["set_tool_version_deprecation_v1_mcp_server__server_id__tools__tool_name__versions__version__deprecation_put"];
post?: never;
/**
* Clear Tool Version Deprecation
* @description Clear deprecation and sunset metadata for an MCP tool version.
*/
delete: operations["clear_tool_version_deprecation_v1_mcp_server__server_id__tools__tool_name__versions__version__deprecation_delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/mcp/server/{server_id}/user-credential": {
parameters: {
query?: never;
@ -37642,6 +37686,20 @@ export interface components {
/** Total */
total: number;
};
/** MCPToolChange */
MCPToolChange: {
/** Breaking */
breaking: boolean;
/** Summary */
summary: string;
};
/** MCPToolDeprecationRequest */
MCPToolDeprecationRequest: {
/** Deprecation Note */
deprecation_note?: string | null;
/** Sunset Date */
sunset_date?: string | null;
};
/**
* MCPToolSearchSettings
* @description `litellm_settings.mcp_tool_search`: how the native `mcp_tool_search` virtual tool ranks the caller's tools.
@ -37685,6 +37743,49 @@ export interface components {
[key: string]: unknown;
};
};
/** MCPToolVersion */
MCPToolVersion: {
/**
* Change Kind
* @enum {string}
*/
change_kind: "initial" | "non_breaking" | "breaking" | "removed";
/** Changelog */
changelog?: string | null;
/**
* Changes
* @default []
*/
changes: components["schemas"]["MCPToolChange"][];
/**
* Created At
* Format: date-time
*/
created_at: string;
/** Created By */
created_by?: string | null;
/** Deprecated At */
deprecated_at?: string | null;
/** Deprecation Note */
deprecation_note?: string | null;
/**
* Description
* @default
*/
description: string;
/** Input Schema */
input_schema?: {
[key: string]: unknown;
};
/** Server Id */
server_id: string;
/** Sunset Date */
sunset_date?: string | null;
/** Tool Name */
tool_name: string;
/** Version */
version: number;
};
/** MCPToolsetTool */
MCPToolsetTool: {
/** Server Id */
@ -40665,6 +40766,11 @@ export interface components {
* @enum {string}
*/
PiiEntityType: "CREDIT_CARD" | "CRYPTO" | "DATE_TIME" | "EMAIL_ADDRESS" | "IBAN_CODE" | "IP_ADDRESS" | "NRP" | "LOCATION" | "PERSON" | "PHONE_NUMBER" | "MEDICAL_LICENSE" | "URL" | "MAC_ADDRESS" | "UUID" | "US_BANK_NUMBER" | "US_DRIVER_LICENSE" | "US_ITIN" | "US_PASSPORT" | "US_SSN" | "US_MBI" | "US_NPI" | "UK_NHS" | "UK_NINO" | "UK_PASSPORT" | "UK_POSTCODE" | "UK_VEHICLE_REGISTRATION" | "UK_DRIVING_LICENCE" | "ES_NIF" | "ES_NIE" | "ES_PASSPORT" | "IT_FISCAL_CODE" | "IT_DRIVER_LICENSE" | "IT_VAT_CODE" | "IT_PASSPORT" | "IT_IDENTITY_CARD" | "PL_PESEL" | "SG_NRIC_FIN" | "SG_UEN" | "AU_ABN" | "AU_ACN" | "AU_TFN" | "AU_MEDICARE" | "IN_PAN" | "IN_AADHAAR" | "IN_VEHICLE_REGISTRATION" | "IN_VOTER" | "IN_PASSPORT" | "IN_GSTIN" | "FI_PERSONAL_IDENTITY_CODE" | "DE_TAX_ID" | "DE_TAX_NUMBER" | "DE_VAT_ID" | "DE_PASSPORT" | "DE_ID_CARD" | "DE_FUEHRERSCHEIN" | "DE_SOCIAL_SECURITY" | "DE_HEALTH_INSURANCE" | "DE_LANR" | "DE_BSNR" | "DE_KFZ" | "DE_HANDELSREGISTER" | "DE_PLZ" | "KR_RRN" | "KR_FRN" | "KR_PASSPORT" | "KR_DRIVER_LICENSE" | "KR_BRN" | "CA_SIN" | "SE_PERSONNUMMER" | "SE_ORGANISATIONSNUMMER" | "TH_TNIN" | "TR_NATIONAL_ID" | "TR_LICENSE_PLATE" | "NG_NIN" | "NG_VEHICLE_REGISTRATION" | "PH_TIN" | "PH_UMID" | "PH_PASSPORT" | "ZA_ID_NUMBER";
/** PinMCPServerToolsRequest */
PinMCPServerToolsRequest: {
/** Changelog */
changelog?: string | null;
};
/**
* PinnedMCPTool
* @description One tool of an admin-pinned catalog: the description and input schema tools/list keeps serving.
@ -79274,7 +79380,11 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody?: {
content: {
"application/json": components["schemas"]["PinMCPServerToolsRequest"] | null;
};
};
responses: {
/** @description Successful Response */
200: {
@ -79366,6 +79476,107 @@ export interface operations {
};
};
};
fetch_mcp_server_tool_versions_v1_mcp_server__server_id__tool_versions_get: {
parameters: {
query?: never;
header?: never;
path: {
server_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MCPToolVersion"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
set_tool_version_deprecation_v1_mcp_server__server_id__tools__tool_name__versions__version__deprecation_put: {
parameters: {
query?: never;
header?: never;
path: {
server_id: string;
tool_name: string;
version: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MCPToolDeprecationRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MCPToolVersion"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
clear_tool_version_deprecation_v1_mcp_server__server_id__tools__tool_name__versions__version__deprecation_delete: {
parameters: {
query?: never;
header?: never;
path: {
server_id: string;
tool_name: string;
version: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MCPToolVersion"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
store_mcp_user_credential_v1_mcp_server__server_id__user_credential_post: {
parameters: {
query?: never;