mcpfleet-obs 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcpfleet_obs/__init__.py +17 -0
- mcpfleet_obs/clients.py +29 -0
- mcpfleet_obs/errors.py +62 -0
- mcpfleet_obs/install.py +32 -0
- mcpfleet_obs/metrics.py +82 -0
- mcpfleet_obs-0.1.0.dist-info/METADATA +19 -0
- mcpfleet_obs-0.1.0.dist-info/RECORD +8 -0
- mcpfleet_obs-0.1.0.dist-info/WHEEL +4 -0
mcpfleet_obs/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Fleet-wide observability for MCP servers: error taxonomy, metrics middleware, client tracking."""
|
|
2
|
+
|
|
3
|
+
from .clients import ClientTrackingMiddleware
|
|
4
|
+
from .errors import FleetErrorPayload, parse_error_payload, raise_http_tool_error, raise_tool_error
|
|
5
|
+
from .install import install
|
|
6
|
+
from .metrics import FleetMetricsMiddleware, make_metrics
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"install",
|
|
10
|
+
"make_metrics",
|
|
11
|
+
"FleetMetricsMiddleware",
|
|
12
|
+
"ClientTrackingMiddleware",
|
|
13
|
+
"raise_tool_error",
|
|
14
|
+
"raise_http_tool_error",
|
|
15
|
+
"parse_error_payload",
|
|
16
|
+
"FleetErrorPayload",
|
|
17
|
+
]
|
mcpfleet_obs/clients.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Client tracking middleware."""
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
5
|
+
|
|
6
|
+
from .metrics import REGION, TRANSPORT, FleetMetrics
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClientTrackingMiddleware(Middleware):
|
|
10
|
+
"""Track MCP client connections and increment counter on initialize.
|
|
11
|
+
|
|
12
|
+
Logs client identity (name, version) and transport context on every initialize handshake.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, metrics: FleetMetrics, *, logger_name: str):
|
|
16
|
+
self.metrics = metrics
|
|
17
|
+
self._log = logging.getLogger(logger_name)
|
|
18
|
+
|
|
19
|
+
async def on_request(self, context: MiddlewareContext, call_next):
|
|
20
|
+
result = await call_next(context)
|
|
21
|
+
if context.method == "initialize":
|
|
22
|
+
params = context.message.params
|
|
23
|
+
info = getattr(params, "clientInfo", None)
|
|
24
|
+
client_name = getattr(info, "name", "unknown") or "unknown"
|
|
25
|
+
client_version = getattr(info, "version", "unknown") or "unknown"
|
|
26
|
+
self._log.info("client_connected client=%s version=%s transport=%s region=%s",
|
|
27
|
+
client_name, client_version, TRANSPORT, REGION)
|
|
28
|
+
self.metrics.client_connections_total.labels(client_name, client_version, TRANSPORT, REGION).inc()
|
|
29
|
+
return result
|
mcpfleet_obs/errors.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Fleet error taxonomy. Payload contract verified against fastmcp 3.2.3-4.0.0b1
|
|
2
|
+
(probes/fastmcp-error-shapes/PROBE-REPORT.md): ToolError messages survive
|
|
3
|
+
mask_error_details=True and create_proxy() hops verbatim."""
|
|
4
|
+
from typing import Literal, NoReturn
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from fastmcp.exceptions import ToolError
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
ErrorCategory = Literal[
|
|
11
|
+
"transient", "validation", "not_found", "auth_required", "configuration", "unknown"
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FleetErrorPayload(BaseModel):
|
|
16
|
+
error_category: ErrorCategory
|
|
17
|
+
is_retryable: bool
|
|
18
|
+
attempted: str
|
|
19
|
+
description: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def raise_tool_error(category: ErrorCategory, *, is_retryable: bool,
|
|
23
|
+
attempted: str, description: str) -> NoReturn:
|
|
24
|
+
raise ToolError(FleetErrorPayload(
|
|
25
|
+
error_category=category, is_retryable=is_retryable,
|
|
26
|
+
attempted=attempted, description=description,
|
|
27
|
+
).model_dump_json())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def raise_http_tool_error(exc: Exception, *, attempted: str) -> NoReturn:
|
|
31
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
32
|
+
status = exc.response.status_code
|
|
33
|
+
url = exc.request.url
|
|
34
|
+
if status == 404:
|
|
35
|
+
raise_tool_error("not_found", is_retryable=False, attempted=attempted,
|
|
36
|
+
description=f"Resource not found (404). Check the identifier. URL: {url}")
|
|
37
|
+
if status in (401, 403):
|
|
38
|
+
raise_tool_error("auth_required", is_retryable=False, attempted=attempted,
|
|
39
|
+
description=f"Access denied ({status}). URL: {url}")
|
|
40
|
+
if status in (429, 503):
|
|
41
|
+
raise_tool_error("transient", is_retryable=True, attempted=attempted,
|
|
42
|
+
description=f"Upstream returned {status} — retry after a short delay. URL: {url}")
|
|
43
|
+
if 400 <= status < 500:
|
|
44
|
+
raise_tool_error("validation", is_retryable=False, attempted=attempted,
|
|
45
|
+
description=f"Upstream rejected the request ({status}) — fix the input and retry. URL: {url}")
|
|
46
|
+
raise_tool_error("unknown", is_retryable=False, attempted=attempted,
|
|
47
|
+
description=f"Upstream returned {status}. URL: {url}")
|
|
48
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
49
|
+
raise_tool_error("transient", is_retryable=True, attempted=attempted,
|
|
50
|
+
description="Request timed out — upstream may be slow, retry is safe.")
|
|
51
|
+
if isinstance(exc, httpx.ConnectError):
|
|
52
|
+
raise_tool_error("transient", is_retryable=True, attempted=attempted,
|
|
53
|
+
description="Could not connect to upstream API — network error, retry is safe.")
|
|
54
|
+
raise_tool_error("unknown", is_retryable=False, attempted=attempted,
|
|
55
|
+
description=f"Unexpected error: {type(exc).__name__}: {exc}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_error_payload(message: str) -> FleetErrorPayload | None:
|
|
59
|
+
try:
|
|
60
|
+
return FleetErrorPayload.model_validate_json(message)
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
mcpfleet_obs/install.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""One-call installation of observability middleware stack."""
|
|
2
|
+
from fastmcp import FastMCP
|
|
3
|
+
from prometheus_client import CONTENT_TYPE_LATEST, REGISTRY, CollectorRegistry, generate_latest
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.responses import Response
|
|
6
|
+
|
|
7
|
+
from .clients import ClientTrackingMiddleware
|
|
8
|
+
from .metrics import FleetMetrics, FleetMetricsMiddleware, make_metrics
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def install(mcp: FastMCP, *, prefix: str,
|
|
12
|
+
registry: CollectorRegistry | None = None) -> FleetMetrics:
|
|
13
|
+
"""Install the fleet observability stack: client tracking, metrics, and /metrics endpoint.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
mcp: FastMCP instance to instrument
|
|
17
|
+
prefix: Prometheus metric name prefix (e.g. "uk_legal")
|
|
18
|
+
registry: Optional Prometheus registry (uses default if None)
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
FleetMetrics instance (for further configuration if needed)
|
|
22
|
+
"""
|
|
23
|
+
reg = registry if registry is not None else REGISTRY
|
|
24
|
+
metrics = make_metrics(prefix, registry=reg)
|
|
25
|
+
mcp.add_middleware(ClientTrackingMiddleware(metrics, logger_name=f"fastmcp.{prefix}.clients"))
|
|
26
|
+
mcp.add_middleware(FleetMetricsMiddleware(metrics))
|
|
27
|
+
|
|
28
|
+
@mcp.custom_route("/metrics", methods=["GET"])
|
|
29
|
+
async def metrics_endpoint(request: Request) -> Response:
|
|
30
|
+
return Response(generate_latest(reg), media_type=CONTENT_TYPE_LATEST)
|
|
31
|
+
|
|
32
|
+
return metrics
|
mcpfleet_obs/metrics.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Fleet-standard Prometheus metrics.
|
|
2
|
+
|
|
3
|
+
Dual-path error counting is REQUIRED, not defensive: on fastmcp 3.4.x a
|
|
4
|
+
proxied tool failure surfaces as a result with is_error=True and no
|
|
5
|
+
exception (probes/fastmcp-error-shapes/PROBE-REPORT.md, P3). Exception-only
|
|
6
|
+
middleware silently counts those as ok.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from fastmcp.exceptions import NotFoundError, ToolError
|
|
13
|
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
14
|
+
from prometheus_client import REGISTRY, CollectorRegistry, Counter, Histogram
|
|
15
|
+
|
|
16
|
+
from .errors import parse_error_payload
|
|
17
|
+
|
|
18
|
+
BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0)
|
|
19
|
+
TRANSPORT = os.getenv("FASTMCP_TRANSPORT", "http")
|
|
20
|
+
REGION = os.getenv("FLY_REGION", "local")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FleetMetrics:
|
|
25
|
+
tool_calls_total: Counter
|
|
26
|
+
tool_duration_seconds: Histogram
|
|
27
|
+
client_connections_total: Counter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_metrics(prefix: str, *, registry: CollectorRegistry | None = None) -> FleetMetrics:
|
|
31
|
+
registry = registry if registry is not None else REGISTRY
|
|
32
|
+
return FleetMetrics(
|
|
33
|
+
tool_calls_total=Counter(
|
|
34
|
+
f"{prefix}_tool_calls_total", "Count of MCP tool invocations.",
|
|
35
|
+
labelnames=["tool", "transport", "region", "status", "error_category"],
|
|
36
|
+
registry=registry),
|
|
37
|
+
tool_duration_seconds=Histogram(
|
|
38
|
+
f"{prefix}_tool_duration_seconds", "Tool invocation latency in seconds.",
|
|
39
|
+
labelnames=["tool", "transport", "region"], buckets=BUCKETS,
|
|
40
|
+
registry=registry),
|
|
41
|
+
client_connections_total=Counter(
|
|
42
|
+
f"{prefix}_client_connections_total", "Count of MCP client initialize handshakes.",
|
|
43
|
+
labelnames=["client_name", "client_version", "transport", "region"],
|
|
44
|
+
registry=registry),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FleetMetricsMiddleware(Middleware):
|
|
49
|
+
def __init__(self, metrics: FleetMetrics):
|
|
50
|
+
self.metrics = metrics
|
|
51
|
+
|
|
52
|
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
|
53
|
+
tool = context.message.name
|
|
54
|
+
t0 = time.perf_counter()
|
|
55
|
+
try:
|
|
56
|
+
result = await call_next(context)
|
|
57
|
+
except NotFoundError:
|
|
58
|
+
# Scanners probing bogus tool names must not mint metric series.
|
|
59
|
+
self.metrics.tool_calls_total.labels(
|
|
60
|
+
"_unknown_", TRANSPORT, REGION, "error", "not_found").inc()
|
|
61
|
+
raise
|
|
62
|
+
except ToolError as exc:
|
|
63
|
+
payload = parse_error_payload(str(exc))
|
|
64
|
+
category = payload.error_category if payload else "unknown"
|
|
65
|
+
self.metrics.tool_calls_total.labels(tool, TRANSPORT, REGION, "error", category).inc()
|
|
66
|
+
raise
|
|
67
|
+
except BaseException:
|
|
68
|
+
self.metrics.tool_calls_total.labels(tool, TRANSPORT, REGION, "error", "unknown").inc()
|
|
69
|
+
raise
|
|
70
|
+
finally:
|
|
71
|
+
self.metrics.tool_duration_seconds.labels(tool, TRANSPORT, REGION).observe(
|
|
72
|
+
time.perf_counter() - t0)
|
|
73
|
+
if getattr(result, "is_error", False):
|
|
74
|
+
payload = None
|
|
75
|
+
content = getattr(result, "content", None)
|
|
76
|
+
if content and getattr(content[0], "text", None):
|
|
77
|
+
payload = parse_error_payload(content[0].text)
|
|
78
|
+
category = payload.error_category if payload else "unknown"
|
|
79
|
+
self.metrics.tool_calls_total.labels(tool, TRANSPORT, REGION, "error", category).inc()
|
|
80
|
+
else:
|
|
81
|
+
self.metrics.tool_calls_total.labels(tool, TRANSPORT, REGION, "ok", "").inc()
|
|
82
|
+
return result
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcpfleet-obs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared error taxonomy, metrics, and client tracking for the mcpfleet MCP servers
|
|
5
|
+
Project-URL: Homepage, https://github.com/uk-agents/mcpfleet-obs
|
|
6
|
+
Project-URL: Repository, https://github.com/uk-agents/mcpfleet-obs
|
|
7
|
+
Author: Paul Boucher
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: fastmcp<4,>=3.2.3
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: prometheus-client>=0.20
|
|
13
|
+
Requires-Dist: pydantic>=2.7
|
|
14
|
+
Requires-Dist: starlette>=1.0.1
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# mcpfleet-obs
|
|
18
|
+
|
|
19
|
+
Shared error taxonomy, metrics middleware, and client tracking for the mcpfleet MCP servers. This package provides standardized error handling and Prometheus instrumentation for FastMCP v3+ applications in the mcpfleet ecosystem. See the error shapes probe report at `probes/fastmcp-error-shapes/PROBE-REPORT.md` in the mcpfleet workspace for detailed FastMCP error taxonomy research.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
mcpfleet_obs/__init__.py,sha256=ve9-WNKS82UlT8IZ2yS-GTwH3P2OnJRlbNomzApUxxg,552
|
|
2
|
+
mcpfleet_obs/clients.py,sha256=SHzQ_ACl2BIgjOjF5u7HbDo76m9lrmUuw-x4Rk_ZkbI,1249
|
|
3
|
+
mcpfleet_obs/errors.py,sha256=qMdqTwxPCE9F42-H1CJwgd-nq1dFObDDlfjLW1_y3qY,2938
|
|
4
|
+
mcpfleet_obs/install.py,sha256=NUyzmSSoJEyzegdUCJFSSOI7yd3HqZrMhUjXCi0CxFo,1349
|
|
5
|
+
mcpfleet_obs/metrics.py,sha256=ae6kiE0QMe_kC7BFgdu7gR-nYo4sIT9BtTuI_U49oYg,3509
|
|
6
|
+
mcpfleet_obs-0.1.0.dist-info/METADATA,sha256=iBNzrbir7ONSd-d7G2Cd0wr5PWHNHTsYfxgmfjPlDjA,938
|
|
7
|
+
mcpfleet_obs-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
mcpfleet_obs-0.1.0.dist-info/RECORD,,
|