mcpfleet-obs 0.1.0__tar.gz

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.
Files changed (27) hide show
  1. mcpfleet_obs-0.1.0/.github/workflows/ci.yml +35 -0
  2. mcpfleet_obs-0.1.0/.github/workflows/release.yml +20 -0
  3. mcpfleet_obs-0.1.0/.gitignore +4 -0
  4. mcpfleet_obs-0.1.0/PKG-INFO +19 -0
  5. mcpfleet_obs-0.1.0/README.md +3 -0
  6. mcpfleet_obs-0.1.0/pyproject.toml +34 -0
  7. mcpfleet_obs-0.1.0/src/mcpfleet_obs/__init__.py +17 -0
  8. mcpfleet_obs-0.1.0/src/mcpfleet_obs/clients.py +29 -0
  9. mcpfleet_obs-0.1.0/src/mcpfleet_obs/errors.py +62 -0
  10. mcpfleet_obs-0.1.0/src/mcpfleet_obs/install.py +32 -0
  11. mcpfleet_obs-0.1.0/src/mcpfleet_obs/metrics.py +82 -0
  12. mcpfleet_obs-0.1.0/tests/fixtures/error-shapes-3.2.3.json +214 -0
  13. mcpfleet_obs-0.1.0/tests/fixtures/error-shapes-3.2.4.json +214 -0
  14. mcpfleet_obs-0.1.0/tests/fixtures/error-shapes-3.3.0.json +214 -0
  15. mcpfleet_obs-0.1.0/tests/fixtures/error-shapes-3.4.5.json +214 -0
  16. mcpfleet_obs-0.1.0/tests/fixtures/error-shapes-4.0.0b1.json +258 -0
  17. mcpfleet_obs-0.1.0/tests/fixtures/proxy-passthrough-3.2.4.json +106 -0
  18. mcpfleet_obs-0.1.0/tests/fixtures/proxy-passthrough-3.4.5.json +106 -0
  19. mcpfleet_obs-0.1.0/tests/fixtures/schema-shapes-3.2.3.json +73 -0
  20. mcpfleet_obs-0.1.0/tests/fixtures/schema-shapes-3.2.4.json +73 -0
  21. mcpfleet_obs-0.1.0/tests/fixtures/schema-shapes-3.4.5.json +73 -0
  22. mcpfleet_obs-0.1.0/tests/fixtures/uk-legal-schemas-3.2.3.json +1328 -0
  23. mcpfleet_obs-0.1.0/tests/fixtures/uk-legal-schemas-3.4.5.json +1328 -0
  24. mcpfleet_obs-0.1.0/tests/test_errors.py +70 -0
  25. mcpfleet_obs-0.1.0/tests/test_install.py +48 -0
  26. mcpfleet_obs-0.1.0/tests/test_metrics.py +113 -0
  27. mcpfleet_obs-0.1.0/uv.lock +1582 -0
