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.
@@ -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
+ ]
@@ -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
@@ -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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any