diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 469ea86ad4b..92b5fb6552f 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -4529,6 +4529,7 @@ class MCPServerManager: extra_headers: dict[str, str] | None = None, add_prefix: bool = True, raw_headers: dict[str, str] | None = None, + raise_on_error: bool = False, ) -> list[Prompt]: try: headers: Final = ( @@ -4561,6 +4562,8 @@ class MCPServerManager: items: Final = await self._prompt_discovery_cache.get(key, fetch) return self._create_prefixed_prompts(items, server, add_prefix=add_prefix) except Exception as error: + if raise_on_error: + raise_classified_list_failure(error, server.name, suppress_challenge=server.is_dcr_bridge) verbose_logger.warning("Failed to get prompts from server %s: %s", server.name, error) return [] @@ -4572,6 +4575,7 @@ class MCPServerManager: extra_headers: dict[str, str] | None = None, add_prefix: bool = True, raw_headers: dict[str, str] | None = None, + raise_on_error: bool = False, ) -> list[Resource]: try: headers: Final = ( @@ -4604,6 +4608,8 @@ class MCPServerManager: items: Final = await self._resource_discovery_cache.get(key, fetch) return self._create_prefixed_resources(items, server, add_prefix=add_prefix) except Exception as error: + if raise_on_error: + raise_classified_list_failure(error, server.name, suppress_challenge=server.is_dcr_bridge) verbose_logger.warning("Failed to get resources from server %s: %s", server.name, error) return [] @@ -4615,6 +4621,7 @@ class MCPServerManager: extra_headers: dict[str, str] | None = None, add_prefix: bool = True, raw_headers: dict[str, str] | None = None, + raise_on_error: bool = False, ) -> list[ResourceTemplate]: try: headers: Final = ( @@ -4647,6 +4654,8 @@ class MCPServerManager: items: Final = await self._template_discovery_cache.get(key, fetch) return self._create_prefixed_resource_templates(items, server, add_prefix=add_prefix) except Exception as error: + if raise_on_error: + raise_classified_list_failure(error, server.name, suppress_challenge=server.is_dcr_bridge) verbose_logger.warning("Failed to get resource_templates from server %s: %s", server.name, error) return [] diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 6a0ab5bdec5..31704aed0e4 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -5,13 +5,13 @@ from dataclasses import dataclass from datetime import datetime from traceback import walk_tb from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Literal +from typing import TYPE_CHECKING, Annotated, Any, Final, Literal from uuid import uuid4 import anyio import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request, status -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from starlette.datastructures import Headers from litellm._logging import verbose_logger @@ -169,12 +169,16 @@ def _known_connection_error_message(exc: BaseException, url: str | None, timeout if MCP_AVAILABLE: from mcp.shared.exceptions import McpError + from mcp.types import Prompt, Resource, ResourceTemplate from mcp.types import Tool as MCPTool from litellm.experimental_mcp_client.client import MCPClient, as_mcp_read_timeout from litellm.llms.litellm_proxy.skills.skill_search import ( DEFAULT_SKILL_SEARCH_TOP_K, ) + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCPRequestHandler, + ) from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES, global_mcp_server_manager, @@ -195,6 +199,21 @@ if MCP_AVAILABLE: fire_mcp_tool_call_failure_logging, ) + class ListMCPPromptsRestAPIResponse(BaseModel): + prompts: list[Prompt] + + class ListMCPResourcesRestAPIResponse(BaseModel): + resources: list[Resource] + resource_templates: list[ResourceTemplate] + + @dataclass(frozen=True, slots=True) + class _CatalogServerContext: + server: MCPServer + user_api_key_dict: UserAPIKeyAuth + mcp_auth_header: dict[str, str] | str | None + extra_headers: dict[str, str] | None + raw_headers: dict[str, str] + ######################################################## ############ MCP Server REST API Routes ################# async def _safe_fire_mcp_tool_call_logging( @@ -1055,6 +1074,107 @@ if MCP_AVAILABLE: "message": f"An unexpected error occurred: {e}", } + async def _resolve_catalog_server_context( + request: Request, + server_id: str, + user_api_key_dict: UserAPIKeyAuth, + ) -> _CatalogServerContext: + acting_auth: Final = await acting_user_auth(user_api_key_dict) + _, canonical_server_id = await _resolve_allowed_mcp_servers_with_ip_filter(request, acting_auth, server_id) + server: Final = global_mcp_server_manager.get_mcp_server_by_id(canonical_server_id) + if server is None: + raise HTTPException( + status_code=404, + detail={ + "error": "server_not_found", + "message": f"MCP server '{server_id}' was not found", + }, + ) + mcp_auth_header, mcp_server_auth_headers, raw_headers = _extract_mcp_headers_from_request( + request, MCPRequestHandler + ) + return _CatalogServerContext( + server=server, + user_api_key_dict=acting_auth, + mcp_auth_header=_get_server_auth_header(server, mcp_server_auth_headers, mcp_auth_header), + extra_headers=await _get_user_oauth_extra_headers(server, acting_auth), + raw_headers=raw_headers, + ) + + def _catalog_list_http_exception( + error: MCPServerListError, server: MCPServer, catalog: Literal["prompts", "resources"] + ) -> HTTPException: + fault: Final = classify_list_exception(error) + verbose_logger.info("Listing %s from %s failed with a %s fault", catalog, server.name, fault.tag) + return HTTPException( + status_code=list_fault_http_status(fault), + detail={ + "error": fault.tag, + "message": f"Failed to list {catalog} from server {get_server_prefix(server)}", + }, + ) + + @router.get("/prompts/list", dependencies=[Depends(user_api_key_auth)]) + async def list_prompts_rest_api( + request: Request, + server_id: Annotated[str, Query(description="The MCP server id, name, or alias to list prompts for")], + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + ) -> ListMCPPromptsRestAPIResponse: + """List the prompts one MCP server exposes, with names as the upstream server reports them. + + An upstream failure relays its classified HTTP status, the same as ``/mcp-rest/tools/list``.""" + context: Final = await _resolve_catalog_server_context(request, server_id, user_api_key_dict) + try: + prompts: Final = await global_mcp_server_manager.get_prompts_from_server( + context.server, + user_api_key_auth=context.user_api_key_dict, + mcp_auth_header=context.mcp_auth_header, + extra_headers=context.extra_headers, + add_prefix=False, + raw_headers=context.raw_headers, + raise_on_error=True, + ) + except MCPUpstreamAuthError as e: + raise _relay_upstream_auth_http_exception(e, request) from e + except MCPServerListError as e: + raise _catalog_list_http_exception(e, context.server, "prompts") from e + return ListMCPPromptsRestAPIResponse(prompts=prompts) + + @router.get("/resources/list", dependencies=[Depends(user_api_key_auth)]) + async def list_resources_rest_api( + request: Request, + server_id: Annotated[str, Query(description="The MCP server id, name, or alias to list resources for")], + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + ) -> ListMCPResourcesRestAPIResponse: + """List the resources and resource templates one MCP server exposes.""" + context: Final = await _resolve_catalog_server_context(request, server_id, user_api_key_dict) + try: + resources, resource_templates = await asyncio.gather( + global_mcp_server_manager.get_resources_from_server( + context.server, + user_api_key_auth=context.user_api_key_dict, + mcp_auth_header=context.mcp_auth_header, + extra_headers=context.extra_headers, + add_prefix=False, + raw_headers=context.raw_headers, + raise_on_error=True, + ), + global_mcp_server_manager.get_resource_templates_from_server( + context.server, + user_api_key_auth=context.user_api_key_dict, + mcp_auth_header=context.mcp_auth_header, + extra_headers=context.extra_headers, + add_prefix=False, + raw_headers=context.raw_headers, + raise_on_error=True, + ), + ) + except MCPUpstreamAuthError as e: + raise _relay_upstream_auth_http_exception(e, request) from e + except MCPServerListError as e: + raise _catalog_list_http_exception(e, context.server, "resources") from e + return ListMCPResourcesRestAPIResponse(resources=resources, resource_templates=resource_templates) + @router.post("/tools/call", dependencies=[Depends(user_api_key_auth)]) async def call_tool_rest_api( request: Request, diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 584b1e05b89..3571179cd08 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -21804,6 +21804,44 @@ "mcp_app": { "components": { "schemas": { + "Annotations": { + "additionalProperties": true, + "properties": { + "audience": { + "anyOf": [ + { + "items": { + "enum": [ + "user", + "assistant" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Audience" + }, + "priority": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Priority" + } + }, + "title": "Annotations", + "type": "object" + }, "HTTPValidationError": { "properties": { "detail": { @@ -21817,6 +21855,86 @@ "title": "HTTPValidationError", "type": "object" }, + "Icon": { + "additionalProperties": true, + "description": "An icon for display in user interfaces.", + "properties": { + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mimetype" + }, + "sizes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sizes" + }, + "src": { + "title": "Src", + "type": "string" + } + }, + "required": [ + "src" + ], + "title": "Icon", + "type": "object" + }, + "ListMCPPromptsRestAPIResponse": { + "properties": { + "prompts": { + "items": { + "$ref": "#/components/schemas/Prompt" + }, + "title": "Prompts", + "type": "array" + } + }, + "required": [ + "prompts" + ], + "title": "ListMCPPromptsRestAPIResponse", + "type": "object" + }, + "ListMCPResourcesRestAPIResponse": { + "properties": { + "resource_templates": { + "items": { + "$ref": "#/components/schemas/ResourceTemplate" + }, + "title": "Resource Templates", + "type": "array" + }, + "resources": { + "items": { + "$ref": "#/components/schemas/Resource" + }, + "title": "Resources", + "type": "array" + } + }, + "required": [ + "resources", + "resource_templates" + ], + "title": "ListMCPResourcesRestAPIResponse", + "type": "object" + }, "MCPCredentials": { "properties": { "audience": { @@ -22628,6 +22746,311 @@ "title": "NewMCPServerRequest", "type": "object" }, + "Prompt": { + "additionalProperties": true, + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "arguments": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/PromptArgument" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Arguments" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "icons": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Icon" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Icons" + }, + "name": { + "title": "Name", + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + } + }, + "required": [ + "name" + ], + "title": "Prompt", + "type": "object" + }, + "PromptArgument": { + "additionalProperties": true, + "description": "An argument for a prompt template.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "title": "Name", + "type": "string" + }, + "required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Required" + } + }, + "required": [ + "name" + ], + "title": "PromptArgument", + "type": "object" + }, + "Resource": { + "additionalProperties": true, + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/components/schemas/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "icons": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Icon" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Icons" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mimetype" + }, + "name": { + "title": "Name", + "type": "string" + }, + "size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Size" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "uri": { + "format": "uri", + "minLength": 1, + "title": "Uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "title": "Resource", + "type": "object" + }, + "ResourceTemplate": { + "additionalProperties": true, + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/components/schemas/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "icons": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Icon" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Icons" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mimetype" + }, + "name": { + "title": "Name", + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "uriTemplate": { + "title": "Uritemplate", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "title": "ResourceTemplate", + "type": "object" + }, "ValidationError": { "properties": { "ctx": { @@ -22799,6 +23222,106 @@ ] } }, + "/mcp-rest/prompts/list": { + "get": { + "description": "List the prompts one MCP server exposes, with names as the upstream server reports them.\n\nAn upstream failure relays its classified HTTP status, the same as ``/mcp-rest/tools/list``.", + "operationId": "list_prompts_rest_api_mcp_rest_prompts_list_get_2", + "parameters": [ + { + "description": "The MCP server id, name, or alias to list prompts for", + "in": "query", + "name": "server_id", + "required": true, + "schema": { + "description": "The MCP server id, name, or alias to list prompts for", + "title": "Server Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMCPPromptsRestAPIResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "List Prompts Rest Api", + "tags": [ + "mcp_app" + ] + } + }, + "/mcp-rest/resources/list": { + "get": { + "description": "List the resources and resource templates one MCP server exposes.", + "operationId": "list_resources_rest_api_mcp_rest_resources_list_get_2", + "parameters": [ + { + "description": "The MCP server id, name, or alias to list resources for", + "in": "query", + "name": "server_id", + "required": true, + "schema": { + "description": "The MCP server id, name, or alias to list resources for", + "title": "Server Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMCPResourcesRestAPIResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "List Resources Rest Api", + "tags": [ + "mcp_app" + ] + } + }, "/mcp-rest/test/connection": { "post": { "description": "Test if we can connect to the provided MCP server before adding it", @@ -31550,6 +32073,44 @@ "mcp_rest": { "components": { "schemas": { + "Annotations": { + "additionalProperties": true, + "properties": { + "audience": { + "anyOf": [ + { + "items": { + "enum": [ + "user", + "assistant" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Audience" + }, + "priority": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Priority" + } + }, + "title": "Annotations", + "type": "object" + }, "HTTPValidationError": { "properties": { "detail": { @@ -31563,6 +32124,86 @@ "title": "HTTPValidationError", "type": "object" }, + "Icon": { + "additionalProperties": true, + "description": "An icon for display in user interfaces.", + "properties": { + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mimetype" + }, + "sizes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sizes" + }, + "src": { + "title": "Src", + "type": "string" + } + }, + "required": [ + "src" + ], + "title": "Icon", + "type": "object" + }, + "ListMCPPromptsRestAPIResponse": { + "properties": { + "prompts": { + "items": { + "$ref": "#/components/schemas/Prompt" + }, + "title": "Prompts", + "type": "array" + } + }, + "required": [ + "prompts" + ], + "title": "ListMCPPromptsRestAPIResponse", + "type": "object" + }, + "ListMCPResourcesRestAPIResponse": { + "properties": { + "resource_templates": { + "items": { + "$ref": "#/components/schemas/ResourceTemplate" + }, + "title": "Resource Templates", + "type": "array" + }, + "resources": { + "items": { + "$ref": "#/components/schemas/Resource" + }, + "title": "Resources", + "type": "array" + } + }, + "required": [ + "resources", + "resource_templates" + ], + "title": "ListMCPResourcesRestAPIResponse", + "type": "object" + }, "MCPCredentials": { "properties": { "audience": { @@ -32374,6 +33015,311 @@ "title": "NewMCPServerRequest", "type": "object" }, + "Prompt": { + "additionalProperties": true, + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "arguments": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/PromptArgument" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Arguments" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "icons": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Icon" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Icons" + }, + "name": { + "title": "Name", + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + } + }, + "required": [ + "name" + ], + "title": "Prompt", + "type": "object" + }, + "PromptArgument": { + "additionalProperties": true, + "description": "An argument for a prompt template.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "title": "Name", + "type": "string" + }, + "required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Required" + } + }, + "required": [ + "name" + ], + "title": "PromptArgument", + "type": "object" + }, + "Resource": { + "additionalProperties": true, + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/components/schemas/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "icons": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Icon" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Icons" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mimetype" + }, + "name": { + "title": "Name", + "type": "string" + }, + "size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Size" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "uri": { + "format": "uri", + "minLength": 1, + "title": "Uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "title": "Resource", + "type": "object" + }, + "ResourceTemplate": { + "additionalProperties": true, + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + }, + "annotations": { + "anyOf": [ + { + "$ref": "#/components/schemas/Annotations" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "icons": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Icon" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Icons" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mimetype" + }, + "name": { + "title": "Name", + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "uriTemplate": { + "title": "Uritemplate", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "title": "ResourceTemplate", + "type": "object" + }, "ValidationError": { "properties": { "ctx": { @@ -32417,6 +33363,106 @@ } }, "paths": { + "/mcp-rest/prompts/list": { + "get": { + "description": "List the prompts one MCP server exposes, with names as the upstream server reports them.\n\nAn upstream failure relays its classified HTTP status, the same as ``/mcp-rest/tools/list``.", + "operationId": "list_prompts_rest_api_mcp_rest_prompts_list_get", + "parameters": [ + { + "description": "The MCP server id, name, or alias to list prompts for", + "in": "query", + "name": "server_id", + "required": true, + "schema": { + "description": "The MCP server id, name, or alias to list prompts for", + "title": "Server Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMCPPromptsRestAPIResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "List Prompts Rest Api", + "tags": [ + "mcp_rest" + ] + } + }, + "/mcp-rest/resources/list": { + "get": { + "description": "List the resources and resource templates one MCP server exposes.", + "operationId": "list_resources_rest_api_mcp_rest_resources_list_get", + "parameters": [ + { + "description": "The MCP server id, name, or alias to list resources for", + "in": "query", + "name": "server_id", + "required": true, + "schema": { + "description": "The MCP server id, name, or alias to list resources for", + "title": "Server Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMCPResourcesRestAPIResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "List Resources Rest Api", + "tags": [ + "mcp_rest" + ] + } + }, "/mcp-rest/test/connection": { "post": { "description": "Test if we can connect to the provided MCP server before adding it", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 76a51627d0c..1f0a885a0b7 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -529,6 +529,8 @@ class LiteLLMRoutes(enum.Enum): "/mcp/tools/call", "/mcp-rest/tools/list", "/mcp-rest/tools/call", + "/mcp-rest/prompts/list", + "/mcp-rest/resources/list", "/v1/mcp/tools", "/introspect", ] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index d449ad06642..1462dde8cc2 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -3931,6 +3931,39 @@ class TestMCPServerManager: mock_client.list_resource_templates.assert_awaited_once() assert result == expected_templates + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("manager_method", "client_method"), + [ + ("get_prompts_from_server", "list_prompts"), + ("get_resources_from_server", "list_resources"), + ("get_resource_templates_from_server", "list_resource_templates"), + ], + ) + async def test_catalog_fetch_failure_is_swallowed_unless_raise_on_error(self, manager_method, client_method): + """Catalog fetches stay best-effort for the MCP protocol aggregate (empty list) but a + single-server caller that opts in gets the classified fault instead of empty-success.""" + manager = MCPServerManager() + server = MCPServer( + server_id="server-1", + name="alias-server", + alias="alias-server", + server_name="alias-server", + url="https://example.com", + transport=MCPTransport.http, + ) + mock_client = AsyncMock() + setattr(mock_client, client_method, AsyncMock(side_effect=httpx.ConnectError("connection refused"))) + mock_client.discovery_auth_fingerprint = AsyncMock(return_value="test-credential-hash") + + with patch.object(manager, "_create_mcp_client", new_callable=AsyncMock, return_value=mock_client): + assert await getattr(manager, manager_method)(server, user_api_key_auth=None) == [] + with pytest.raises(MCPServerListError) as exc_info: + await getattr(manager, manager_method)(server, user_api_key_auth=None, raise_on_error=True) + + assert exc_info.value.fault == ServerListFault(tag="unreachable") + assert exc_info.value.server_name == server.name + @pytest.mark.asyncio async def test_read_resource_from_server_success(self): manager = MCPServerManager() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index 4ec4ae31ca6..fda8b1620e4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -2366,6 +2366,191 @@ class TestListToolsRestAPI: assert result["error"] is None +class TestListPromptsAndResourcesRestAPI: + """LIT-2011: the dashboard lists a server's prompts and resources through /mcp-rest, gated + by the same server admission and upstream credential resolution as /mcp-rest/tools/list.""" + + pytestmark = pytest.mark.asyncio + + @staticmethod + def _stub_server() -> MCPServer: + server = MCPServer(server_id="catalog-server-id", name="catalog-server", transport=MCPTransport.http) + server.alias = "catalog" + server.server_name = "catalog-server" + server.available_on_public_internet = True + return server + + def _grant(self, monkeypatch, server: MCPServer, allowed: list[str]) -> None: + async def fake_contexts(user_api_key_auth): + return [user_api_key_auth] + + async def fake_get_allowed_mcp_servers(*args, **kwargs): + return allowed + + monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_allowed_mcp_servers", + fake_get_allowed_mcp_servers, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_mcp_server_by_id", + lambda sid: server if sid == server.server_id else None, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_mcp_server_by_name", + lambda name, client_ip=None: server if name == server.alias else None, + raising=False, + ) + + @pytest.mark.parametrize("route_name", ["list_prompts_rest_api", "list_resources_rest_api"]) + async def test_rejects_server_outside_caller_grant(self, monkeypatch, route_name): + server = self._stub_server() + self._grant(monkeypatch, server, allowed=["some-other-server"]) + upstream = AsyncMock() + monkeypatch.setattr(rest_endpoints.global_mcp_server_manager, "get_prompts_from_server", upstream) + monkeypatch.setattr(rest_endpoints.global_mcp_server_manager, "get_resources_from_server", upstream) + monkeypatch.setattr(rest_endpoints.global_mcp_server_manager, "get_resource_templates_from_server", upstream) + + request = _build_request(path=f"/mcp-rest/{route_name}", method="GET") + with pytest.raises(HTTPException) as exc_info: + await getattr(rest_endpoints, route_name)( + request, server_id=server.server_id, user_api_key_dict=UserAPIKeyAuth() + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail["error"] == "access_denied" + upstream.assert_not_awaited() + + async def test_lists_prompts_with_upstream_names_and_server_credential(self, monkeypatch): + from mcp.types import Prompt, PromptArgument + + server = self._stub_server() + self._grant(monkeypatch, server, allowed=[server.server_id]) + upstream_prompts = [ + Prompt( + name="summarize", + description="Summarize text", + arguments=[PromptArgument(name="text", required=True)], + ) + ] + get_prompts = AsyncMock(return_value=upstream_prompts) + monkeypatch.setattr(rest_endpoints.global_mcp_server_manager, "get_prompts_from_server", get_prompts) + + request = _build_request( + headers={"x-mcp-catalog-authorization": "Bearer per-server-token"}, + path="/mcp-rest/prompts/list", + method="GET", + ) + result = await rest_endpoints.list_prompts_rest_api( + request, server_id=server.alias, user_api_key_dict=UserAPIKeyAuth(user_id="user-123") + ) + + assert [prompt.name for prompt in result.prompts] == ["summarize"] + assert result.prompts[0].arguments[0].name == "text" + get_prompts.assert_awaited_once() + call = get_prompts.await_args + assert call.args[0] is server + assert call.kwargs["add_prefix"] is False + assert call.kwargs["mcp_auth_header"] == {"Authorization": "Bearer per-server-token"} + assert call.kwargs["raw_headers"]["x-mcp-catalog-authorization"] == "Bearer per-server-token" + assert call.kwargs["user_api_key_auth"].user_id == "user-123" + + async def test_lists_resources_and_templates_for_allowed_server(self, monkeypatch): + from mcp.types import Resource, ResourceTemplate + + server = self._stub_server() + self._grant(monkeypatch, server, allowed=[server.server_id]) + get_resources = AsyncMock(return_value=[Resource(name="readme", uri="demo://readme", mimeType="text/markdown")]) + get_templates = AsyncMock( + return_value=[ResourceTemplate(name="user_profile", uriTemplate="demo://users/{user_id}/profile")] + ) + monkeypatch.setattr(rest_endpoints.global_mcp_server_manager, "get_resources_from_server", get_resources) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, "get_resource_templates_from_server", get_templates + ) + + request = _build_request(path="/mcp-rest/resources/list", method="GET") + result = await rest_endpoints.list_resources_rest_api( + request, server_id=server.server_id, user_api_key_dict=UserAPIKeyAuth() + ) + + assert [str(resource.uri) for resource in result.resources] == ["demo://readme"] + assert result.resources[0].mimeType == "text/markdown" + assert [template.uriTemplate for template in result.resource_templates] == ["demo://users/{user_id}/profile"] + for upstream in (get_resources, get_templates): + upstream.assert_awaited_once() + assert upstream.await_args.args[0] is server + assert upstream.await_args.kwargs["add_prefix"] is False + + @pytest.mark.parametrize( + ("route_name", "manager_method", "catalog"), + [ + ("list_prompts_rest_api", "get_prompts_from_server", "prompts"), + ("list_resources_rest_api", "get_resources_from_server", "resources"), + ("list_resources_rest_api", "get_resource_templates_from_server", "resources"), + ], + ) + async def test_upstream_fault_relays_truthful_status_instead_of_empty_success( + self, monkeypatch, route_name, manager_method, catalog + ): + """A broken upstream must answer like /mcp-rest/tools/list does (a gateway status), not as + an empty catalog the dashboard would render as "this server has no prompts".""" + from litellm.proxy._experimental.mcp_server.exceptions import MCPServerListError + from litellm.proxy._experimental.mcp_server.faults.list_outcomes import ServerListFault + + server = self._stub_server() + self._grant(monkeypatch, server, allowed=[server.server_id]) + failing = AsyncMock(side_effect=MCPServerListError(ServerListFault(tag="unreachable"), server.name)) + for method in ("get_prompts_from_server", "get_resources_from_server", "get_resource_templates_from_server"): + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + method, + failing if method == manager_method else AsyncMock(return_value=[]), + ) + + request = _build_request(path=f"/mcp-rest/{catalog}/list", method="GET") + with pytest.raises(HTTPException) as exc_info: + await getattr(rest_endpoints, route_name)( + request, server_id=server.server_id, user_api_key_dict=UserAPIKeyAuth() + ) + + assert exc_info.value.status_code == 502 + assert exc_info.value.detail == { + "error": "unreachable", + "message": f"Failed to list {catalog} from server catalog", + } + assert failing.await_args.kwargs["raise_on_error"] is True + + async def test_upstream_auth_challenge_is_relayed_for_catalog_routes(self, monkeypatch): + from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError + + server = self._stub_server() + self._grant(monkeypatch, server, allowed=[server.server_id]) + challenge = 'Bearer resource_metadata="https://upstream.example/.well-known/oauth-protected-resource"' + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_prompts_from_server", + AsyncMock( + side_effect=MCPUpstreamAuthError(status_code=401, www_authenticate=challenge, server_name=server.name) + ), + ) + + request = _build_request(path="/mcp-rest/prompts/list", method="GET") + with pytest.raises(HTTPException) as exc_info: + await rest_endpoints.list_prompts_rest_api( + request, server_id=server.server_id, user_api_key_dict=UserAPIKeyAuth() + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.headers is not None + assert "www-authenticate" in {key.lower() for key in exc_info.value.headers} + + class TestCallToolRestAPI: pytestmark = pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 72c59223549..563d7151de1 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -495,6 +495,8 @@ def test_virtual_key_llm_api_routes_rejects_mcp_multi_segment_admin_subpaths( ("/mcp/tools/call", "POST"), ("/mcp-rest/tools/list", "GET"), ("/mcp-rest/tools/call", "POST"), + ("/mcp-rest/prompts/list", "GET"), + ("/mcp-rest/resources/list", "GET"), ("/v1/mcp/tools", "GET"), ], ) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPCatalogSections.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPCatalogSections.tsx new file mode 100644 index 00000000000..a0445f75fb4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPCatalogSections.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { FileText, MessageSquareText, type LucideIcon } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import type { MCPPrompt, MCPResource, MCPResourceTemplate } from "@/components/mcp_tools/types"; + +interface CatalogSectionProps { + title: string; + icon: LucideIcon; + count: number; + isLoading: boolean; + errorMessage?: string | null; + emptyText: string; + children: React.ReactNode; +} + +const CatalogSection = ({ + title, + icon: Icon, + count, + isLoading, + errorMessage, + emptyText, + children, +}: CatalogSectionProps) => { + const isSettled = !isLoading && !errorMessage; + return ( +
+