@@ -0,0 +1,35 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ name: pytest (fastmcp ${{ matrix.fastmcp }})
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ fastmcp: ["3.2.3", "3.2.4", "3.3.0", "3.4.5"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v5
18
+ with:
19
+ python-version: "3.12"
20
+ - run: uv sync --group dev
21
+ - run: uv pip install "fastmcp==${{ matrix.fastmcp }}"
22
+ - run: uv run --no-sync pytest tests/ -q
23
+
24
+ test-fastmcp-4-beta:
25
+ name: pytest (fastmcp 4.0.0b1, allowed failure)
26
+ runs-on: ubuntu-latest
27
+ continue-on-error: true
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - uses: astral-sh/setup-uv@v5
31
+ with:
32
+ python-version: "3.12"
33
+ - run: uv sync --group dev
34
+ - run: uv pip install --prerelease=allow "fastmcp==4.0.0b1"
35
+ - run: uv run --no-sync pytest tests/ -q
@@ -0,0 +1,20 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ name: Publish to PyPI
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ - run: uv build
18
+ - uses: pypa/gh-action-pypi-publish@release/v1
19
+ with:
20
+ skip_existing: true
@@ -0,0 +1,4 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ .pytest_cache/
@@ -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,3 @@
1
+ # mcpfleet-obs
2
+
3
+ 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,34 @@
1
+ [project]
2
+ name = "mcpfleet-obs"
3
+ version = "0.1.0"
4
+ description = "Shared error taxonomy, metrics, and client tracking for the mcpfleet MCP servers"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Paul Boucher" },
10
+ ]
11
+ dependencies = [
12
+ "fastmcp>=3.2.3,<4",
13
+ "prometheus-client>=0.20",
14
+ "pydantic>=2.7",
15
+ "httpx>=0.27",
16
+ "starlette>=1.0.1",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/uk-agents/mcpfleet-obs"
21
+ Repository = "https://github.com/uk-agents/mcpfleet-obs"
22
+
23
+ [dependency-groups]
24
+ dev = ["pytest>=8", "pytest-asyncio>=0.24"]
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/mcpfleet_obs"]
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
@@ -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
@@ -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,214 @@
1
+ {
2
+ "fastmcp_version": "3.2.3",
3
+ "python": "3.12.11",
4
+ "cases": [
5
+ {
6
+ "tool": "ok_tool",
7
+ "mask_error_details": true,
8
+ "wire_result": {
9
+ "meta": {
10
+ "fastmcp": {
11
+ "wrap_result": true
12
+ }
13
+ },
14
+ "content": [
15
+ {
16
+ "type": "text",
17
+ "text": "fine",
18
+ "annotations": null,
19
+ "meta": null
20
+ }
21
+ ],
22
+ "structuredContent": {
23
+ "result": "fine"
24
+ },
25
+ "isError": false
26
+ },
27
+ "middleware_observation": {
28
+ "tool": "ok_tool",
29
+ "middleware_saw": "result",
30
+ "result_is_error": "<no attr>",
31
+ "result_type": "ToolResult"
32
+ }
33
+ },
34
+ {
35
+ "tool": "empty_tool",
36
+ "mask_error_details": true,
37
+ "wire_result": {
38
+ "meta": {
39
+ "fastmcp": {
40
+ "wrap_result": true
41
+ }
42
+ },
43
+ "content": [
44
+ {
45
+ "type": "text",
46
+ "text": "{\"status\": \"empty\", \"result_count\": 0, \"detail\": \"Query executed successfully, no matches.\"}",
47
+ "annotations": null,
48
+ "meta": null
49
+ }
50
+ ],
51
+ "structuredContent": {
52
+ "result": "{\"status\": \"empty\", \"result_count\": 0, \"detail\": \"Query executed successfully, no matches.\"}"
53
+ },
54
+ "isError": false
55
+ },
56
+ "middleware_observation": {
57
+ "tool": "empty_tool",
58
+ "middleware_saw": "result",
59
+ "result_is_error": "<no attr>",
60
+ "result_type": "ToolResult"
61
+ }
62
+ },
63
+ {
64
+ "tool": "structured_tool_error",
65
+ "mask_error_details": true,
66
+ "wire_result": {
67
+ "meta": null,
68
+ "content": [
69
+ {
70
+ "type": "text",
71
+ "text": "{\"error_category\": \"transient\", \"is_retryable\": true, \"attempted\": \"structured_tool_error()\", \"description\": \"Upstream returned 503 \\u2014 retry after a short delay.\"}",
72
+ "annotations": null,
73
+ "meta": null
74
+ }
75
+ ],
76
+ "structuredContent": null,
77
+ "isError": true
78
+ },
79
+ "middleware_observation": {
80
+ "tool": "structured_tool_error",
81
+ "middleware_saw": "exception",
82
+ "exc_type": "ToolError",
83
+ "exc_str": "{\"error_category\": \"transient\", \"is_retryable\": true, \"attempted\": \"structured_tool_error()\", \"description\": \"Upstream returned 503 \\u2014 retry after a short delay.\"}"
84
+ }
85
+ },
86
+ {
87
+ "tool": "plain_exception",
88
+ "mask_error_details": true,
89
+ "wire_result": {
90
+ "meta": null,
91
+ "content": [
92
+ {
93
+ "type": "text",
94
+ "text": "Error calling tool 'plain_exception'",
95
+ "annotations": null,
96
+ "meta": null
97
+ }
98
+ ],
99
+ "structuredContent": null,
100
+ "isError": true
101
+ },
102
+ "middleware_observation": {
103
+ "tool": "plain_exception",
104
+ "middleware_saw": "exception",
105
+ "exc_type": "ToolError",
106
+ "exc_str": "Error calling tool 'plain_exception'"
107
+ }
108
+ },
109
+ {
110
+ "tool": "ok_tool",
111
+ "mask_error_details": false,
112
+ "wire_result": {
113
+ "meta": {
114
+ "fastmcp": {
115
+ "wrap_result": true
116
+ }
117
+ },
118
+ "content": [
119
+ {
120
+ "type": "text",
121
+ "text": "fine",
122
+ "annotations": null,
123
+ "meta": null
124
+ }
125
+ ],
126
+ "structuredContent": {
127
+ "result": "fine"
128
+ },
129
+ "isError": false
130
+ },
131
+ "middleware_observation": {
132
+ "tool": "ok_tool",
133
+ "middleware_saw": "result",
134
+ "result_is_error": "<no attr>",
135
+ "result_type": "ToolResult"
136
+ }
137
+ },
138
+ {
139
+ "tool": "empty_tool",
140
+ "mask_error_details": false,
141
+ "wire_result": {
142
+ "meta": {
143
+ "fastmcp": {
144
+ "wrap_result": true
145
+ }
146
+ },
147
+ "content": [
148
+ {
149
+ "type": "text",
150
+ "text": "{\"status\": \"empty\", \"result_count\": 0, \"detail\": \"Query executed successfully, no matches.\"}",
151
+ "annotations": null,
152
+ "meta": null
153
+ }
154
+ ],
155
+ "structuredContent": {
156
+ "result": "{\"status\": \"empty\", \"result_count\": 0, \"detail\": \"Query executed successfully, no matches.\"}"
157
+ },
158
+ "isError": false
159
+ },
160
+ "middleware_observation": {
161
+ "tool": "empty_tool",
162
+ "middleware_saw": "result",
163
+ "result_is_error": "<no attr>",
164
+ "result_type": "ToolResult"
165
+ }
166
+ },
167
+ {
168
+ "tool": "structured_tool_error",
169
+ "mask_error_details": false,
170
+ "wire_result": {
171
+ "meta": null,
172
+ "content": [
173
+ {
174
+ "type": "text",
175
+ "text": "{\"error_category\": \"transient\", \"is_retryable\": true, \"attempted\": \"structured_tool_error()\", \"description\": \"Upstream returned 503 \\u2014 retry after a short delay.\"}",
176
+ "annotations": null,
177
+ "meta": null
178
+ }
179
+ ],
180
+ "structuredContent": null,
181
+ "isError": true
182
+ },
183
+ "middleware_observation": {
184
+ "tool": "structured_tool_error",
185
+ "middleware_saw": "exception",
186
+ "exc_type": "ToolError",
187
+ "exc_str": "{\"error_category\": \"transient\", \"is_retryable\": true, \"attempted\": \"structured_tool_error()\", \"description\": \"Upstream returned 503 \\u2014 retry after a short delay.\"}"
188
+ }
189
+ },
190
+ {
191
+ "tool": "plain_exception",
192
+ "mask_error_details": false,
193
+ "wire_result": {
194
+ "meta": null,
195
+ "content": [
196
+ {
197
+ "type": "text",
198
+ "text": "Error calling tool 'plain_exception': INTERNAL-SECRET-DETAIL db password wrong",
199
+ "annotations": null,
200
+ "meta": null
201
+ }
202
+ ],
203
+ "structuredContent": null,
204
+ "isError": true
205
+ },
206
+ "middleware_observation": {
207
+ "tool": "plain_exception",
208
+ "middleware_saw": "exception",
209
+ "exc_type": "ToolError",
210
+ "exc_str": "Error calling tool 'plain_exception': INTERNAL-SECRET-DETAIL db password wrong"
211
+ }
212
+ }
213
+ ]
214
+ }