mcp-server-taxcode 0.1.1__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.
taxcode_mcp/errors.py ADDED
@@ -0,0 +1,399 @@
1
+ """Error structures and automated credential redaction."""
2
+
3
+ import json
4
+ import re
5
+ from typing import Any
6
+
7
+ from fastmcp.exceptions import ToolError
8
+
9
+ # Credential keys whose whole value is a secret.
10
+ _KEYS = r"(?:api[_-]?key|client[_-]?secret|private[_-]?key|password)"
11
+ # Credential keys whose bracketed or bare (number, ``None``, ``True``) value is masked whole,
12
+ # whether the key is quoted or not: ``_KEYS`` plus the token names the token rules cover. The
13
+ # lookbehind keeps ``next_token``, ``page_token`` and ``max_tokens`` untouched.
14
+ _VALUE_KEYS = (
15
+ r"(?:" + _KEYS + r"|(?:api|access|refresh|auth|id|session)[_-]?token|(?<![A-Za-z0-9_])token)"
16
+ )
17
+ # A closing quote after a key (``'api_key':``, ``"password":``, ``\"token\":``).
18
+ _KEY_CLOSE = r"(?:\\?[\"'])?"
19
+ # The fixed mask: the same string whatever the secret's length (template #67).
20
+ MASK = "[REDACTED]"
21
+
22
+ # The value of an escaped quote one level down (``\"...\"`` inside a serialized string).
23
+ _ESCAPED_VALUE = r"(?:\\\\\\\"|\\\\\\\\|\\\\[^\\\"]|[^\\\"])*"
24
+
25
+
26
+ def _token_patterns(
27
+ key: str, sep: str, unquoted_stop: str | None = r"\s\"'\\&,;}"
28
+ ) -> list[re.Pattern[str]]:
29
+ """Escaped-quoted, quoted and (unless ``unquoted_stop`` is None) unquoted forms of a key.
30
+
31
+ A quoted value is masked to its closing unescaped quote, spaces and escapes included;
32
+ the quotes stay. An unquoted value stops at whitespace or ``unquoted_stop`` punctuation.
33
+ """
34
+ head = r"(?i)(" + key + r"\s*" + sep + r"\s*"
35
+ patterns = [
36
+ re.compile(head + r"\\\")" + _ESCAPED_VALUE, re.IGNORECASE),
37
+ re.compile(head + r"([\"']))(?:\\.|(?!\2)[^\\\n])*", re.IGNORECASE),
38
+ ]
39
+ if unquoted_stop is not None:
40
+ patterns.append(
41
+ re.compile(head + r")(?![\"'\\]|\[REDACTED\])[^" + unquoted_stop + "]+", re.IGNORECASE)
42
+ )
43
+ return patterns
44
+
45
+
46
+ # Regex patterns for sensitive tokens, bearer headers, and keys. Every match is replaced by
47
+ # ``MASK``. A secret is redacted whole; no part of it is left behind.
48
+ SECRET_PATTERNS = [
49
+ # A PEM block, from ``-----BEGIN ...-----`` to ``-----END ...-----``, across lines or
50
+ # with ``\n`` escapes inside a serialized JSON string. A BEGIN with no END is masked to
51
+ # the end of the text (fail closed), which also keeps the scan linear: a lazy search for
52
+ # an END that never comes re-read the rest of the text from every BEGIN. The END must
53
+ # carry the BEGIN's label (any case): a ``-----END CERTIFICATE-----`` inside a PRIVATE
54
+ # KEY block does not end it.
55
+ re.compile(
56
+ r"()-----BEGIN ([A-Z0-9 ]+)-----(?:(?!-----END \2-----).)*"
57
+ r"(?:-----END \2-----|\Z)",
58
+ re.DOTALL | re.IGNORECASE,
59
+ ),
60
+ # Bearer value: base64url and base64 characters (``~``, ``+``, ``/``) plus ``=`` padding.
61
+ re.compile(r"(?i)(bearer\s+)[a-z0-9_\-\.~+/]{8,}=*", re.IGNORECASE),
62
+ # ``Authorization: Bearer <value>`` of any length. A bare short ``Bearer abc`` is left
63
+ # alone so prose such as "Bearer of bad news" is not redacted.
64
+ re.compile(
65
+ r"(?i)(authorization(?:\\?[\"'])?\s*[:=]\s*(?:\\?[\"'])?bearer\s+)[a-z0-9_\-\.~+/]+=*",
66
+ re.IGNORECASE,
67
+ ),
68
+ # Quoted value (``"password": "..."``, ``{'api_key': '...'}``, ``password="..."``): the
69
+ # whole string up to its closing unescaped quote, escapes included; the quotes stay.
70
+ re.compile(
71
+ r"(?i)([\"']?" + _KEYS + r"[\"']?\s*[:=]\s*([\"']))(?:\\.|(?!\2)[^\\\n])*",
72
+ re.IGNORECASE,
73
+ ),
74
+ # Quoted key with a bare value: a Python ``repr`` or JSON number, ``None``, ``True`` or
75
+ # word (``{'password': 12345}``). The value stops at whitespace, ``,`` or a closing
76
+ # bracket, so the rest of the dict keeps its shape. ``None``/``null`` stays, as in
77
+ # ``redact_payload``. Quoted and bracketed values have their own rules.
78
+ re.compile(
79
+ r"(?i)(\\?[\"']" + _VALUE_KEYS + r"\\?[\"']\s*[:=]\s*)"
80
+ r"(?![\"'\\\[{]|\[REDACTED\]|(?:null|None)(?![^\s,}\])]))[^\s,}\])]+",
81
+ re.IGNORECASE,
82
+ ),
83
+ # The same inside an already-serialized JSON string (``\"password\": \"...\"``, template
84
+ # #68): the value ends at the escaped quote that closed it one level down.
85
+ re.compile(
86
+ r"(?i)(\\\"" + _KEYS + r"\\\"\s*:\s*\\\")"
87
+ r"(?:\\\\\\\"|\\\\\\\\|\\\\[^\\\"]|[^\\\"])*",
88
+ re.IGNORECASE,
89
+ ),
90
+ # Unquoted ``key=value`` or ``key: value`` of any length: the value runs to the end of
91
+ # the line, spaces and punctuation included, so ``password=pass word`` leaves nothing
92
+ # behind (template #69). A ``{...}`` / ``[...]`` value is masked first, to its balanced
93
+ # bracket (``_mask_bracket_values``). Structured payloads are redacted value by value
94
+ # before serialization (``redact_payload``), so this never runs on JSON that parses.
95
+ re.compile(
96
+ r"(?i)(" + _KEYS + r"[ \t]*[:=][ \t]*)(?![\"'\\]|\[REDACTED\])[^\s][^\n]*",
97
+ re.IGNORECASE,
98
+ ),
99
+ # Whitespace-separated older forms keep a minimum, so prose stays untouched.
100
+ re.compile(r"(?i)(password\s+)(?!\[REDACTED\])[^\s\"']\S{3,}", re.IGNORECASE),
101
+ re.compile(
102
+ r"(?i)((?:api[_-]?key|client[_-]?secret)\s+)(?!\[REDACTED\])[a-z0-9_\-\.]{8,}",
103
+ re.IGNORECASE,
104
+ ),
105
+ # api/access/refresh/auth/id/session tokens as key=value, key: value, an
106
+ # ``X-Auth-Token:`` header and JSON ("key": "value", also backslash-escaped inside an
107
+ # already-serialized JSON string). A quoted value runs to its closing unescaped quote,
108
+ # spaces included (template #69); an unquoted one stops at whitespace or punctuation.
109
+ *_token_patterns(r"(?:api|access|refresh|auth|id|session)[_-]?token(?:\\?[\"'])?", "[:=]"),
110
+ # The same keys URL-encoded (``access_token%3D...``); the value stops at an encoded
111
+ # ``%26`` (&) or ``%23`` (#), so the parameters after it survive.
112
+ re.compile(
113
+ r"(?i)((?:api|access|refresh|auth|id|session)[_-]?token%3D)"
114
+ r"(?:[^\s\"'\\&,;#%]|%(?!26|23))+",
115
+ re.IGNORECASE,
116
+ ),
117
+ # ``Authorization: Token <value>`` scheme, also as a quoted JSON or dict entry.
118
+ re.compile(
119
+ r"(?i)(authorization(?:\\?[\"'])?\s*[:=]\s*(?:\\?[\"'])?token\s+)[^\s\"'\\&,;}]+",
120
+ re.IGNORECASE,
121
+ ),
122
+ # JSON ``"token": "value"``; the opening quote right before ``token`` keeps keys such
123
+ # as ``"next_token"`` and ``"page_token"`` untouched.
124
+ *_token_patterns(r"\\?[\"']token\\?[\"']", ":", unquoted_stop=None),
125
+ # Bare ``token`` key with ``:`` or ``=``, optional spaces and an optional opening quote
126
+ # (``token=``, ``token: x``, ``token = x``, ``token: "x y"``); the lookbehind keeps
127
+ # ``page_token``, ``next_token``, ``csrf_token`` and ``max_tokens`` untouched.
128
+ *_token_patterns(r"(?<![A-Za-z0-9_])token", "[:=]", unquoted_stop=r"\s\"'\\&#,;}"),
129
+ ]
130
+
131
+
132
+ # A credential key followed by ``{``, ``[`` or ``(``: the start of a bracketed value. The
133
+ # key may be quoted, as in a Python ``repr`` (``{'api_key': ['a', 'b']}`` or a tuple
134
+ # ``{'api_key': ('a', 'b')}``) or JSON that does not parse.
135
+ _KEYED_BRACKET = re.compile(
136
+ r"(?i)(" + _VALUE_KEYS + _KEY_CLOSE + r"[ \t]*[:=][ \t]*)(?=[\[{(])(?!\[REDACTED\])",
137
+ re.IGNORECASE,
138
+ )
139
+ _CLOSERS = {"{": "}", "[": "]", "(": ")"}
140
+
141
+
142
+ class _Brackets:
143
+ """Balanced-bracket ends in one text, with every scan's findings cached.
144
+
145
+ A scan from an opener records where each opener it pushed was closed, or that it was
146
+ never closed. A later scan from one of those openers (outside a string in the earlier
147
+ scan) would see exactly the same characters above it, so its answer is read from the
148
+ cache instead of re-scanning to the end of the text. Many unbalanced keyed brackets
149
+ (``password={`` on every line) then cost one pass instead of one pass each.
150
+ """
151
+
152
+ def __init__(self, text: str) -> None:
153
+ self.text = text
154
+ self.closes: dict[int, int] = {}
155
+ self.unclosed: set[int] = set()
156
+
157
+ def _line_end(self, start: int) -> int:
158
+ newline = self.text.find("\n", start)
159
+ return len(self.text) if newline == -1 else newline
160
+
161
+ def end(self, start: int) -> int:
162
+ """Return the index just past the bracket balancing ``text[start]``.
163
+
164
+ Brackets inside double- or single-quoted strings (with backslash escapes) do not
165
+ count, so a Python ``repr`` value such as ``['a]b']`` balances correctly. A
166
+ single-quoted string also ends at a newline (a ``repr`` string never spans lines),
167
+ so a stray apostrophe cannot hide the rest of the text. When
168
+ the value never balances, the end of the line is returned instead, so an
169
+ unparseable value is still masked whole.
170
+ """
171
+ if start in self.closes:
172
+ return self.closes[start]
173
+ if start in self.unclosed:
174
+ return self._line_end(start)
175
+ text = self.text
176
+ stack: list[tuple[str, int]] = []
177
+ quote = ""
178
+ i = start
179
+ size = len(text)
180
+ while i < size:
181
+ ch = text[i]
182
+ if quote:
183
+ if ch == "\\":
184
+ i += 1
185
+ elif ch == quote or (ch == "\n" and quote == "'"):
186
+ quote = ""
187
+ elif ch in "\"'":
188
+ quote = ch
189
+ elif ch in _CLOSERS:
190
+ stack.append((_CLOSERS[ch], i))
191
+ elif stack and ch == stack[-1][0]:
192
+ self.closes[stack.pop()[1]] = i + 1
193
+ if not stack:
194
+ return i + 1
195
+ i += 1
196
+ self.unclosed.update(pos for _, pos in stack)
197
+ return self._line_end(start)
198
+
199
+
200
+ def _mask_bracket_values(text: str) -> str:
201
+ """Mask a ``{...}``, ``[...]`` or ``(...)`` value after a credential key to its balance."""
202
+ parts: list[str] = []
203
+ pos = 0
204
+ brackets = _Brackets(text)
205
+ for match in _KEYED_BRACKET.finditer(text):
206
+ if match.start() < pos:
207
+ continue
208
+ parts.append(text[pos : match.end()] + MASK)
209
+ pos = brackets.end(match.end())
210
+ parts.append(text[pos:])
211
+ return "".join(parts)
212
+
213
+
214
+ def redact_secrets(text: str) -> str:
215
+ """Scrub sensitive credentials, tokens, and authorization headers from text."""
216
+ if not text:
217
+ return ""
218
+ sanitized = _mask_bracket_values(text)
219
+ for pattern in SECRET_PATTERNS:
220
+ sanitized = pattern.sub(lambda m: m.group(1) + MASK, sanitized)
221
+ return sanitized
222
+
223
+
224
+ def tool_error(exc: Exception) -> ToolError:
225
+ """Return a FastMCP ``ToolError`` that carries the redacted message of ``exc``.
226
+
227
+ A tool handler raises it for a failed call, so the client receives a ``tools/call``
228
+ result with ``isError: true``. The original exception keeps its unredacted message, so
229
+ it must not ride along on the error: build the error inside the ``except`` block and
230
+ raise it ``from None`` after the block ends. Raised inside the block, ``from None``
231
+ still leaves the original on ``__context__``, where chain-walking reporters find it::
232
+
233
+ except Exception as exc:
234
+ failure = tool_error(exc)
235
+ raise failure from None
236
+
237
+ Batch tools process every item; partial success returns a normal result listing each
238
+ item's status, and when every item fails they raise the redacted ``batch_failed`` error
239
+ (``tool_failure``).
240
+ """
241
+ return ToolError(redact_message(str(exc)))
242
+
243
+
244
+ def _keyed_before(text: str, floor: int, sep: int) -> bool:
245
+ """Return whether ``text[floor : sep + 1]`` ends with a credential key and ``:`` or ``=``."""
246
+ window = text[floor : sep + 1] + "("
247
+ return any(m.end() == len(window) - 1 for m in _KEYED_BRACKET.finditer(window))
248
+
249
+
250
+ _DECODER = json.JSONDecoder()
251
+ # Failed bracket tries per message before ``redact_message`` masks the rest (fail closed).
252
+ _MAX_BRACKET_TRIES = 64
253
+
254
+
255
+ def redact_message(text: str) -> str:
256
+ """Redact a free-text message, keeping any JSON inside it valid where possible.
257
+
258
+ Each ``{...}`` or ``[...]`` that ``json.loads`` accepts (the whole message, or one after
259
+ a prefix such as ``HTTP 400: ``) is parsed, redacted value by value (``redact_payload``)
260
+ and re-serialized in place; the text around it goes through ``redact_secrets``. JSON
261
+ that does not parse falls back to ``redact_secrets`` on the raw text: the redaction is
262
+ still whole, but the JSON shape may break. A bracketed value right after a credential
263
+ key (``password={...}``), or a tuple right after one (``{'api_key': ('a', 'b')}``), is
264
+ masked whole to its balanced bracket, or to the end of the line when it never balances,
265
+ whether or not it parses. A ``(`` never counts toward ``_MAX_BRACKET_TRIES``.
266
+
267
+ At most ``_MAX_BRACKET_TRIES`` brackets that neither parse nor follow a credential key
268
+ are tried per message. Each try re-reads the text since the last match, so without the
269
+ cap the cost grew with the square of the bracket count (8,000 ``{`` took about 14s).
270
+ Past the cap the message fails closed: everything from the bracket that hit the cap to
271
+ the end is replaced by ``MASK``. Only abusive input (dozens of stray brackets) gets
272
+ there, and full redaction beats a readable tail that might hold a secret.
273
+ """
274
+ if not text:
275
+ return ""
276
+ parts: list[str] = []
277
+ start = 0
278
+ i = 0
279
+ tries = 0
280
+ brackets = _Brackets(text)
281
+ size = len(text)
282
+ while i < size:
283
+ if text[i] == "(":
284
+ # A tuple right after a credential key (``{'api_key': ('a', 'b')}``) is masked
285
+ # whole too. A key needs ``:`` or ``=`` before the ``(``, with only spaces and
286
+ # tabs between, so the 256 characters before that ``:`` or ``=`` are searched
287
+ # only when one is there. The step back over the gap has no bound (a gap of any
288
+ # length is still checked), yet each gap is read once, by the one ``(`` after
289
+ # it, and a ``(`` never counts as a try, so long inputs stay linear.
290
+ j = i - 1
291
+ while j >= start and text[j] in " \t":
292
+ j -= 1
293
+ if j >= start and text[j] in ":=" and _keyed_before(text, max(start, j - 256), j):
294
+ parts.append(redact_secrets(text[start:i]) + MASK)
295
+ start = i = brackets.end(i)
296
+ continue
297
+ elif text[i] in "{[":
298
+ prefix = text[start:i]
299
+ # A value right after a credential key (``password={...}``) is the secret,
300
+ # masked whole to its balanced bracket whether or not it parses.
301
+ if redact_secrets(prefix + "x").endswith(MASK):
302
+ parts.append(redact_secrets(prefix) + MASK)
303
+ start = i = brackets.end(i)
304
+ continue
305
+ try:
306
+ value, end = _DECODER.raw_decode(text, i)
307
+ except (ValueError, RecursionError):
308
+ # Nesting deeper than the decoder's recursion limit (Python 3.10/3.11 raise
309
+ # RecursionError) is treated as JSON that does not parse.
310
+ value, end = None, i
311
+ if isinstance(value, (dict, list)):
312
+ parts.append(redact_secrets(prefix))
313
+ parts.append(json.dumps(redact_payload(value)))
314
+ start = i = end
315
+ continue
316
+ tries += 1
317
+ if tries == _MAX_BRACKET_TRIES:
318
+ return "".join(parts) + redact_secrets(text[start:i]) + MASK
319
+ i += 1
320
+ if not parts:
321
+ return redact_secrets(text)
322
+ parts.append(redact_secrets(text[start:]))
323
+ return "".join(parts)
324
+
325
+
326
+ _SECRET_KEY = re.compile(
327
+ r"(?i)(?:" + _KEYS + r"|(?:api|access|refresh|auth|id|session)?[_-]?token|authorization)"
328
+ )
329
+
330
+
331
+ def redact_payload(value: Any) -> Any:
332
+ """Redact a structured payload value by value, before it is serialized.
333
+
334
+ Every string is passed through ``redact_secrets`` on its decoded text, so a
335
+ ``password=...`` inside a message is redacted whole and ``json.dumps`` then escapes the
336
+ result: the serialized JSON stays valid. Any non-None value under a credential key
337
+ (``password``, ``api_key``, ``access_token``, ...), whether a string, number, list or
338
+ dict, is replaced by ``MASK`` outright.
339
+ """
340
+ if isinstance(value, dict):
341
+ return {
342
+ k: (
343
+ MASK
344
+ if isinstance(k, str) and v is not None and _SECRET_KEY.fullmatch(k)
345
+ else redact_payload(v)
346
+ )
347
+ for k, v in value.items()
348
+ }
349
+ if isinstance(value, (list, tuple)):
350
+ return [redact_payload(v) for v in value]
351
+ if isinstance(value, str):
352
+ return redact_secrets(value)
353
+ return value
354
+
355
+
356
+ def tool_failure(error_type: str, message: str, **details: Any) -> ToolError:
357
+ """Return a ``ToolError`` for a failure the tool detects itself, such as a failed batch.
358
+
359
+ The message is the ``{"error": {"type": ..., "message": ..., **details}}`` JSON. The
360
+ payload is redacted value by value before ``json.dumps`` (``redact_payload``), never as
361
+ serialized text, so the message always parses. Raise it ``from None``, outside any
362
+ ``except`` block, so the client receives ``isError: true`` and no caught exception rides
363
+ along on ``__context__``.
364
+ """
365
+ payload: dict[str, Any] = {"type": error_type, "message": message, **details}
366
+ return ToolError(json.dumps({"error": redact_payload(payload)}, indent=2))
367
+
368
+
369
+ class TemplateError(Exception):
370
+ """Base exception for all template errors with automatic message redaction.
371
+
372
+ The message goes through ``redact_message``, so a JSON body inside it (an upstream 401
373
+ response, for example) is redacted value by value and keeps its shape.
374
+ """
375
+
376
+ def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
377
+ self.message = redact_message(message)
378
+ self.details = details or {}
379
+ super().__init__(self.message)
380
+
381
+
382
+ class AuthenticationError(TemplateError):
383
+ """Raised on HTTP 401 / 403 authorization failures."""
384
+
385
+
386
+ class ResourceNotFoundError(TemplateError):
387
+ """Raised on HTTP 404 missing resource errors."""
388
+
389
+
390
+ class RateLimitError(TemplateError):
391
+ """Raised on HTTP 429 rate limit exhaustion."""
392
+
393
+
394
+ class SafetyViolationError(TemplateError, ToolError):
395
+ """Raised when an operation violates read-only, single-delete, or bulk-destructive gates.
396
+
397
+ Also a FastMCP ``ToolError``, so a refusal raised from middleware reaches the client as
398
+ a ``tools/call`` result with ``isError: true`` instead of a JSON-RPC internal error.
399
+ """
@@ -0,0 +1,169 @@
1
+ """Hierarchical middleware for FastMCP 4 Server Composition.
2
+
3
+ Execution Order:
4
+ 1. Parent Middleware (Global audit, timing, secret redaction, and global read-only gate).
5
+ 2. Child Middleware (Domain-specific guards, bulk-destructive gating, and parameter limits).
6
+ 3. Tool Handler (Core business logic).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+ from collections.abc import Callable, Collection
14
+ from typing import Any
15
+
16
+ from fastmcp import FastMCP
17
+ from fastmcp.server.middleware import Middleware, MiddlewareContext
18
+ from fastmcp.tools import Tool
19
+
20
+ from taxcode_mcp.config import settings
21
+ from taxcode_mcp.errors import SafetyViolationError, redact_secrets
22
+ from taxcode_mcp.profiles import is_read_only_tool
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Synthetic discovery proxies that may wrap a real tool name in arguments.
27
+ _SEARCH_PROXY_TOOLS = {"call_tool"}
28
+
29
+
30
+ def _resolve_effective_tool_name(context: MiddlewareContext) -> str:
31
+ """Return the tool name under enforcement, unwrapping search ``call_tool`` proxies."""
32
+ message = context.message
33
+ tool_name = getattr(message, "name", "") if message else ""
34
+ if tool_name not in _SEARCH_PROXY_TOOLS or message is None:
35
+ return tool_name
36
+
37
+ arguments = getattr(message, "arguments", None)
38
+ if not isinstance(arguments, dict):
39
+ return tool_name
40
+
41
+ nested = arguments.get("name")
42
+ if isinstance(nested, str) and nested:
43
+ return nested
44
+ return tool_name
45
+
46
+
47
+ class ParentAuditMiddleware(Middleware):
48
+ """Global gateway middleware logging operation timing, method, and sanitizing errors."""
49
+
50
+ async def on_message(
51
+ self,
52
+ context: MiddlewareContext,
53
+ call_next: Callable[[MiddlewareContext], Any],
54
+ ) -> Any:
55
+ """Intercept request, track timing, log lifecycle, and redact sensitive output."""
56
+ start_time = time.perf_counter()
57
+ method = getattr(context, "method", "unknown")
58
+ tool_name = getattr(context.message, "name", None) if context.message else None
59
+ target = f"{method}:{tool_name}" if tool_name else method
60
+
61
+ logger.debug("→ MCP Request received: %s", target)
62
+ try:
63
+ result = await call_next(context)
64
+ duration_ms = (time.perf_counter() - start_time) * 1000.0
65
+ logger.debug("← MCP Request completed: %s in %.2fms", target, duration_ms)
66
+ return result
67
+ except Exception as exc:
68
+ duration_ms = (time.perf_counter() - start_time) * 1000.0
69
+ sanitized_msg = redact_secrets(str(exc))
70
+ logger.error(
71
+ "✗ MCP Request failed: %s in %.2fms: %s", target, duration_ms, sanitized_msg
72
+ )
73
+ if hasattr(exc, "args") and exc.args:
74
+ exc.args = tuple(
75
+ redact_secrets(str(a)) if isinstance(a, str) else a for a in exc.args
76
+ )
77
+ raise
78
+
79
+
80
+ def _read_only_refusal(name: str) -> SafetyViolationError:
81
+ """Build the read-only refusal for ``name`` (a FastMCP ``ToolError``: ``isError: true``)."""
82
+ return SafetyViolationError(
83
+ f"Server running in read-only mode; tool '{name}' blocked "
84
+ "(not annotated readOnlyHint=True)."
85
+ )
86
+
87
+
88
+ class ReadOnlyGateMiddleware(Middleware):
89
+ """Refuse calls to real tools not annotated ``readOnlyHint=True`` while read-only is on.
90
+
91
+ Read-only is on when ``settings.MCP_READONLY`` is set or the gate is built with
92
+ ``enforce=True`` (the ``readonly`` profile). Classification uses only the MCP
93
+ ``readOnlyHint`` annotation of the resolved tool: a missing annotation or
94
+ ``readOnlyHint`` other than ``True`` is a write and is refused (fail closed).
95
+
96
+ A name that is not a tool on this server is not refused here: it passes through so
97
+ FastMCP returns its normal ``Unknown tool`` error, and a typo is never reported as an
98
+ existing tool. ``catalog`` holds the profile's tool names captured before the
99
+ read-only filter hid the writes, so a hidden real tool is still refused, not unknown.
100
+ The ``call_tool`` proxy gets the same check: it is unwrapped only when it is a real
101
+ tool on this server (Tool Search attached).
102
+ """
103
+
104
+ def __init__(self, enforce: bool = False, catalog: Collection[str] = ()) -> None:
105
+ self.enforce = enforce
106
+ self.catalog = frozenset(catalog)
107
+
108
+ async def on_message(
109
+ self,
110
+ context: MiddlewareContext,
111
+ call_next: Callable[[MiddlewareContext], Any],
112
+ ) -> Any:
113
+ """Block non-read-only tool calls before they reach tool handlers.
114
+
115
+ 1. No serving FastMCP context: the annotation cannot be read, so refuse.
116
+ 2. Outer name is ``call_tool`` but not a tool on this server (no Tool Search):
117
+ pass through, so FastMCP reports ``Unknown tool: 'call_tool'``.
118
+ 3. Otherwise resolve the effective name (``call_tool`` unwrapped to the proxied
119
+ tool) and refuse it only if it is a real tool not annotated read-only.
120
+ """
121
+ readonly = self.enforce or settings.MCP_READONLY
122
+ if not readonly or getattr(context, "method", None) != "tools/call":
123
+ return await call_next(context)
124
+
125
+ fastmcp_context = context.fastmcp_context
126
+ if fastmcp_context is None:
127
+ raise _read_only_refusal(_resolve_effective_tool_name(context))
128
+ server = fastmcp_context.fastmcp
129
+
130
+ outer_name = getattr(context.message, "name", "")
131
+ if outer_name in _SEARCH_PROXY_TOOLS:
132
+ _, proxy_known = await self._lookup(server, outer_name)
133
+ if not proxy_known:
134
+ return await call_next(context)
135
+
136
+ effective_name = _resolve_effective_tool_name(context)
137
+ tool, known = await self._lookup(server, effective_name)
138
+ if known and not is_read_only_tool(tool):
139
+ raise _read_only_refusal(effective_name)
140
+ return await call_next(context)
141
+
142
+ async def _lookup(self, server: FastMCP, name: str) -> tuple[Tool | None, bool]:
143
+ """Resolve ``name`` with the public ``get_tool``; return ``(tool, is_real_tool)``.
144
+
145
+ ``get_tool`` returns ``None`` for an unknown name and for a tool hidden by the
146
+ read-only filter; the recorded ``catalog`` tells the two apart.
147
+ """
148
+ tool = await server.get_tool(name)
149
+ return tool, tool is not None or name in self.catalog
150
+
151
+
152
+ class StatutesDomainGuardMiddleware(Middleware):
153
+ """Child domain middleware mounted on statutes sub-server for bulk and mutation protection."""
154
+
155
+ async def on_message(
156
+ self,
157
+ context: MiddlewareContext,
158
+ call_next: Callable[[MiddlewareContext], Any],
159
+ ) -> Any:
160
+ """Enforce domain safety gates strictly within the statutes namespace."""
161
+ tool_name = getattr(context.message, "name", "") if context.message else ""
162
+ if tool_name in ("bulk_delete_statutes", "statutes_bulk_delete_statutes"):
163
+ if not settings.MCP_ALLOW_BULK_DESTRUCTIVE:
164
+ raise SafetyViolationError(
165
+ "Bulk destructive operations disabled. "
166
+ "Set TAXCODE_MCP_ALLOW_BULK_DESTRUCTIVE=1."
167
+ )
168
+
169
+ return await call_next(context)