From 880dca160009b1e15e2de97337f19d604db29e6c Mon Sep 17 00:00:00 2001 From: joshua Date: Fri, 18 Sep 2026 23:36:10 +0000 Subject: [PATCH] feat(mcp): list MCP prompts and resources in the admin UI Add GET /mcp-rest/prompts/list and GET /mcp-rest/resources/list for one MCP server, reusing the tools/list admission, IP filtering, alias resolution, per-server auth header selection and OAuth extra-header forwarding. The resources route also returns resource templates. Upstream failures relay the same classified HTTP status the tools route uses instead of an empty 200 The MCP server tools viewer in the dashboard now renders Prompts and Resources sections under the tools list, behind the same OAuth gate Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../mcp_server/mcp_server_manager.py | 9 + .../mcp_server/rest_endpoints.py | 124 +- litellm/proxy/_lazy_openapi_snapshot.json | 1046 +++++++++++++++++ litellm/proxy/_types.py | 2 + .../mcp_server/test_mcp_server_manager.py | 33 + .../mcp_server/test_rest_endpoints.py | 185 +++ .../proxy/auth/test_route_checks.py | 2 + .../_components/MCPCatalogSections.tsx | 154 +++ .../_components/mcp_tools.test.tsx | 105 +- .../mcp-servers/_components/mcp_tools.tsx | 53 +- .../src/components/mcp_tools/types.tsx | 16 + .../src/components/networking.test.ts | 46 + .../src/components/networking.tsx | 56 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 235 +++- 14 files changed, 2051 insertions(+), 15 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPCatalogSections.tsx 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;