Skip to content

Commit 33408fd

Browse files
author
Vicky Kuo
committed
Implement metrics enhancements and testing scripts for issue #699
Signed-off-by: Vicky Kuo <[email protected]>
1 parent 8b7dd2b commit 33408fd

File tree

10 files changed

+686
-54
lines changed

10 files changed

+686
-54
lines changed

mcpgateway/admin.py

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@
2323
from datetime import datetime
2424
from functools import wraps
2525
import io
26+
from io import StringIO
2627
import json
2728
from pathlib import Path
2829
import time
2930
from typing import Any, cast, Dict, List, Optional, Union
3031
import uuid
3132

3233
# Third-Party
33-
from fastapi import APIRouter, Depends, HTTPException, Request, Response
34+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
3435
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
3536
import httpx
3637
from pydantic import ValidationError
@@ -670,7 +671,6 @@ async def admin_edit_server(
670671
update operation.
671672
672673
Expects form fields:
673-
- id (optional): Updated UUID for the server
674674
- name (optional): The updated name of the server
675675
- description (optional): An updated description of the server's purpose
676676
- icon (optional): Updated URL or path to the server's icon
@@ -779,7 +779,6 @@ async def admin_edit_server(
779779
try:
780780
LOGGER.debug(f"User {user} is editing server ID {server_id} with name: {form.get('name')}")
781781
server = ServerUpdate(
782-
id=form.get("id"),
783782
name=form.get("name"),
784783
description=form.get("description"),
785784
icon=form.get("icon"),
@@ -1981,7 +1980,6 @@ async def admin_add_tool(
19811980

19821981
tool_data: dict[str, Any] = {
19831982
"name": form.get("name"),
1984-
"displayName": form.get("displayName"),
19851983
"url": form.get("url"),
19861984
"description": form.get("description"),
19871985
"request_type": request_type,
@@ -2047,7 +2045,6 @@ async def admin_edit_tool(
20472045
20482046
Expects form fields:
20492047
- name
2050-
- displayName (optional)
20512048
- url
20522049
- description (optional)
20532050
- requestType (to be mapped to request_type)
@@ -2222,7 +2219,6 @@ async def admin_edit_tool(
22222219

22232220
tool_data: dict[str, Any] = {
22242221
"name": form.get("name"),
2225-
"displayName": form.get("displayName"),
22262222
"custom_name": form.get("customName"),
22272223
"url": form.get("url"),
22282224
"description": form.get("description"),
@@ -4155,6 +4151,8 @@ async def admin_delete_root(uri: str, request: Request, user: str = Depends(requ
41554151
# Metrics
41564152
MetricsDict = Dict[str, Union[ToolMetrics, ResourceMetrics, ServerMetrics, PromptMetrics]]
41574153

4154+
# Import the response time formatting function
4155+
from mcpgateway.utils.metrics_common import format_response_time
41584156

41594157
# @admin_router.get("/metrics", response_model=MetricsDict)
41604158
# async def admin_get_metrics(
@@ -4233,6 +4231,120 @@ async def get_aggregated_metrics(
42334231
return metrics
42344232

42354233

4234+
@admin_router.get("/metrics/export", response_class=Response)
4235+
async def export_metrics_csv(
4236+
db: Session = Depends(get_db),
4237+
entity_type: str = Query(..., description="Entity type to export (tools, resources, prompts, servers)"),
4238+
limit: Optional[int] = Query(None, description="Maximum number of results to return. If not provided, all results are returned."),
4239+
user: str = Depends(require_auth),
4240+
) -> Response:
4241+
"""Export metrics for a specific entity type to CSV format.
4242+
4243+
This endpoint retrieves performance metrics for the specified entity type and
4244+
exports them to CSV format for download. All rows are exported, not just the top 5.
4245+
Response times are formatted to 3 decimal places.
4246+
4247+
Args:
4248+
db (Session): Database session dependency for querying metrics.
4249+
entity_type (str): Type of entity to export (tools, resources, prompts, servers).
4250+
limit (Optional[int]): Maximum number of results to return. If None, all results are returned.
4251+
user (str): Authenticated user.
4252+
4253+
Returns:
4254+
Response: CSV file download response containing the metrics data.
4255+
4256+
Raises:
4257+
HTTPException: If the entity type is invalid.
4258+
"""
4259+
LOGGER.debug(f"User {user} requested CSV export of {entity_type} metrics")
4260+
4261+
# Validate entity type
4262+
valid_types = ["tools", "resources", "prompts", "servers"]
4263+
if entity_type not in valid_types:
4264+
raise HTTPException(status_code=400, detail=f"Invalid entity type. Must be one of: {', '.join(valid_types)}")
4265+
4266+
# Get the top performers for the requested entity type without limit to get all rows
4267+
try:
4268+
if entity_type == "tools":
4269+
if limit:
4270+
performers = await tool_service.get_top_tools(db, limit=limit)
4271+
else:
4272+
performers = await tool_service.get_top_tools(db, limit=None)
4273+
elif entity_type == "resources":
4274+
if limit:
4275+
performers = await resource_service.get_top_resources(db, limit=limit)
4276+
else:
4277+
performers = await resource_service.get_top_resources(db, limit=None)
4278+
elif entity_type == "prompts":
4279+
if limit:
4280+
performers = await prompt_service.get_top_prompts(db, limit=limit)
4281+
else:
4282+
performers = await prompt_service.get_top_prompts(db, limit=None)
4283+
elif entity_type == "servers":
4284+
if limit:
4285+
performers = await server_service.get_top_servers(db, limit=limit)
4286+
else:
4287+
performers = await server_service.get_top_servers(db, limit=None)
4288+
except Exception as e:
4289+
LOGGER.error(f"Error exporting {entity_type} metrics to CSV: {str(e)}")
4290+
raise HTTPException(status_code=500, detail=f"Failed to export metrics: {str(e)}")
4291+
4292+
# Handle empty data case
4293+
if not performers:
4294+
# Return empty CSV with headers
4295+
csv_content = "ID,Name,Execution Count,Average Response Time (s),Success Rate (%),Last Execution\n"
4296+
return Response(
4297+
content=csv_content,
4298+
media_type="text/csv",
4299+
headers={"Content-Disposition": f"attachment; filename={entity_type}_metrics.csv"}
4300+
)
4301+
4302+
# Create CSV content
4303+
output = StringIO()
4304+
writer = csv.writer(output)
4305+
4306+
# Write header row
4307+
writer.writerow([
4308+
"ID",
4309+
"Name",
4310+
"Execution Count",
4311+
"Average Response Time (s)",
4312+
"Success Rate (%)",
4313+
"Last Execution"
4314+
])
4315+
4316+
# Write data rows with formatted values
4317+
for performer in performers:
4318+
# Format response time to 3 decimal places
4319+
formatted_response_time = format_response_time(performer.avg_response_time) if performer.avg_response_time is not None else "N/A"
4320+
4321+
# Format success rate
4322+
success_rate = f"{performer.success_rate:.1f}" if performer.success_rate is not None else "N/A"
4323+
4324+
# Format timestamp
4325+
last_execution = performer.last_execution.isoformat() if performer.last_execution else "N/A"
4326+
4327+
writer.writerow([
4328+
performer.id,
4329+
performer.name,
4330+
performer.execution_count,
4331+
formatted_response_time,
4332+
success_rate,
4333+
last_execution
4334+
])
4335+
4336+
# Get the CSV content as a string
4337+
csv_content = output.getvalue()
4338+
output.close()
4339+
4340+
# Return CSV response
4341+
return Response(
4342+
content=csv_content,
4343+
media_type="text/csv",
4344+
headers={"Content-Disposition": f"attachment; filename={entity_type}_metrics.csv"}
4345+
)
4346+
4347+
42364348
@admin_router.post("/metrics/reset", response_model=Dict[str, object])
42374349
async def admin_reset_metrics(db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, object]:
42384350
"""

mcpgateway/services/prompt_service.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ async def shutdown(self) -> None:
142142
self._event_subscribers.clear()
143143
logger.info("Prompt service shutdown complete")
144144

145-
async def get_top_prompts(self, db: Session, limit: int = 5) -> List[TopPerformer]:
145+
async def get_top_prompts(self, db: Session, limit: Optional[int] = 5) -> List[TopPerformer]:
146146
"""Retrieve the top-performing prompts based on execution count.
147147
148148
Queries the database to get prompts with their metrics, ordered by the number of executions
@@ -151,7 +151,8 @@ async def get_top_prompts(self, db: Session, limit: int = 5) -> List[TopPerforme
151151
152152
Args:
153153
db (Session): Database session for querying prompt metrics.
154-
limit (int): Maximum number of prompts to return. Defaults to 5.
154+
limit (Optional[int]): Maximum number of prompts to return. Defaults to 5.
155+
If None, returns all prompts.
155156
156157
Returns:
157158
List[TopPerformer]: A list of TopPerformer objects, each containing:
@@ -162,7 +163,7 @@ async def get_top_prompts(self, db: Session, limit: int = 5) -> List[TopPerforme
162163
- success_rate: Success rate percentage, or None if no metrics.
163164
- last_execution: Timestamp of the last execution, or None if no metrics.
164165
"""
165-
results = (
166+
query = (
166167
db.query(
167168
DbPrompt.id,
168169
DbPrompt.name,
@@ -180,9 +181,12 @@ async def get_top_prompts(self, db: Session, limit: int = 5) -> List[TopPerforme
180181
.outerjoin(PromptMetric)
181182
.group_by(DbPrompt.id, DbPrompt.name)
182183
.order_by(desc("execution_count"))
183-
.limit(limit)
184-
.all()
185184
)
185+
186+
if limit is not None:
187+
query = query.limit(limit)
188+
189+
results = query.all()
186190

187191
return build_top_performers(results)
188192

mcpgateway/services/resource_service.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ async def shutdown(self) -> None:
137137
self._event_subscribers.clear()
138138
logger.info("Resource service shutdown complete")
139139

140-
async def get_top_resources(self, db: Session, limit: int = 5) -> List[TopPerformer]:
140+
async def get_top_resources(self, db: Session, limit: Optional[int] = 5) -> List[TopPerformer]:
141141
"""Retrieve the top-performing resources based on execution count.
142142
143143
Queries the database to get resources with their metrics, ordered by the number of executions
@@ -146,7 +146,8 @@ async def get_top_resources(self, db: Session, limit: int = 5) -> List[TopPerfor
146146
147147
Args:
148148
db (Session): Database session for querying resource metrics.
149-
limit (int): Maximum number of resources to return. Defaults to 5.
149+
limit (Optional[int]): Maximum number of resources to return. Defaults to 5.
150+
If None, returns all resources.
150151
151152
Returns:
152153
List[TopPerformer]: A list of TopPerformer objects, each containing:
@@ -157,7 +158,7 @@ async def get_top_resources(self, db: Session, limit: int = 5) -> List[TopPerfor
157158
- success_rate: Success rate percentage, or None if no metrics.
158159
- last_execution: Timestamp of the last execution, or None if no metrics.
159160
"""
160-
results = (
161+
query = (
161162
db.query(
162163
DbResource.id,
163164
DbResource.uri.label("name"), # Using URI as the name field for TopPerformer
@@ -175,9 +176,12 @@ async def get_top_resources(self, db: Session, limit: int = 5) -> List[TopPerfor
175176
.outerjoin(ResourceMetric)
176177
.group_by(DbResource.id, DbResource.uri)
177178
.order_by(desc("execution_count"))
178-
.limit(limit)
179-
.all()
180179
)
180+
181+
if limit is not None:
182+
query = query.limit(limit)
183+
184+
results = query.all()
181185

182186
return build_top_performers(results)
183187

mcpgateway/services/server_service.py

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ async def shutdown(self) -> None:
128128
logger.info("Server service shutdown complete")
129129

130130
# get_top_server
131-
async def get_top_servers(self, db: Session, limit: int = 5) -> List[TopPerformer]:
131+
async def get_top_servers(self, db: Session, limit: Optional[int] = 5) -> List[TopPerformer]:
132132
"""Retrieve the top-performing servers based on execution count.
133133
134134
Queries the database to get servers with their metrics, ordered by the number of executions
@@ -137,7 +137,8 @@ async def get_top_servers(self, db: Session, limit: int = 5) -> List[TopPerforme
137137
138138
Args:
139139
db (Session): Database session for querying server metrics.
140-
limit (int): Maximum number of servers to return. Defaults to 5.
140+
limit (Optional[int]): Maximum number of servers to return. Defaults to 5.
141+
If None, returns all servers.
141142
142143
Returns:
143144
List[TopPerformer]: A list of TopPerformer objects, each containing:
@@ -148,7 +149,7 @@ async def get_top_servers(self, db: Session, limit: int = 5) -> List[TopPerforme
148149
- success_rate: Success rate percentage, or None if no metrics.
149150
- last_execution: Timestamp of the last execution, or None if no metrics.
150151
"""
151-
results = (
152+
query = (
152153
db.query(
153154
DbServer.id,
154155
DbServer.name,
@@ -166,9 +167,14 @@ async def get_top_servers(self, db: Session, limit: int = 5) -> List[TopPerforme
166167
.outerjoin(ServerMetric)
167168
.group_by(DbServer.id, DbServer.name)
168169
.order_by(desc("execution_count"))
169-
.limit(limit)
170-
.all()
171170
)
171+
172+
if limit is not None:
173+
query = query.limit(limit)
174+
175+
results = query.all()
176+
177+
return build_top_performers(results)
172178

173179
return build_top_performers(results)
174180

@@ -313,10 +319,6 @@ async def register_server(self, db: Session, server_in: ServerCreate) -> ServerR
313319
is_active=True,
314320
tags=server_in.tags or [],
315321
)
316-
317-
# Set custom UUID if provided
318-
if server_in.id:
319-
db_server.id = server_in.id
320322
db.add(db_server)
321323

322324
# Associate tools, verifying each exists.
@@ -501,17 +503,14 @@ async def update_server(self, db: Session, server_id: str, server_update: Server
501503
>>> service = ServerService()
502504
>>> db = MagicMock()
503505
>>> server = MagicMock()
504-
>>> server.id = 'server_id'
505506
>>> db.get.return_value = server
506507
>>> db.commit = MagicMock()
507508
>>> db.refresh = MagicMock()
508509
>>> db.execute.return_value.scalar_one_or_none.return_value = None
509510
>>> service._convert_server_to_read = MagicMock(return_value='server_read')
510511
>>> ServerRead.model_validate = MagicMock(return_value='server_read')
511-
>>> server_update = MagicMock()
512-
>>> server_update.id = None # No UUID change
513512
>>> import asyncio
514-
>>> asyncio.run(service.update_server(db, 'server_id', server_update))
513+
>>> asyncio.run(service.update_server(db, 'server_id', MagicMock()))
515514
'server_read'
516515
"""
517516
try:
@@ -530,12 +529,6 @@ async def update_server(self, db: Session, server_id: str, server_update: Server
530529
)
531530

532531
# Update simple fields
533-
if server_update.id is not None and server_update.id != server.id:
534-
# Check if the new UUID is already in use
535-
existing = db.get(DbServer, server_update.id)
536-
if existing:
537-
raise ServerError(f"Server with ID {server_update.id} already exists")
538-
server.id = server_update.id
539532
if server_update.name is not None:
540533
server.name = server_update.name
541534
if server_update.description is not None:

0 commit comments

Comments
 (0)