Skip to content

Commit 2dbac37

Browse files
authored
Merge pull request #1330 from maysunfaisal/RHIDP-12470-1
RHIDP-12470: Add API endpoints for dynamically registering and unregistering MCP Servers
2 parents cc31d1c + 8f2a6ec commit 2dbac37

9 files changed

Lines changed: 1058 additions & 4 deletions

File tree

src/app/endpoints/mcp_servers.py

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
"""Handler for REST API calls to dynamically manage MCP servers."""
2+
3+
from typing import Annotated, Any
4+
5+
from fastapi import APIRouter, Depends, HTTPException, Request, status
6+
from llama_stack_client import APIConnectionError
7+
8+
from authentication import get_auth_dependency
9+
from authentication.interface import AuthTuple
10+
from authorization.middleware import authorize
11+
from client import AsyncLlamaStackClientHolder
12+
from configuration import configuration
13+
from models.config import Action, ModelContextProtocolServer
14+
from models.requests import MCPServerRegistrationRequest
15+
from models.responses import (
16+
ConflictResponse,
17+
ForbiddenResponse,
18+
InternalServerErrorResponse,
19+
MCPServerDeleteResponse,
20+
MCPServerInfo,
21+
MCPServerListResponse,
22+
MCPServerRegistrationResponse,
23+
NotFoundResponse,
24+
ServiceUnavailableResponse,
25+
UnauthorizedResponse,
26+
)
27+
from utils.endpoints import check_configuration_loaded
28+
from log import get_logger
29+
30+
logger = get_logger(__name__)
31+
router = APIRouter(tags=["mcp-servers"])
32+
33+
34+
register_responses: dict[int | str, dict[str, Any]] = {
35+
201: MCPServerRegistrationResponse.openapi_response(),
36+
401: UnauthorizedResponse.openapi_response(
37+
examples=["missing header", "missing token"]
38+
),
39+
403: ForbiddenResponse.openapi_response(examples=["endpoint"]),
40+
409: ConflictResponse.openapi_response(examples=["mcp server"]),
41+
500: InternalServerErrorResponse.openapi_response(examples=["configuration"]),
42+
503: ServiceUnavailableResponse.openapi_response(),
43+
}
44+
45+
46+
@router.post(
47+
"/mcp-servers",
48+
responses=register_responses,
49+
status_code=status.HTTP_201_CREATED,
50+
)
51+
@authorize(Action.REGISTER_MCP_SERVER)
52+
async def register_mcp_server_handler(
53+
request: Request,
54+
body: MCPServerRegistrationRequest,
55+
auth: Annotated[AuthTuple, Depends(get_auth_dependency())],
56+
) -> MCPServerRegistrationResponse:
57+
"""Register an MCP server dynamically at runtime.
58+
59+
Adds the MCP server to the runtime configuration and registers it
60+
as a toolgroup with Llama Stack so it becomes available for queries.
61+
62+
Raises:
63+
HTTPException: On duplicate name, Llama Stack connection error,
64+
or registration failure.
65+
66+
Returns:
67+
MCPServerRegistrationResponse: Details of the newly registered server.
68+
"""
69+
_ = auth
70+
_ = request
71+
72+
check_configuration_loaded(configuration)
73+
74+
mcp_server = ModelContextProtocolServer(
75+
name=body.name,
76+
url=body.url,
77+
provider_id=body.provider_id,
78+
authorization_headers=body.authorization_headers or {},
79+
headers=body.headers or [],
80+
timeout=body.timeout,
81+
)
82+
83+
try:
84+
configuration.add_mcp_server(mcp_server)
85+
except ValueError as e:
86+
response = ConflictResponse(resource="MCP server", resource_id=body.name)
87+
raise HTTPException(**response.model_dump()) from e
88+
89+
try:
90+
client = AsyncLlamaStackClientHolder().get_client()
91+
await client.toolgroups.register( # pyright: ignore[reportDeprecated]
92+
toolgroup_id=mcp_server.name,
93+
provider_id=mcp_server.provider_id,
94+
mcp_endpoint={"uri": mcp_server.url},
95+
)
96+
except APIConnectionError as e:
97+
configuration.remove_mcp_server(body.name)
98+
logger.error("Failed to register MCP server with Llama Stack: %s", e)
99+
response = ServiceUnavailableResponse(backend_name="Llama Stack", cause=str(e))
100+
raise HTTPException(**response.model_dump()) from e
101+
except Exception as e: # pylint: disable=broad-exception-caught
102+
configuration.remove_mcp_server(body.name)
103+
logger.error("Failed to register MCP toolgroup: %s", e)
104+
error_response = InternalServerErrorResponse(
105+
response="Failed to register MCP server",
106+
cause=str(e),
107+
)
108+
raise HTTPException(**error_response.model_dump()) from e
109+
110+
logger.info("Dynamically registered MCP server: %s at %s", body.name, body.url)
111+
112+
return MCPServerRegistrationResponse(
113+
name=mcp_server.name,
114+
url=mcp_server.url,
115+
provider_id=mcp_server.provider_id,
116+
message=f"MCP server '{mcp_server.name}' registered successfully",
117+
)
118+
119+
120+
list_responses: dict[int | str, dict[str, Any]] = {
121+
200: MCPServerListResponse.openapi_response(),
122+
401: UnauthorizedResponse.openapi_response(
123+
examples=["missing header", "missing token"]
124+
),
125+
403: ForbiddenResponse.openapi_response(examples=["endpoint"]),
126+
500: InternalServerErrorResponse.openapi_response(examples=["configuration"]),
127+
}
128+
129+
130+
@router.get("/mcp-servers", responses=list_responses)
131+
@authorize(Action.LIST_MCP_SERVERS)
132+
async def list_mcp_servers_handler(
133+
request: Request,
134+
auth: Annotated[AuthTuple, Depends(get_auth_dependency())],
135+
) -> MCPServerListResponse:
136+
"""List all registered MCP servers.
137+
138+
Returns both statically configured (from YAML) and dynamically
139+
registered (via API) MCP servers.
140+
141+
Raises:
142+
HTTPException: If configuration is not loaded.
143+
144+
Returns:
145+
MCPServerListResponse: List of all registered MCP servers with source info.
146+
"""
147+
_ = auth
148+
_ = request
149+
150+
check_configuration_loaded(configuration)
151+
152+
servers = []
153+
for mcp in configuration.mcp_servers:
154+
source = "api" if configuration.is_dynamic_mcp_server(mcp.name) else "config"
155+
servers.append(
156+
MCPServerInfo(
157+
name=mcp.name,
158+
url=mcp.url,
159+
provider_id=mcp.provider_id,
160+
source=source,
161+
)
162+
)
163+
164+
return MCPServerListResponse(servers=servers)
165+
166+
167+
delete_responses: dict[int | str, dict[str, Any]] = {
168+
200: MCPServerDeleteResponse.openapi_response(),
169+
401: UnauthorizedResponse.openapi_response(
170+
examples=["missing header", "missing token"]
171+
),
172+
403: ForbiddenResponse.openapi_response(examples=["endpoint"]),
173+
404: NotFoundResponse.openapi_response(examples=["mcp server"]),
174+
500: InternalServerErrorResponse.openapi_response(examples=["configuration"]),
175+
503: ServiceUnavailableResponse.openapi_response(),
176+
}
177+
178+
179+
@router.delete("/mcp-servers/{name}", responses=delete_responses)
180+
@authorize(Action.DELETE_MCP_SERVER)
181+
async def delete_mcp_server_handler(
182+
request: Request,
183+
name: str,
184+
auth: Annotated[AuthTuple, Depends(get_auth_dependency())],
185+
) -> MCPServerDeleteResponse:
186+
"""Unregister a dynamically registered MCP server.
187+
188+
Removes the MCP server from the runtime configuration and unregisters
189+
its toolgroup from Llama Stack. Only servers registered via the API
190+
can be deleted; statically configured servers cannot be removed.
191+
192+
Raises:
193+
HTTPException: If the server is not found, is statically configured,
194+
or Llama Stack unregistration fails.
195+
196+
Returns:
197+
MCPServerDeleteResponse: Confirmation of the deletion.
198+
"""
199+
_ = auth
200+
_ = request
201+
202+
check_configuration_loaded(configuration)
203+
204+
if not configuration.is_dynamic_mcp_server(name):
205+
found = any(s.name == name for s in configuration.mcp_servers)
206+
if found:
207+
response = ForbiddenResponse(
208+
response="Cannot delete statically configured MCP server",
209+
cause=f"MCP server '{name}' was configured in lightspeed-stack.yaml "
210+
"and cannot be removed via the API.",
211+
)
212+
else:
213+
response = NotFoundResponse(resource="MCP server", resource_id=name)
214+
raise HTTPException(**response.model_dump())
215+
216+
try:
217+
client = AsyncLlamaStackClientHolder().get_client()
218+
await client.toolgroups.unregister( # pyright: ignore[reportDeprecated]
219+
toolgroup_id=name
220+
)
221+
except APIConnectionError as e:
222+
logger.error("Failed to unregister MCP toolgroup from Llama Stack: %s", e)
223+
svc_response = ServiceUnavailableResponse(
224+
backend_name="Llama Stack", cause=str(e)
225+
)
226+
raise HTTPException(**svc_response.model_dump()) from e
227+
except Exception as e: # pylint: disable=broad-exception-caught
228+
logger.warning(
229+
"Llama Stack toolgroup unregister failed for '%s', "
230+
"proceeding with local removal: %s",
231+
name,
232+
e,
233+
)
234+
235+
try:
236+
configuration.remove_mcp_server(name)
237+
except ValueError as e:
238+
logger.error("Failed to remove MCP server from configuration: %s", e)
239+
response = NotFoundResponse(resource="MCP server", resource_id=name)
240+
raise HTTPException(**response.model_dump()) from e
241+
242+
logger.info("Dynamically unregistered MCP server: %s", name)
243+
244+
return MCPServerDeleteResponse(
245+
name=name,
246+
message=f"MCP server '{name}' unregistered successfully",
247+
)