+ {title} + {count > 0 && ( + + {count} + + )} +

+ {isLoading && ( +
+ +

Loading {title.toLowerCase()}...

+
+ )} + {!isLoading && errorMessage && ( +
+

Error: {errorMessage}

+
+ )} + {isSettled && count === 0 && ( +
+

{emptyText}

+
+ )} + {isSettled && count > 0 &&
{children}
} +
+ ); +}; + +interface MCPPromptsSectionProps { + prompts: MCPPrompt[]; + isLoading: boolean; + errorMessage?: string | null; +} + +export const MCPPromptsSection = ({ prompts, isLoading, errorMessage }: MCPPromptsSectionProps) => ( + + {prompts.map((prompt) => ( +
+

{prompt.name}

+ {prompt.description && ( +

{prompt.description}

+ )} + {prompt.arguments && prompt.arguments.length > 0 && ( +
+ {prompt.arguments.map((argument) => ( + + {argument.name} + + ))} +
+ )} +
+ ))} +
+); + +interface MCPResourcesSectionProps { + resources: MCPResource[]; + resourceTemplates: MCPResourceTemplate[]; + isLoading: boolean; + errorMessage?: string | null; +} + +export const MCPResourcesSection = ({ + resources, + resourceTemplates, + isLoading, + errorMessage, +}: MCPResourcesSectionProps) => ( + + {resources.map((resource) => ( +
+
+

{resource.title ?? resource.name}

+ {resource.mimeType && ( + + {resource.mimeType} + + )} +
+

+ {resource.uri} +

+ {resource.description && ( +

{resource.description}

+ )} +
+ ))} + {resourceTemplates.map((template) => ( +
+
+

{template.title ?? template.name}

+ + template + +
+

+ {template.uriTemplate} +

+ {template.description && ( +

{template.description}

+ )} +
+ ))} +
+); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.test.tsx index 3a189f50264..a183dbf8a89 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.test.tsx @@ -1,12 +1,19 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it, vi, beforeEach } from "vitest"; import MCPToolsViewer from "./mcp_tools"; -import { listMCPTools, getMCPOAuthUserCredentialStatus } from "@/components/networking"; +import { + listMCPTools, + listMCPPrompts, + listMCPResources, + getMCPOAuthUserCredentialStatus, +} from "@/components/networking"; import { isTokenValid, getToken } from "@/utils/mcpTokenStore"; vi.mock("@/components/networking", () => ({ listMCPTools: vi.fn(), + listMCPPrompts: vi.fn(), + listMCPResources: vi.fn(), callMCPTool: vi.fn(), getMCPOAuthUserCredentialStatus: vi.fn(), })); @@ -82,6 +89,8 @@ describe("MCPToolsViewer gatewayMintsClient wiring", () => { describe("MCPToolsViewer auth gate routing", () => { beforeEach(() => { vi.mocked(listMCPTools).mockReset().mockResolvedValue({ tools: [], error: null }); + vi.mocked(listMCPPrompts).mockReset().mockResolvedValue({ prompts: [] }); + vi.mocked(listMCPResources).mockReset().mockResolvedValue({ resources: [], resource_templates: [] }); vi.mocked(isTokenValid).mockReset().mockReturnValue(false); vi.mocked(getToken) .mockReset() @@ -215,3 +224,95 @@ describe("MCPToolsViewer auth gate routing", () => { expect(vi.mocked(getMCPOAuthUserCredentialStatus)).not.toHaveBeenCalled(); }); }); + +describe("MCPToolsViewer prompts and resources catalog", () => { + beforeEach(() => { + vi.mocked(listMCPTools).mockReset().mockResolvedValue({ tools: [], error: null }); + vi.mocked(listMCPPrompts) + .mockReset() + .mockResolvedValue({ + prompts: [ + { + name: "summarize", + description: "Summarize a block of text", + arguments: [ + { name: "text", required: true }, + { name: "style", required: false }, + ], + }, + ], + }); + vi.mocked(listMCPResources) + .mockReset() + .mockResolvedValue({ + resources: [{ name: "readme", uri: "demo://readme", description: "Project readme", mimeType: "text/markdown" }], + resource_templates: [{ name: "profile", uriTemplate: "demo://users/{user_id}/profile" }], + }); + vi.mocked(isTokenValid).mockReset().mockReturnValue(false); + vi.mocked(getToken).mockReset().mockReturnValue(null); + vi.mocked(getMCPOAuthUserCredentialStatus).mockReset().mockResolvedValue(credStatus()); + }); + + it("lists the server's prompts and resources next to its tools", async () => { + renderViewer({ auth_type: "api_key", tokenUrl: null }); + + const prompts = await screen.findByRole("region", { name: "Prompts" }); + expect(await within(prompts).findByText("summarize")).toBeInTheDocument(); + expect(within(prompts).getByText("Summarize a block of text")).toBeInTheDocument(); + expect(within(prompts).getByText("text")).toBeInTheDocument(); + expect(within(prompts).getByText("style")).toBeInTheDocument(); + + const resources = screen.getByRole("region", { name: "Resources" }); + expect(await within(resources).findByText("demo://readme")).toBeInTheDocument(); + expect(within(resources).getByText("text/markdown")).toBeInTheDocument(); + expect(within(resources).getByText("demo://users/{user_id}/profile")).toBeInTheDocument(); + expect(within(resources).getByText("2")).toBeInTheDocument(); + + expect(vi.mocked(listMCPPrompts)).toHaveBeenCalledWith("litellm-key", "srv-1", undefined); + expect(vi.mocked(listMCPResources)).toHaveBeenCalledWith("litellm-key", "srv-1", undefined); + }); + + it("forwards the browser session token to the prompt and resource listings like tools", async () => { + vi.mocked(isTokenValid).mockReturnValue(true); + vi.mocked(getToken).mockReturnValue({ + access_token: "slack-tok", + expires_at: Date.now() + 60_000, + token_type: "bearer", + }); + + renderViewer({ oauth2_flow: null, delegate_auth_to_upstream: true }); + + await screen.findByText("summarize"); + const passthroughHeader = expect.objectContaining({ "x-mcp-slack-authorization": "Bearer slack-tok" }); + expect(vi.mocked(listMCPPrompts)).toHaveBeenCalledWith("litellm-key", "srv-1", passthroughHeader); + expect(vi.mocked(listMCPResources)).toHaveBeenCalledWith("litellm-key", "srv-1", passthroughHeader); + }); + + it("does not list prompts or resources while the auth gate is shown", async () => { + vi.mocked(getMCPOAuthUserCredentialStatus).mockResolvedValue(credStatus({ has_credential: false })); + + renderViewer({ oauth2_flow: null, delegate_auth_to_upstream: false }); + + expect(await screen.findByText(GATE_TEXT)).toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Prompts" })).not.toBeInTheDocument(); + expect(vi.mocked(listMCPPrompts)).not.toHaveBeenCalled(); + expect(vi.mocked(listMCPResources)).not.toHaveBeenCalled(); + }); + + it("shows the upstream error for a catalog that failed to load", async () => { + const failedResources = { + resources: [], + resource_templates: [], + error: "http_502", + message: "upstream unreachable", + status: 502, + }; + vi.mocked(listMCPResources).mockResolvedValue(failedResources); + + renderViewer({ auth_type: "api_key", tokenUrl: null }); + + const resources = await screen.findByRole("region", { name: "Resources" }); + expect(await within(resources).findByText("Error: upstream unreachable")).toBeInTheDocument(); + expect(await screen.findByText("summarize")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx index a7af5c978f9..bacd2a58216 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { ToolTestPanel } from "./ToolTestPanel"; +import { MCPPromptsSection, MCPResourcesSection } from "./MCPCatalogSections"; import { resolveLogoSrc } from "@/lib/assetPaths"; import { isClientForwardedTokenMode, @@ -11,7 +12,13 @@ import { CallMCPToolResponse, getMcpOAuthMode, } from "@/components/mcp_tools/types"; -import { listMCPTools, callMCPTool, getMCPOAuthUserCredentialStatus } from "@/components/networking"; +import { + listMCPTools, + listMCPPrompts, + listMCPResources, + callMCPTool, + getMCPOAuthUserCredentialStatus, +} from "@/components/networking"; import { isTokenValid, getToken, removeToken } from "@/utils/mcpTokenStore"; import { sanitizeMcpAliasForHeader, buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils"; import { useToolsOAuthFlow } from "@/hooks/useToolsOAuthFlow"; @@ -148,6 +155,12 @@ const MCPToolsViewer = ({ return Object.keys(customHeaders).length > 0 ? customHeaders : undefined; }; + // Passthrough blocks until a browser session token exists; authorization_code blocks until + // the user has a valid DB credential (else the backend returns no tools). + const catalogQueriesEnabled = + !!accessToken && + (usesBrowserHeldToken ? oauthToken !== null : isAuthorizationCode ? hasAuthorizationCodeCred : true); + // Query to fetch MCP tools const { data: mcpToolsResponse, @@ -179,11 +192,7 @@ const MCPToolsViewer = ({ } return result; }, - // Passthrough blocks until a browser session token exists; authorization_code blocks until - // the user has a valid DB credential (else the backend returns no tools). - enabled: - !!accessToken && - (usesBrowserHeldToken ? oauthToken !== null : isAuthorizationCode ? hasAuthorizationCodeCred : true), + enabled: catalogQueriesEnabled, staleTime: 30000, // Consider data fresh for 30 seconds retry: (failureCount, error: any) => { // Don't retry on 401 — token is invalid, user must re-authenticate @@ -192,6 +201,20 @@ const MCPToolsViewer = ({ }, }); + const { data: mcpPromptsResponse, isLoading: isLoadingPrompts } = useQuery({ + queryKey: ["mcpPrompts", serverId, passthroughHeaders, oauthToken], + queryFn: () => listMCPPrompts(accessToken ?? "", serverId, buildCustomHeaders()), + enabled: catalogQueriesEnabled, + staleTime: 30000, + }); + + const { data: mcpResourcesResponse, isLoading: isLoadingResources } = useQuery({ + queryKey: ["mcpResources", serverId, passthroughHeaders, oauthToken], + queryFn: () => listMCPResources(accessToken ?? "", serverId, buildCustomHeaders()), + enabled: catalogQueriesEnabled, + staleTime: 30000, + }); + // authorization_code authorize: same redirect+exchange flow as the admin "Authorize & Fetch" // and the chat "Connect" button, but persists the token to the per-user DB. const onAuthorizationCodeAuthSuccess = useCallback(() => { @@ -288,6 +311,12 @@ const MCPToolsViewer = ({ ); }); + const promptsData = mcpPromptsResponse?.prompts ?? []; + const resourcesData = mcpResourcesResponse?.resources ?? []; + const resourceTemplatesData = mcpResourcesResponse?.resource_templates ?? []; + const catalogErrorMessage = (response: { error?: string | null; message?: string | null } | undefined) => + response?.error ? response.message || response.error : null; + return (
@@ -539,6 +568,18 @@ const MCPToolsViewer = ({ )} )} + + + ) : null}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index f009429693b..92a823b2028 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -1,3 +1,5 @@ +import type { components } from "@/lib/http/schema"; + /** A single MCP tool event emitted by the LiteLLM proxy during a Responses API turn. */ export interface MCPEvent { type: string; @@ -340,6 +342,20 @@ export interface MCPTool { // Define the response structure for the listMCPTools endpoint - now a flat array export type ListMCPToolsResponse = MCPTool[]; +export type MCPPrompt = components["schemas"]["Prompt"]; +export type MCPResource = components["schemas"]["Resource"]; +export type MCPResourceTemplate = components["schemas"]["ResourceTemplate"]; + +export interface MCPRestListFailure { + error?: string | null; + message?: string | null; + status?: number; +} + +export type ListMCPPromptsResponse = components["schemas"]["ListMCPPromptsRestAPIResponse"] & MCPRestListFailure; + +export type ListMCPResourcesResponse = components["schemas"]["ListMCPResourcesRestAPIResponse"] & MCPRestListFailure; + // Define the argument structure for calling an MCP tool export interface CallMCPToolArgs { name: string; diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 3b2a17101ee..7fd487887af 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -913,3 +913,49 @@ describe("fetchMemoryList search serialization", () => { expect(lastParams(mockFetch).has("search")).toBe(false); }); }); + +describe("listMCPPrompts / listMCPResources", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + const mockFetch = (status: number, body: unknown) => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: status < 400, + status, + text: vi.fn().mockResolvedValue(JSON.stringify(body)), + } as unknown as Response); + global.fetch = fetchMock; + return fetchMock; + }; + + it("passes the server id and custom MCP headers through to the catalog route", async () => { + const fetchMock = mockFetch(200, { prompts: [{ name: "summarize" }] }); + + const result = await Networking.listMCPPrompts("token", "srv-1", { "x-mcp-demo-authorization": "Bearer t" }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url, "http://example.com"); + expect(parsed.pathname.endsWith("/mcp-rest/prompts/list")).toBe(true); + expect(parsed.searchParams.get("server_id")).toBe("srv-1"); + expect((init.headers as Record)["x-mcp-demo-authorization"]).toBe("Bearer t"); + expect(result).toEqual({ prompts: [{ name: "summarize" }] }); + }); + + it("returns the proxy's classified fault and message instead of throwing", async () => { + mockFetch(502, { detail: { error: "unreachable", message: "Failed to list resources from server demo" } }); + + const result = await Networking.listMCPResources("token", "srv-1"); + + const expected = { + resources: [], + resource_templates: [], + error: "unreachable", + message: "Failed to list resources from server demo", + status: 502, + }; + expect(result).toEqual(expected); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 80b4a72649d..282f1d3165f 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -98,9 +98,12 @@ import type { ObjectPermission } from "./object_permission_types"; import type { components } from "@/lib/http/schema"; import { jsonFields } from "./common_components/check_openapi_schema"; import type { + ListMCPPromptsResponse, + ListMCPResourcesResponse, MCPGatewaySessionSelector, MCPGatewaySessionsResponse, MCPGatewaySessionsTerminateResponse, + MCPRestListFailure, MCPServerUserCredentialListItem, MCPServerUserCredentialType, MCPUserEnvVarsStatus, @@ -116,6 +119,7 @@ import type { AutoRouterPresetsResponse } from "@/lib/autorouter_presets"; import type { VectorStoreIndex } from "@/app/(dashboard)/vector-stores/_components/IndexesTab"; import type { RoutingDecision } from "./view_logs/LogDetailsDrawer/RoutingDecisionCard"; import { + ApiError, createApiClient, deriveErrorMessage, extractProxyErrorMessage, @@ -5223,6 +5227,58 @@ export const listMCPTools = async ( return data; }; +interface MCPCatalogRequest { + accessToken: string; + serverId: string; + customHeaders?: Record; +} + +const listMCPCatalog = async ( + catalog: "prompts" | "resources", + empty: T, + { accessToken, serverId, customHeaders }: MCPCatalogRequest, +): Promise => { + try { + const data = await apiClient.get>(`/mcp-rest/${catalog}/list`, { + accessToken, + query: { server_id: serverId }, + headers: customHeaders, + }); + return { ...empty, ...data }; + } catch (error) { + console.error(`Failed to fetch MCP ${catalog}:`, error); + if (error instanceof ApiError) { + const detail = (error.body as { detail?: { error?: string; message?: string } | string } | null)?.detail; + const structured = typeof detail === "object" && detail !== null ? detail : undefined; + return { + ...empty, + error: structured?.error ?? `http_${error.status}`, + message: structured?.message ?? error.message, + status: error.status, + }; + } + return { + ...empty, + error: "network_error", + message: error instanceof Error ? error.message : `Failed to fetch MCP ${catalog}`, + }; + } +}; + +export const listMCPPrompts = ( + accessToken: string, + serverId: string, + customHeaders?: Record, +): Promise => + listMCPCatalog("prompts", { prompts: [] }, { accessToken, serverId, customHeaders }); + +export const listMCPResources = ( + accessToken: string, + serverId: string, + customHeaders?: Record, +): Promise => + listMCPCatalog("resources", { resources: [], resource_templates: [] }, { accessToken, serverId, customHeaders }); + interface CallMCPToolOptions { guardrails?: string[]; customHeaders?: Record; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 136b8f26784..04710de93c0 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -8800,6 +8800,48 @@ export interface paths { patch: operations["aggregate_mcp_route_mcp_patch"]; trace?: never; }; + "/mcp-rest/prompts/list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Prompts Rest Api + * @description List the prompts one MCP server exposes, with names as the upstream server reports them. + * + * An upstream failure relays its classified HTTP status, the same as ``/mcp-rest/tools/list``. + */ + get: operations["list_prompts_rest_api_mcp_rest_prompts_list_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mcp-rest/resources/list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Resources Rest Api + * @description List the resources and resource templates one MCP server exposes. + */ + get: operations["list_resources_rest_api_mcp_rest_resources_list_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/mcp-rest/test/connection": { parameters: { query?: never; @@ -23705,6 +23747,15 @@ export interface components { /** Index Permissions */ index_permissions: ("read" | "write")[]; }; + /** Annotations */ + Annotations: { + /** Audience */ + audience?: ("user" | "assistant")[] | null; + /** Priority */ + priority?: number | null; + } & { + [key: string]: unknown; + }; /** ApplyGuardrailRequest */ ApplyGuardrailRequest: { /** Entities */ @@ -29184,6 +29235,20 @@ export interface components { /** Ip */ ip: string; }; + /** + * Icon + * @description An icon for display in user interfaces. + */ + Icon: { + /** Mimetype */ + mimeType?: string | null; + /** Sizes */ + sizes?: string[] | null; + /** Src */ + src: string; + } & { + [key: string]: unknown; + }; /** ImageURLListItem */ ImageURLListItem: { image_url: components["schemas"]["ImageURLObject"]; @@ -29624,6 +29689,18 @@ export interface components { /** Self */ self: string; }; + /** ListMCPPromptsRestAPIResponse */ + ListMCPPromptsRestAPIResponse: { + /** Prompts */ + prompts: components["schemas"]["Prompt"][]; + }; + /** ListMCPResourcesRestAPIResponse */ + ListMCPResourcesRestAPIResponse: { + /** Resource Templates */ + resource_templates: components["schemas"]["ResourceTemplate"][]; + /** Resources */ + resources: components["schemas"]["Resource"][]; + }; /** * ListMeta * @description Page-mode counterpart to `PageMeta`: an entity list pays for the COUNT(*) so the table can show a page count. @@ -35606,12 +35683,41 @@ export interface components { */ version_status: string; }; - /** Prompt */ + /** + * Prompt + * @description A prompt or prompt template that the server offers. + */ Prompt: { - litellm_params: components["schemas"]["PromptLiteLLMParams"]; - /** Prompt Id */ - prompt_id: string; - prompt_info?: components["schemas"]["PromptInfo"] | null; + /** Meta */ + _meta?: { + [key: string]: unknown; + } | null; + /** Arguments */ + arguments?: components["schemas"]["PromptArgument"][] | null; + /** Description */ + description?: string | null; + /** Icons */ + icons?: components["schemas"]["Icon"][] | null; + /** Name */ + name: string; + /** Title */ + title?: string | null; + } & { + [key: string]: unknown; + }; + /** + * PromptArgument + * @description An argument for a prompt template. + */ + PromptArgument: { + /** Description */ + description?: string | null; + /** Name */ + name: string; + /** Required */ + required?: boolean | null; + } & { + [key: string]: unknown; }; /** PromptInfo */ PromptInfo: { @@ -36662,6 +36768,61 @@ export interface components { /** Reset To */ reset_to: number; }; + /** + * Resource + * @description A known resource that the server is capable of reading. + */ + Resource: { + /** Meta */ + _meta?: { + [key: string]: unknown; + } | null; + annotations?: components["schemas"]["Annotations"] | null; + /** Description */ + description?: string | null; + /** Icons */ + icons?: components["schemas"]["Icon"][] | null; + /** Mimetype */ + mimeType?: string | null; + /** Name */ + name: string; + /** Size */ + size?: number | null; + /** Title */ + title?: string | null; + /** + * Uri + * Format: uri + */ + uri: string; + } & { + [key: string]: unknown; + }; + /** + * ResourceTemplate + * @description A template description for resources available on the server. + */ + ResourceTemplate: { + /** Meta */ + _meta?: { + [key: string]: unknown; + } | null; + annotations?: components["schemas"]["Annotations"] | null; + /** Description */ + description?: string | null; + /** Icons */ + icons?: components["schemas"]["Icon"][] | null; + /** Mimetype */ + mimeType?: string | null; + /** Name */ + name: string; + /** Title */ + title?: string | null; + /** Uritemplate */ + uriTemplate: string; + } & { + [key: string]: unknown; + }; /** ResponseLiteLLM_ManagedVectorStore */ ResponseLiteLLM_ManagedVectorStore: { vector_store?: components["schemas"]["LiteLLM_ManagedVectorStoresTable"]; @@ -53058,6 +53219,70 @@ export interface operations { }; }; }; + list_prompts_rest_api_mcp_rest_prompts_list_get: { + parameters: { + query: { + /** @description The MCP server id, name, or alias to list prompts for */ + server_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListMCPPromptsRestAPIResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_resources_rest_api_mcp_rest_resources_list_get: { + parameters: { + query: { + /** @description The MCP server id, name, or alias to list resources for */ + server_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListMCPResourcesRestAPIResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; test_connection_mcp_rest_test_connection_post: { parameters: { query?: never;