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>
This commit is contained in:
joshua 2026-09-18 23:36:10 +00:00
parent 018f640b30
commit 880dca1600
14 changed files with 2051 additions and 15 deletions

View file

@ -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 []

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

@ -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",
]

View file

@ -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()

View file

@ -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

View file

@ -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"),
],
)

View file

@ -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 (
<section aria-label={title} className="mt-4 flex flex-col">
<p className="mb-3 flex items-center text-sm font-medium">
<Icon className="mr-2 size-4" /> {title}
{count > 0 && (
<Badge variant="secondary" className="ml-2">
{count}
</Badge>
)}
</p>
{isLoading && (
<div className="flex flex-col items-center justify-center rounded-lg border border-border bg-card py-6">
<UiLoadingSpinner className="mb-2 size-5 text-muted-foreground" />
<p className="text-xs font-medium">Loading {title.toLowerCase()}...</p>
</div>
)}
{!isLoading && errorMessage && (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs text-destructive">
<p className="font-medium">Error: {errorMessage}</p>
</div>
)}
{isSettled && count === 0 && (
<div className="rounded-lg border border-border bg-card p-3 text-center">
<p className="text-xs text-muted-foreground">{emptyText}</p>
</div>
)}
{isSettled && count > 0 && <div className="max-h-60 min-h-0 space-y-2 overflow-y-auto">{children}</div>}
</section>
);
};
interface MCPPromptsSectionProps {
prompts: MCPPrompt[];
isLoading: boolean;
errorMessage?: string | null;
}
export const MCPPromptsSection = ({ prompts, isLoading, errorMessage }: MCPPromptsSectionProps) => (
<CatalogSection
title="Prompts"
icon={MessageSquareText}
count={prompts.length}
isLoading={isLoading}
errorMessage={errorMessage}
emptyText="No prompts found for this server"
>
{prompts.map((prompt) => (
<div key={prompt.name} className="rounded-lg border border-border bg-card p-3">
<h4 className="truncate font-mono text-xs font-medium">{prompt.name}</h4>
{prompt.description && (
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">{prompt.description}</p>
)}
{prompt.arguments && prompt.arguments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{prompt.arguments.map((argument) => (
<Badge
key={argument.name}
variant={argument.required ? "default" : "outline"}
title={argument.description ?? (argument.required ? "required" : "optional")}
className="font-mono text-[10px]"
>
{argument.name}
</Badge>
))}
</div>
)}
</div>
))}
</CatalogSection>
);
interface MCPResourcesSectionProps {
resources: MCPResource[];
resourceTemplates: MCPResourceTemplate[];
isLoading: boolean;
errorMessage?: string | null;
}
export const MCPResourcesSection = ({
resources,
resourceTemplates,
isLoading,
errorMessage,
}: MCPResourcesSectionProps) => (
<CatalogSection
title="Resources"
icon={FileText}
count={resources.length + resourceTemplates.length}
isLoading={isLoading}
errorMessage={errorMessage}
emptyText="No resources found for this server"
>
{resources.map((resource) => (
<div key={resource.uri} className="rounded-lg border border-border bg-card p-3">
<div className="flex items-start justify-between gap-2">
<h4 className="truncate text-xs font-medium">{resource.title ?? resource.name}</h4>
{resource.mimeType && (
<Badge variant="outline" className="shrink-0 font-mono text-[10px]">
{resource.mimeType}
</Badge>
)}
</div>
<p className="truncate font-mono text-xs text-muted-foreground" title={resource.uri}>
{resource.uri}
</p>
{resource.description && (
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">{resource.description}</p>
)}
</div>
))}
{resourceTemplates.map((template) => (
<div key={template.uriTemplate} className="rounded-lg border border-dashed border-border bg-card p-3">
<div className="flex items-start justify-between gap-2">
<h4 className="truncate text-xs font-medium">{template.title ?? template.name}</h4>
<Badge variant="secondary" className="shrink-0 text-[10px]">
template
</Badge>
</div>
<p className="truncate font-mono text-xs text-muted-foreground" title={template.uriTemplate}>
{template.uriTemplate}
</p>
{template.description && (
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">{template.description}</p>
)}
</div>
))}
</CatalogSection>
);

View file

@ -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();
});
});

View file

@ -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 (
<div className="w-full p-4">
<Card className="w-full overflow-hidden rounded-xl shadow-md">
@ -539,6 +568,18 @@ const MCPToolsViewer = ({
)}
</>
)}
<MCPPromptsSection
prompts={promptsData}
isLoading={isLoadingPrompts || authorizationCodeStatusLoading}
errorMessage={catalogErrorMessage(mcpPromptsResponse)}
/>
<MCPResourcesSection
resources={resourcesData}
resourceTemplates={resourceTemplatesData}
isLoading={isLoadingResources || authorizationCodeStatusLoading}
errorMessage={catalogErrorMessage(mcpResourcesResponse)}
/>
</>
) : null}
</div>

View file

@ -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;

View file

@ -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<typeof fetch>().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<string, string>)["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);
});
});

View file

@ -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<string, string>;
}
const listMCPCatalog = async <T extends object>(
catalog: "prompts" | "resources",
empty: T,
{ accessToken, serverId, customHeaders }: MCPCatalogRequest,
): Promise<T & MCPRestListFailure> => {
try {
const data = await apiClient.get<Partial<T>>(`/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<string, string>,
): Promise<ListMCPPromptsResponse> =>
listMCPCatalog("prompts", { prompts: [] }, { accessToken, serverId, customHeaders });
export const listMCPResources = (
accessToken: string,
serverId: string,
customHeaders?: Record<string, string>,
): Promise<ListMCPResourcesResponse> =>
listMCPCatalog("resources", { resources: [], resource_templates: [] }, { accessToken, serverId, customHeaders });
interface CallMCPToolOptions {
guardrails?: string[];
customHeaders?: Record<string, string>;

View file

@ -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;