src/app/routers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
metrics,
2121
tools,
2222
mcp_auth,
23+
mcp_servers,
2324
# Query endpoints for Response API support
2425
query,
2526
# RHEL Lightspeed rlsapi v1 compatibility
@@ -48,6 +49,7 @@ def include_routers(app: FastAPI) -> None:
4849
app.include_router(models.router, prefix="/v1")
4950
app.include_router(tools.router, prefix="/v1")
5051
app.include_router(mcp_auth.router, prefix="/v1")
52+
app.include_router(mcp_servers.router, prefix="/v1")
5153
app.include_router(shields.router, prefix="/v1")
5254
app.include_router(providers.router, prefix="/v1")
5355
app.include_router(rags.router, prefix="/v1")

src/configuration.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def __init__(self) -> None:
6464
self._conversation_cache: Optional[Cache] = None
6565
self._quota_limiters: list[QuotaLimiter] = []
6666
self._token_usage_history: Optional[TokenUsageHistory] = None
67+
self._dynamic_mcp_server_names: set[str] = set()
6768

6869
def load_configuration(self, filename: str) -> None:
6970
"""Load configuration from YAML file.
@@ -165,6 +166,67 @@ def mcp_servers(self) -> list[ModelContextProtocolServer]:
165166
raise LogicError("logic error: configuration is not loaded")
166167
return self._configuration.mcp_servers
167168

169+
@property
170+
def dynamic_mcp_server_names(self) -> set[str]:
171+
"""Return the set of dynamically registered MCP server names.
172+
173+
Returns:
174+
set[str]: Names of MCP servers added via the API (not from config file).
175+
"""
176+
return self._dynamic_mcp_server_names
177+
178+
def add_mcp_server(self, mcp_server: ModelContextProtocolServer) -> None:
179+
"""Add an MCP server to the runtime configuration.
180+
181+
Parameters:
182+
mcp_server: The MCP server configuration to add.
183+
184+
Raises:
185+
LogicError: If the configuration has not been loaded.
186+
ValueError: If an MCP server with the same name already exists.
187+
"""
188+
if self._configuration is None:
189+
raise LogicError("logic error: configuration is not loaded")
190+
for existing in self._configuration.mcp_servers:
191+
if existing.name == mcp_server.name:
192+
raise ValueError(
193+
f"MCP server with name '{mcp_server.name}' already exists"
194+
)
195+
self._configuration.mcp_servers.append(mcp_server)
196+
self._dynamic_mcp_server_names.add(mcp_server.name)
197+
198+
def remove_mcp_server(self, name: str) -> None:
199+
"""Remove a dynamically registered MCP server from the runtime configuration.
200+
201+
Parameters:
202+
name: The name of the MCP server to remove.
203+
204+
Raises:
205+
LogicError: If the configuration has not been loaded.
206+
ValueError: If the server was not found or was statically configured.
207+
"""
208+
if self._configuration is None:
209+
raise LogicError("logic error: configuration is not loaded")
210+
if name not in self._dynamic_mcp_server_names:
211+
raise ValueError(
212+
f"MCP server '{name}' was not dynamically registered or does not exist"
213+
)
214+
self._configuration.mcp_servers = [
215+
s for s in self._configuration.mcp_servers if s.name != name
216+
]
217+
self._dynamic_mcp_server_names.discard(name)
218+
219+
def is_dynamic_mcp_server(self, name: str) -> bool:
220+
"""Check if an MCP server was dynamically registered.
221+
222+
Parameters:
223+
name: The name of the MCP server.
224+
225+
Returns:
226+
bool: True if the server was registered via the API.
227+
"""
228+
return name in self._dynamic_mcp_server_names
229+
168230
@property
169231
def authentication_configuration(self) -> AuthenticationConfiguration:
170232
"""Return authentication configuration.

src/models/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,11 @@ class Action(str, Enum):
10191019
# RHEL Lightspeed rlsapi v1 compatibility - stateless inference (no history/RAG)
10201020
RLSAPI_V1_INFER = "rlsapi_v1_infer"
10211021

1022+
# Dynamic MCP server management
1023+
REGISTER_MCP_SERVER = "register_mcp_server"
1024+
LIST_MCP_SERVERS = "list_mcp_servers"
1025+
DELETE_MCP_SERVER = "delete_mcp_server"
1026+
10221027
# A2A (Agent-to-Agent) protocol actions
10231028
A2A_AGENT_CARD = "a2a_agent_card"
10241029
A2A_TASK_EXECUTION = "a2a_task_execution"

0 commit comments

Comments
 (0)