mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
018f640b30
commit
880dca1600
14 changed files with 2051 additions and 15 deletions
|
|
@ -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 []
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
235
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
235
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue