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.
- mcp_server_taxcode-0.1.1.dist-info/METADATA +307 -0
- mcp_server_taxcode-0.1.1.dist-info/RECORD +19 -0
- mcp_server_taxcode-0.1.1.dist-info/WHEEL +4 -0
- mcp_server_taxcode-0.1.1.dist-info/entry_points.txt +3 -0
- mcp_server_taxcode-0.1.1.dist-info/licenses/LICENSE +190 -0
- taxcode_mcp/__init__.py +14 -0
- taxcode_mcp/auth.py +65 -0
- taxcode_mcp/client.py +218 -0
- taxcode_mcp/config.py +95 -0
- taxcode_mcp/db.py +1450 -0
- taxcode_mcp/errors.py +399 -0
- taxcode_mcp/middleware.py +169 -0
- taxcode_mcp/profiles.py +180 -0
- taxcode_mcp/server.py +526 -0
- taxcode_mcp/ssrf.py +287 -0
- taxcode_mcp/tools/__init__.py +41 -0
- taxcode_mcp/tools/compliance.py +542 -0
- taxcode_mcp/tools/statutes.py +156 -0
- taxcode_mcp/tools/system.py +72 -0
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)
|