node804-mcp-toolkit 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.
- node804_mcp_toolkit/__init__.py +45 -0
- node804_mcp_toolkit/audit.py +315 -0
- node804_mcp_toolkit/lean.py +128 -0
- node804_mcp_toolkit/params.py +97 -0
- node804_mcp_toolkit/py.typed +0 -0
- node804_mcp_toolkit/rbac.py +262 -0
- node804_mcp_toolkit/tls.py +136 -0
- node804_mcp_toolkit-0.1.0.dist-info/METADATA +129 -0
- node804_mcp_toolkit-0.1.0.dist-info/RECORD +11 -0
- node804_mcp_toolkit-0.1.0.dist-info/WHEEL +4 -0
- node804_mcp_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shared utilities for token-efficient MCP servers.
|
|
2
|
+
|
|
3
|
+
Public API re-exported here for convenience::
|
|
4
|
+
|
|
5
|
+
from node804_mcp_toolkit import Mode, ModeGate, audit, open_sink, whitelist, ...
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .audit import (
|
|
9
|
+
AuditCategory,
|
|
10
|
+
AuditEvent,
|
|
11
|
+
AuditSink,
|
|
12
|
+
JsonlSink,
|
|
13
|
+
audit,
|
|
14
|
+
open_sink,
|
|
15
|
+
sanitize_args,
|
|
16
|
+
)
|
|
17
|
+
from .lean import filter_by_pattern, paginate, strip_keys, whitelist
|
|
18
|
+
from .params import FieldsList, Pagination, Pattern, VerboseFlag
|
|
19
|
+
from .rbac import Mode, ModeGate
|
|
20
|
+
from .tls import TlsConfig, describe_tls, resolve_tls_config
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AuditCategory",
|
|
26
|
+
"AuditEvent",
|
|
27
|
+
"AuditSink",
|
|
28
|
+
"FieldsList",
|
|
29
|
+
"JsonlSink",
|
|
30
|
+
"Mode",
|
|
31
|
+
"ModeGate",
|
|
32
|
+
"Pagination",
|
|
33
|
+
"Pattern",
|
|
34
|
+
"TlsConfig",
|
|
35
|
+
"VerboseFlag",
|
|
36
|
+
"audit",
|
|
37
|
+
"describe_tls",
|
|
38
|
+
"filter_by_pattern",
|
|
39
|
+
"open_sink",
|
|
40
|
+
"paginate",
|
|
41
|
+
"resolve_tls_config",
|
|
42
|
+
"sanitize_args",
|
|
43
|
+
"strip_keys",
|
|
44
|
+
"whitelist",
|
|
45
|
+
]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Audit logging for MCP tool invocations.
|
|
2
|
+
|
|
3
|
+
Each tool call emits one JSON-lines event with timing, success/error state,
|
|
4
|
+
sanitized arguments, and a request ID for correlation. The sink is
|
|
5
|
+
configured at startup via env var; when unset the sink is a no-op so the
|
|
6
|
+
decorator costs nothing in the default case.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from node804_mcp_toolkit.audit import audit, open_sink
|
|
11
|
+
|
|
12
|
+
sink = open_sink({"PANOS_AUDIT_LOG": "/var/log/panos-audit.jsonl"})
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
@audit(sink, category="READ", tool_name="get_security_rules")
|
|
16
|
+
async def get_security_rules(...): ...
|
|
17
|
+
|
|
18
|
+
In practice the ModeGate.tool decorator from rbac.py composes the audit and
|
|
19
|
+
gating decorators together, so most callers never invoke audit() directly.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import functools
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Literal, Protocol, TypeVar
|
|
32
|
+
|
|
33
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
34
|
+
|
|
35
|
+
AuditCategory = Literal["READ", "WRITE", "ADMIN", "UNKNOWN"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuditEvent(BaseModel):
|
|
39
|
+
"""One audit log line. Field names are stable across MCPs in the suite."""
|
|
40
|
+
|
|
41
|
+
ts: str = Field(description="ISO-8601 timestamp with millisecond precision, UTC")
|
|
42
|
+
request_id: str = Field(description="UUID4 — correlates one tool call across logs")
|
|
43
|
+
tool: str = Field(description="MCP tool name (e.g. get_security_rules)")
|
|
44
|
+
category: AuditCategory = Field(description="READ / WRITE / ADMIN / UNKNOWN")
|
|
45
|
+
mode: str = Field(description="Active permission mode at call time (e.g. 'read', 'admin')")
|
|
46
|
+
args: dict[str, Any] = Field(description="Sanitized tool arguments — sensitive keys redacted")
|
|
47
|
+
success: bool = Field(description="True for successful calls; False for handler errors")
|
|
48
|
+
duration_ms: int = Field(description="Wall-clock duration of the tool call")
|
|
49
|
+
error: str | None = Field(default=None, description="Error message when success=False")
|
|
50
|
+
extra: dict[str, Any] = Field(
|
|
51
|
+
default_factory=dict,
|
|
52
|
+
description="Per-MCP optional fields (e.g. {'firewall': 'hq-fw'} for panos-mcp)",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(frozen=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AuditSink(Protocol):
|
|
59
|
+
"""Protocol for audit destinations. Implement ``write`` to plug in a custom sink."""
|
|
60
|
+
|
|
61
|
+
enabled: bool
|
|
62
|
+
|
|
63
|
+
def write(self, event: AuditEvent) -> None: ...
|
|
64
|
+
|
|
65
|
+
def describe(self) -> str: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _NoopSink:
|
|
69
|
+
"""Sink that drops events. Used when audit logging is unconfigured."""
|
|
70
|
+
|
|
71
|
+
enabled: bool = False
|
|
72
|
+
|
|
73
|
+
def write(self, event: AuditEvent) -> None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def describe(self) -> str:
|
|
77
|
+
return "disabled"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class JsonlSink:
|
|
81
|
+
"""Append-only JSON-lines sink writing to a single file path.
|
|
82
|
+
|
|
83
|
+
The path's parent directory is created on first use. Write failures
|
|
84
|
+
warn once to stderr and never raise — audit must not break tool calls.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
enabled: bool = True
|
|
88
|
+
|
|
89
|
+
def __init__(self, path: Path) -> None:
|
|
90
|
+
self._path = path
|
|
91
|
+
self._warned = False
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
def write(self, event: AuditEvent) -> None:
|
|
95
|
+
try:
|
|
96
|
+
with self._path.open("a", encoding="utf-8") as f:
|
|
97
|
+
f.write(event.model_dump_json() + "\n")
|
|
98
|
+
except OSError as err:
|
|
99
|
+
if not self._warned:
|
|
100
|
+
print(
|
|
101
|
+
f"[node804-mcp-toolkit] WARNING: audit log write failed ({err}). "
|
|
102
|
+
"Subsequent failures suppressed.",
|
|
103
|
+
file=sys.stderr,
|
|
104
|
+
)
|
|
105
|
+
self._warned = True
|
|
106
|
+
|
|
107
|
+
def describe(self) -> str:
|
|
108
|
+
return f"enabled → {self._path}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def open_sink(
|
|
112
|
+
env: Mapping[str, str] | None = None,
|
|
113
|
+
*,
|
|
114
|
+
env_var: str = "MCP_AUDIT_LOG",
|
|
115
|
+
) -> AuditSink:
|
|
116
|
+
"""Open an audit sink based on environment.
|
|
117
|
+
|
|
118
|
+
Returns a :class:`JsonlSink` when ``env_var`` is set; otherwise a no-op
|
|
119
|
+
sink. Directory creation failures fall back to no-op with a stderr
|
|
120
|
+
warning rather than raising — audit should be best-effort.
|
|
121
|
+
"""
|
|
122
|
+
e = env if env is not None else os.environ
|
|
123
|
+
raw = e.get(env_var, "").strip()
|
|
124
|
+
if not raw:
|
|
125
|
+
return _NoopSink()
|
|
126
|
+
|
|
127
|
+
path = Path(raw)
|
|
128
|
+
try:
|
|
129
|
+
return JsonlSink(path)
|
|
130
|
+
except OSError as err:
|
|
131
|
+
print(
|
|
132
|
+
f"[node804-mcp-toolkit] WARNING: could not initialize audit sink at '{raw}' ({err}). "
|
|
133
|
+
"Audit logging DISABLED.",
|
|
134
|
+
file=sys.stderr,
|
|
135
|
+
)
|
|
136
|
+
return _NoopSink()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --- Sanitization ---------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
DEFAULT_SENSITIVE_KEYS = frozenset(
|
|
142
|
+
{
|
|
143
|
+
"api_key",
|
|
144
|
+
"apikey",
|
|
145
|
+
"password",
|
|
146
|
+
"passwd",
|
|
147
|
+
"secret",
|
|
148
|
+
"token",
|
|
149
|
+
"auth",
|
|
150
|
+
"authorization",
|
|
151
|
+
"x-api-key",
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
"""Keys whose values are replaced with ``<redacted>`` regardless of nesting depth.
|
|
155
|
+
|
|
156
|
+
The current schemas in this MCP suite don't accept any of these as tool arguments
|
|
157
|
+
(api keys live in the keychain, not in tool params), but this is defense in depth —
|
|
158
|
+
a future tool that inadvertently accepts one will not leak it to the audit log.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
DEFAULT_MAX_VALUE_LEN = 2048
|
|
162
|
+
"""Strings longer than this are replaced with '<elided: N chars>' so a giant
|
|
163
|
+
``set_config`` element doesn't blow a single audit line out to 100K+ chars."""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def sanitize_args(
|
|
167
|
+
args: Any,
|
|
168
|
+
*,
|
|
169
|
+
sensitive_keys: frozenset[str] = DEFAULT_SENSITIVE_KEYS,
|
|
170
|
+
max_value_len: int = DEFAULT_MAX_VALUE_LEN,
|
|
171
|
+
) -> dict[str, Any]:
|
|
172
|
+
"""Deep-copy ``args`` with sensitive values redacted and long strings elided.
|
|
173
|
+
|
|
174
|
+
Returns ``{}`` when ``args`` is not a dict (audit log fields are typed
|
|
175
|
+
as ``dict[str, Any]`` for stability; a non-dict input usually means
|
|
176
|
+
"tool was called with no kwargs" which is fine).
|
|
177
|
+
"""
|
|
178
|
+
if not isinstance(args, Mapping):
|
|
179
|
+
return {}
|
|
180
|
+
sanitized = _sanitize_value(args, sensitive_keys, max_value_len)
|
|
181
|
+
# _sanitize_value preserves dict-ness for dict inputs; cast for mypy.
|
|
182
|
+
assert isinstance(sanitized, dict)
|
|
183
|
+
return sanitized
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _sanitize_value(
|
|
187
|
+
value: Any,
|
|
188
|
+
sensitive: frozenset[str],
|
|
189
|
+
max_len: int,
|
|
190
|
+
) -> Any:
|
|
191
|
+
if isinstance(value, str):
|
|
192
|
+
if len(value) > max_len:
|
|
193
|
+
return f"<elided: {len(value)} chars>"
|
|
194
|
+
return value
|
|
195
|
+
if isinstance(value, (int, float, bool)) or value is None:
|
|
196
|
+
return value
|
|
197
|
+
if isinstance(value, Mapping):
|
|
198
|
+
out: dict[str, Any] = {}
|
|
199
|
+
for k, v in value.items():
|
|
200
|
+
if isinstance(k, str) and k.lower() in sensitive:
|
|
201
|
+
out[k] = "<redacted>"
|
|
202
|
+
else:
|
|
203
|
+
out[k] = _sanitize_value(v, sensitive, max_len)
|
|
204
|
+
return out
|
|
205
|
+
if isinstance(value, (list, tuple)):
|
|
206
|
+
return [_sanitize_value(item, sensitive, max_len) for item in value]
|
|
207
|
+
# bytes, sets, custom classes: stringify defensively rather than crash
|
|
208
|
+
return str(value)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --- Decorator ------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
T = TypeVar("T")
|
|
214
|
+
F = TypeVar("F", bound=Callable[..., Awaitable[Any]])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _now_iso() -> str:
|
|
218
|
+
"""ISO-8601 with millisecond precision, UTC."""
|
|
219
|
+
# Build by hand because datetime.isoformat default is microsecond.
|
|
220
|
+
t = time.time()
|
|
221
|
+
ms = int((t - int(t)) * 1000)
|
|
222
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(t)) + f".{ms:03d}Z"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def audit(
|
|
226
|
+
sink: AuditSink,
|
|
227
|
+
*,
|
|
228
|
+
category: AuditCategory,
|
|
229
|
+
tool_name: str | None = None,
|
|
230
|
+
mode_provider: Callable[[], str] = lambda: "unknown",
|
|
231
|
+
extra_extractor: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
|
232
|
+
) -> Callable[[F], F]:
|
|
233
|
+
"""Decorator that wraps an async tool handler with audit logging.
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
sink
|
|
238
|
+
Where to write events. Use :func:`open_sink` to construct.
|
|
239
|
+
category
|
|
240
|
+
``READ`` / ``WRITE`` / ``ADMIN`` — what the tool does at a high level.
|
|
241
|
+
Used for ``filter category=ADMIN`` in audit log analysis.
|
|
242
|
+
tool_name
|
|
243
|
+
MCP tool name. Defaults to the wrapped function's ``__name__``.
|
|
244
|
+
mode_provider
|
|
245
|
+
Callable returning the active permission mode string. Typically passed
|
|
246
|
+
``lambda: gate.mode.value`` from rbac.ModeGate.
|
|
247
|
+
extra_extractor
|
|
248
|
+
Optional function ``(kwargs) -> dict`` that pulls per-MCP fields out
|
|
249
|
+
of the call kwargs into the event's ``extra`` field. For panos-mcp
|
|
250
|
+
this would extract ``firewall``; for freshservice-mcp, perhaps a
|
|
251
|
+
workspace identifier.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
def decorator(func: F) -> F:
|
|
255
|
+
if not sink.enabled:
|
|
256
|
+
return func
|
|
257
|
+
|
|
258
|
+
name = tool_name or func.__name__
|
|
259
|
+
|
|
260
|
+
@functools.wraps(func)
|
|
261
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
262
|
+
start = time.monotonic()
|
|
263
|
+
request_id = str(uuid.uuid4())
|
|
264
|
+
success = False
|
|
265
|
+
error: str | None = None
|
|
266
|
+
try:
|
|
267
|
+
result = await func(*args, **kwargs)
|
|
268
|
+
# FastMCP tools that return {"error": "..."} should be recorded as failures.
|
|
269
|
+
if isinstance(result, Mapping) and isinstance(result.get("error"), str):
|
|
270
|
+
error = result["error"]
|
|
271
|
+
success = False
|
|
272
|
+
else:
|
|
273
|
+
success = True
|
|
274
|
+
return result
|
|
275
|
+
except Exception as exc:
|
|
276
|
+
error = str(exc) or exc.__class__.__name__
|
|
277
|
+
raise
|
|
278
|
+
finally:
|
|
279
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
280
|
+
extra: dict[str, Any] = {}
|
|
281
|
+
if extra_extractor is not None:
|
|
282
|
+
try:
|
|
283
|
+
extra = extra_extractor(kwargs) or {}
|
|
284
|
+
except Exception:
|
|
285
|
+
extra = {}
|
|
286
|
+
event = AuditEvent(
|
|
287
|
+
ts=_now_iso(),
|
|
288
|
+
request_id=request_id,
|
|
289
|
+
tool=name,
|
|
290
|
+
category=category,
|
|
291
|
+
mode=mode_provider(),
|
|
292
|
+
args=sanitize_args(kwargs),
|
|
293
|
+
success=success,
|
|
294
|
+
duration_ms=duration_ms,
|
|
295
|
+
error=error,
|
|
296
|
+
extra=extra,
|
|
297
|
+
)
|
|
298
|
+
sink.write(event)
|
|
299
|
+
|
|
300
|
+
return wrapper # type: ignore[return-value]
|
|
301
|
+
|
|
302
|
+
return decorator
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
__all__ = [
|
|
306
|
+
"DEFAULT_MAX_VALUE_LEN",
|
|
307
|
+
"DEFAULT_SENSITIVE_KEYS",
|
|
308
|
+
"AuditCategory",
|
|
309
|
+
"AuditEvent",
|
|
310
|
+
"AuditSink",
|
|
311
|
+
"JsonlSink",
|
|
312
|
+
"audit",
|
|
313
|
+
"open_sink",
|
|
314
|
+
"sanitize_args",
|
|
315
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Pure response-shaping utilities for token-efficient MCP responses.
|
|
2
|
+
|
|
3
|
+
Tools assemble their default response shape using these helpers — typically:
|
|
4
|
+
|
|
5
|
+
1. :func:`strip_keys` to drop SDK internals (UUIDs, dirty-state flags) that
|
|
6
|
+
the AI never needs.
|
|
7
|
+
2. :func:`whitelist` to keep only the fields the tool's default response
|
|
8
|
+
should expose, unless ``verbose=True`` is requested.
|
|
9
|
+
3. :func:`filter_by_pattern` for server-side name filtering when the caller
|
|
10
|
+
passed a ``pattern`` arg.
|
|
11
|
+
4. :func:`paginate` to enforce ``limit``/``offset`` before serializing.
|
|
12
|
+
|
|
13
|
+
All functions are pure — no I/O, no global state, easy to test and compose.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Iterable, Mapping
|
|
20
|
+
from typing import Any, TypeVar
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def whitelist(
|
|
24
|
+
data: Mapping[str, Any] | Iterable[Mapping[str, Any]],
|
|
25
|
+
fields: Iterable[str],
|
|
26
|
+
) -> dict[str, Any] | list[dict[str, Any]]:
|
|
27
|
+
"""Keep only the listed top-level keys.
|
|
28
|
+
|
|
29
|
+
Accepts either a single dict or an iterable of dicts; returns the same
|
|
30
|
+
shape with non-listed keys removed. Field names that aren't present in
|
|
31
|
+
the input are silently skipped (so callers can pass a generous whitelist).
|
|
32
|
+
|
|
33
|
+
Nested dict values are passed through unchanged — whitelisting is
|
|
34
|
+
intentionally shallow because the field whitelist is per-tool and tools
|
|
35
|
+
that need nested filtering should compose multiple whitelist calls.
|
|
36
|
+
"""
|
|
37
|
+
keep = set(fields)
|
|
38
|
+
if isinstance(data, Mapping):
|
|
39
|
+
return {k: v for k, v in data.items() if k in keep}
|
|
40
|
+
return [{k: v for k, v in item.items() if k in keep} for item in data]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def strip_keys(
|
|
44
|
+
data: Any,
|
|
45
|
+
keys: Iterable[str],
|
|
46
|
+
*,
|
|
47
|
+
recursive: bool = True,
|
|
48
|
+
) -> Any:
|
|
49
|
+
"""Remove the listed keys from a dict (or list of dicts) at any depth.
|
|
50
|
+
|
|
51
|
+
Useful for stripping SDK internals like ``@_uuid``, ``@_dirtyId``,
|
|
52
|
+
``@_loc`` that various XML responses include but the AI never reasons over.
|
|
53
|
+
|
|
54
|
+
Set ``recursive=False`` to only strip top-level keys (rare; useful when
|
|
55
|
+
the caller has already validated nested structure).
|
|
56
|
+
"""
|
|
57
|
+
drop = set(keys)
|
|
58
|
+
return _strip(data, drop, recursive)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _strip(value: Any, drop: set[str], recursive: bool) -> Any:
|
|
62
|
+
if isinstance(value, dict):
|
|
63
|
+
out = {k: v for k, v in value.items() if k not in drop}
|
|
64
|
+
if recursive:
|
|
65
|
+
out = {k: _strip(v, drop, True) for k, v in out.items()}
|
|
66
|
+
return out
|
|
67
|
+
if recursive and isinstance(value, list):
|
|
68
|
+
return [_strip(item, drop, True) for item in value]
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
T = TypeVar("T")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def paginate(items: list[T], limit: int, offset: int = 0) -> list[T]:
|
|
76
|
+
"""Slice ``items[offset : offset + limit]``.
|
|
77
|
+
|
|
78
|
+
Returns an empty list when ``offset`` exceeds the list length, rather than
|
|
79
|
+
raising — pagination should be forgiving.
|
|
80
|
+
"""
|
|
81
|
+
if offset < 0 or limit < 0:
|
|
82
|
+
raise ValueError("limit and offset must be non-negative")
|
|
83
|
+
return items[offset : offset + limit]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def filter_by_pattern(
|
|
87
|
+
items: Iterable[Mapping[str, Any]],
|
|
88
|
+
pattern: str | None,
|
|
89
|
+
*,
|
|
90
|
+
name_field: str = "name",
|
|
91
|
+
case_sensitive: bool = False,
|
|
92
|
+
use_regex: bool = False,
|
|
93
|
+
) -> list[Mapping[str, Any]]:
|
|
94
|
+
"""Filter items by substring or regex on ``name_field``.
|
|
95
|
+
|
|
96
|
+
Default is case-insensitive substring matching, which is what the AI
|
|
97
|
+
typically wants ("find rules matching 'social'"). Set ``use_regex=True``
|
|
98
|
+
when the caller passed a regex; the regex compiles with the same case
|
|
99
|
+
flag as substring mode.
|
|
100
|
+
|
|
101
|
+
Items missing ``name_field`` are excluded (no implicit pass-through —
|
|
102
|
+
a missing name field signals the caller used the wrong filter on the
|
|
103
|
+
wrong list).
|
|
104
|
+
"""
|
|
105
|
+
if not pattern:
|
|
106
|
+
return list(items)
|
|
107
|
+
|
|
108
|
+
if use_regex:
|
|
109
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
110
|
+
compiled = re.compile(pattern, flags)
|
|
111
|
+
return [
|
|
112
|
+
item for item in items
|
|
113
|
+
if isinstance(name := item.get(name_field), str) and compiled.search(name) is not None
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
needle = pattern if case_sensitive else pattern.casefold()
|
|
117
|
+
out: list[Mapping[str, Any]] = []
|
|
118
|
+
for item in items:
|
|
119
|
+
name = item.get(name_field)
|
|
120
|
+
if not isinstance(name, str):
|
|
121
|
+
continue
|
|
122
|
+
haystack = name if case_sensitive else name.casefold()
|
|
123
|
+
if needle in haystack:
|
|
124
|
+
out.append(item)
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = ["filter_by_pattern", "paginate", "strip_keys", "whitelist"]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Shared Pydantic schemas for token-efficient MCP tool parameters.
|
|
2
|
+
|
|
3
|
+
The point of this module is consistency across MCPs in the suite. Every tool
|
|
4
|
+
that accepts a verbose flag, a field whitelist, pagination, or a pattern
|
|
5
|
+
filter uses the same names, defaults, and descriptions. A user who learns
|
|
6
|
+
``verbose`` / ``fields`` / ``limit`` / ``offset`` / ``pattern`` in one MCP
|
|
7
|
+
finds the same conventions everywhere else.
|
|
8
|
+
|
|
9
|
+
Usage in a FastMCP tool::
|
|
10
|
+
|
|
11
|
+
from node804_mcp_toolkit.params import VerboseFlag, FieldsList, Pagination, Pattern
|
|
12
|
+
|
|
13
|
+
@mcp.tool()
|
|
14
|
+
def get_security_rules(
|
|
15
|
+
verbose: VerboseFlag = False,
|
|
16
|
+
fields: FieldsList = None,
|
|
17
|
+
pagination: Pagination = Pagination(),
|
|
18
|
+
pattern: Pattern = None,
|
|
19
|
+
) -> dict[str, Any]: ...
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Annotated
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel, Field
|
|
27
|
+
|
|
28
|
+
VerboseFlag = Annotated[
|
|
29
|
+
bool,
|
|
30
|
+
Field(
|
|
31
|
+
default=False,
|
|
32
|
+
description=(
|
|
33
|
+
"Return the full response from the underlying API. "
|
|
34
|
+
"Default false returns only the fields most relevant to the tool's purpose, "
|
|
35
|
+
"which typically reduces token use by 40-70%. "
|
|
36
|
+
"Set true when you need uncommon fields (UUIDs, audit timestamps, internal flags)."
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
"""Per-tool opt-in to full responses. Default false (lean)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
FieldsList = Annotated[
|
|
44
|
+
list[str] | None,
|
|
45
|
+
Field(
|
|
46
|
+
default=None,
|
|
47
|
+
description=(
|
|
48
|
+
"Optional explicit list of field names to include in the response. "
|
|
49
|
+
"Overrides the tool's default whitelist when set; takes precedence over verbose. "
|
|
50
|
+
"Use to project a small subset of fields when you know exactly what you need."
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
]
|
|
54
|
+
"""Per-tool field projection. Caller picks fields; bypasses default and verbose whitelists."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
Pattern = Annotated[
|
|
58
|
+
str | None,
|
|
59
|
+
Field(
|
|
60
|
+
default=None,
|
|
61
|
+
description=(
|
|
62
|
+
"Optional case-insensitive substring filter applied server-side to the name field "
|
|
63
|
+
"of returned items. Returns only items whose name contains this substring. "
|
|
64
|
+
"Reduces token cost dramatically vs fetching the full inventory and filtering client-side."
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
]
|
|
68
|
+
"""Server-side substring filter on a name field. Reduces dump-and-filter patterns."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Pagination(BaseModel):
|
|
72
|
+
"""Pagination parameters for list-returning tools.
|
|
73
|
+
|
|
74
|
+
Defaults are tuned for token efficiency: a 100-item ceiling per call keeps
|
|
75
|
+
response size bounded for any inventory size. Callers paginate by
|
|
76
|
+
incrementing ``offset`` between calls when they need more.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
limit: int = Field(
|
|
80
|
+
default=100,
|
|
81
|
+
ge=1,
|
|
82
|
+
le=1000,
|
|
83
|
+
description=(
|
|
84
|
+
"Maximum number of items to return. Default 100; max 1000. "
|
|
85
|
+
"Hard cap exists because dumping unbounded inventories can blow the AI's context window."
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
offset: int = Field(
|
|
89
|
+
default=0,
|
|
90
|
+
ge=0,
|
|
91
|
+
description="Number of items to skip before returning. Use with limit to page through results.",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
model_config = {"frozen": True}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = ["FieldsList", "Pagination", "Pattern", "VerboseFlag"]
|
|
File without changes
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Role-based access control for MCP tool registration.
|
|
2
|
+
|
|
3
|
+
Each tool is decorated with a required :class:`Mode`. At decoration time
|
|
4
|
+
(server startup), the :class:`ModeGate` checks whether the active mode (from
|
|
5
|
+
env) meets the requirement. Tools that don't qualify are NOT registered
|
|
6
|
+
with the MCP server, so the AI client never sees them — there's nothing
|
|
7
|
+
to call.
|
|
8
|
+
|
|
9
|
+
This is "default deny on misconfiguration": when ``PANOS_MODE`` is unset or
|
|
10
|
+
holds an unrecognized value, the gate falls back to the lowest mode (``READ``).
|
|
11
|
+
The operator must opt in to write capabilities explicitly.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
from mcp.server.fastmcp import FastMCP
|
|
16
|
+
from node804_mcp_toolkit.rbac import Mode, ModeGate
|
|
17
|
+
|
|
18
|
+
mcp = FastMCP("panos-mcp")
|
|
19
|
+
gate = ModeGate.from_env(env_var="PANOS_MODE")
|
|
20
|
+
|
|
21
|
+
@gate.tool(mcp, required=Mode.READ)
|
|
22
|
+
async def get_security_rules(...): ...
|
|
23
|
+
|
|
24
|
+
@gate.tool(mcp, required=Mode.ADMIN)
|
|
25
|
+
async def commit(...): ...
|
|
26
|
+
|
|
27
|
+
The ``gate.tool`` decorator composes :func:`audit` automatically when the
|
|
28
|
+
gate is constructed with an ``audit_sink``::
|
|
29
|
+
|
|
30
|
+
sink = open_sink(env_var="PANOS_AUDIT_LOG")
|
|
31
|
+
gate = ModeGate.from_env(env_var="PANOS_MODE", audit_sink=sink)
|
|
32
|
+
# All gated tools now also emit audit events with no extra decorators.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import functools
|
|
38
|
+
import os
|
|
39
|
+
import sys
|
|
40
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
41
|
+
from enum import IntEnum
|
|
42
|
+
from typing import Any, TypeVar
|
|
43
|
+
|
|
44
|
+
from .audit import AuditCategory, AuditSink, audit
|
|
45
|
+
|
|
46
|
+
F = TypeVar("F", bound=Callable[..., Awaitable[Any]])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Mode(IntEnum):
|
|
50
|
+
"""Permission tier. Higher values include all capabilities of lower ones.
|
|
51
|
+
|
|
52
|
+
Modes are an integer enum so comparison is hierarchical out of the box::
|
|
53
|
+
|
|
54
|
+
if active_mode >= Mode.FULL: ... # admin and full both pass
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
READ = 0
|
|
58
|
+
STANDARD = 1
|
|
59
|
+
FULL = 2
|
|
60
|
+
ADMIN = 3
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def parse(cls, raw: str | None, *, default: Mode) -> Mode:
|
|
64
|
+
"""Parse a string into a Mode, falling back to ``default`` on misses.
|
|
65
|
+
|
|
66
|
+
Accepts upper or lower case. Empty/None/unknown inputs return the
|
|
67
|
+
default — fail-safe to the most-restrictive mode usually.
|
|
68
|
+
"""
|
|
69
|
+
if not raw:
|
|
70
|
+
return default
|
|
71
|
+
v = raw.strip().lower()
|
|
72
|
+
for m in cls:
|
|
73
|
+
if m.name.lower() == v:
|
|
74
|
+
return m
|
|
75
|
+
return default
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Default audit category when one isn't supplied to gate.tool() — UNKNOWN
|
|
79
|
+
# rather than READ so it's obviously something to fix in code review.
|
|
80
|
+
_DEFAULT_AUDIT_CATEGORY: AuditCategory = "UNKNOWN"
|
|
81
|
+
|
|
82
|
+
# Suggested mapping from Mode → AuditCategory when the caller doesn't override.
|
|
83
|
+
_MODE_TO_CATEGORY: dict[Mode, AuditCategory] = {
|
|
84
|
+
Mode.READ: "READ",
|
|
85
|
+
Mode.STANDARD: "WRITE",
|
|
86
|
+
Mode.FULL: "WRITE",
|
|
87
|
+
Mode.ADMIN: "ADMIN",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ModeGate:
|
|
92
|
+
"""Holds the active mode and decorates tools with mode-gating + optional audit.
|
|
93
|
+
|
|
94
|
+
Construct with :meth:`from_env` to read the mode from environment, or
|
|
95
|
+
pass ``mode`` directly for tests. The instance keeps an internal map of
|
|
96
|
+
registered tools (``tool_name → required_mode``) populated as decorators
|
|
97
|
+
fire, which lets :meth:`summary` report exactly what's enabled at startup.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
mode: Mode,
|
|
104
|
+
env_var: str,
|
|
105
|
+
audit_sink: AuditSink | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._mode = mode
|
|
108
|
+
self._env_var = env_var
|
|
109
|
+
self._audit_sink = audit_sink
|
|
110
|
+
# name → (required, category, registered)
|
|
111
|
+
# registered=False means the tool was decorated but didn't make the cut.
|
|
112
|
+
self._registry: dict[str, tuple[Mode, AuditCategory, bool]] = {}
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_env(
|
|
116
|
+
cls,
|
|
117
|
+
*,
|
|
118
|
+
env_var: str,
|
|
119
|
+
default: Mode = Mode.READ,
|
|
120
|
+
env: Mapping[str, str] | None = None,
|
|
121
|
+
audit_sink: AuditSink | None = None,
|
|
122
|
+
warn_on_invalid: bool = True,
|
|
123
|
+
) -> ModeGate:
|
|
124
|
+
"""Resolve the active mode from environment.
|
|
125
|
+
|
|
126
|
+
Reads ``env[env_var]``, parses to :class:`Mode`, falls back to
|
|
127
|
+
``default`` on missing or unrecognized values. When the env var is
|
|
128
|
+
present but not a valid mode and ``warn_on_invalid=True``, prints
|
|
129
|
+
a one-time stderr warning so the misconfiguration is visible.
|
|
130
|
+
"""
|
|
131
|
+
e = env if env is not None else os.environ
|
|
132
|
+
raw = e.get(env_var)
|
|
133
|
+
parsed = Mode.parse(raw, default=default)
|
|
134
|
+
|
|
135
|
+
# raw was set, didn't match any mode, fell back to default → warn
|
|
136
|
+
fell_back = (
|
|
137
|
+
bool(raw)
|
|
138
|
+
and parsed is default
|
|
139
|
+
and (raw or "").strip().lower() != default.name.lower()
|
|
140
|
+
)
|
|
141
|
+
if fell_back and warn_on_invalid:
|
|
142
|
+
valid = ", ".join(m.name.lower() for m in Mode)
|
|
143
|
+
print(
|
|
144
|
+
f"[node804-mcp-toolkit] WARNING: invalid {env_var}='{raw}'. "
|
|
145
|
+
f"Falling back to '{default.name.lower()}'. Valid modes: {valid}",
|
|
146
|
+
file=sys.stderr,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return cls(mode=parsed, env_var=env_var, audit_sink=audit_sink)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def mode(self) -> Mode:
|
|
153
|
+
"""The active permission mode."""
|
|
154
|
+
return self._mode
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def env_var(self) -> str:
|
|
158
|
+
"""Name of the env var this gate reads from (e.g. ``PANOS_MODE``)."""
|
|
159
|
+
return self._env_var
|
|
160
|
+
|
|
161
|
+
def allows(self, required: Mode) -> bool:
|
|
162
|
+
"""Return True when the active mode meets or exceeds ``required``."""
|
|
163
|
+
return self._mode >= required
|
|
164
|
+
|
|
165
|
+
def tool(
|
|
166
|
+
self,
|
|
167
|
+
mcp: Any,
|
|
168
|
+
*,
|
|
169
|
+
required: Mode,
|
|
170
|
+
category: AuditCategory | None = None,
|
|
171
|
+
tool_name: str | None = None,
|
|
172
|
+
extra_extractor: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
|
173
|
+
**mcp_tool_kwargs: Any,
|
|
174
|
+
) -> Callable[[F], F]:
|
|
175
|
+
"""Decorator: register a tool only if ``required`` ≤ active mode.
|
|
176
|
+
|
|
177
|
+
When the gate denies, the function is recorded in the registry as
|
|
178
|
+
``registered=False`` and returned unchanged (no MCP registration).
|
|
179
|
+
When the gate allows, the function is wrapped with audit (if a sink
|
|
180
|
+
was provided) and registered via ``mcp.tool(**mcp_tool_kwargs)``.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
mcp
|
|
185
|
+
The FastMCP server instance to register against.
|
|
186
|
+
required
|
|
187
|
+
Minimum mode level needed to expose this tool.
|
|
188
|
+
category
|
|
189
|
+
Audit category override. Defaults to a sensible value derived
|
|
190
|
+
from ``required`` (READ → READ, STANDARD/FULL → WRITE, ADMIN → ADMIN).
|
|
191
|
+
tool_name
|
|
192
|
+
Override for the tool's registered name. Defaults to ``func.__name__``.
|
|
193
|
+
extra_extractor
|
|
194
|
+
Forwarded to :func:`audit.audit` to pull MCP-specific fields
|
|
195
|
+
out of call kwargs into the event's ``extra`` field.
|
|
196
|
+
**mcp_tool_kwargs
|
|
197
|
+
Forwarded to ``mcp.tool()`` (e.g. ``description=...``).
|
|
198
|
+
"""
|
|
199
|
+
cat = category if category is not None else _MODE_TO_CATEGORY.get(required, _DEFAULT_AUDIT_CATEGORY)
|
|
200
|
+
|
|
201
|
+
def decorator(func: F) -> F:
|
|
202
|
+
name = tool_name or func.__name__
|
|
203
|
+
allowed = self.allows(required)
|
|
204
|
+
self._registry[name] = (required, cat, allowed)
|
|
205
|
+
|
|
206
|
+
if not allowed:
|
|
207
|
+
# Don't register with MCP, don't wrap. The function still exists
|
|
208
|
+
# on the module, just isn't exposed as a tool.
|
|
209
|
+
return func
|
|
210
|
+
|
|
211
|
+
wrapped: F = func
|
|
212
|
+
if self._audit_sink is not None and self._audit_sink.enabled:
|
|
213
|
+
wrapped = audit(
|
|
214
|
+
self._audit_sink,
|
|
215
|
+
category=cat,
|
|
216
|
+
tool_name=name,
|
|
217
|
+
mode_provider=lambda: self._mode.name.lower(),
|
|
218
|
+
extra_extractor=extra_extractor,
|
|
219
|
+
)(wrapped)
|
|
220
|
+
|
|
221
|
+
# FastMCP's tool() returns a decorator; passing name= explicitly
|
|
222
|
+
# so we use the override when present.
|
|
223
|
+
registered: F = mcp.tool(name=name, **mcp_tool_kwargs)(wrapped)
|
|
224
|
+
return registered
|
|
225
|
+
|
|
226
|
+
return decorator
|
|
227
|
+
|
|
228
|
+
@functools.cached_property
|
|
229
|
+
def all_known_tools(self) -> tuple[str, ...]:
|
|
230
|
+
"""All tool names that have been decorated, in registration order."""
|
|
231
|
+
return tuple(self._registry.keys())
|
|
232
|
+
|
|
233
|
+
def summary(self) -> dict[str, Any]:
|
|
234
|
+
"""Diagnostic snapshot for ``server_status`` tools and startup logs.
|
|
235
|
+
|
|
236
|
+
Returns counts, the active mode, and lists of enabled vs blocked tool
|
|
237
|
+
names. Stable shape across MCPs in the suite for cross-MCP tooling.
|
|
238
|
+
"""
|
|
239
|
+
enabled = [name for name, (_, _, ok) in self._registry.items() if ok]
|
|
240
|
+
blocked = [name for name, (_, _, ok) in self._registry.items() if not ok]
|
|
241
|
+
return {
|
|
242
|
+
"mode": self._mode.name.lower(),
|
|
243
|
+
"env_var": self._env_var,
|
|
244
|
+
"tools_total": len(self._registry),
|
|
245
|
+
"tools_enabled": len(enabled),
|
|
246
|
+
"tools_blocked": len(blocked),
|
|
247
|
+
"enabled_tools": sorted(enabled),
|
|
248
|
+
"blocked_tools": sorted(blocked),
|
|
249
|
+
"audit": "enabled" if self._audit_sink and self._audit_sink.enabled else "disabled",
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
def describe(self) -> str:
|
|
253
|
+
"""One-line stderr-friendly description for startup logs."""
|
|
254
|
+
s = self.summary()
|
|
255
|
+
return (
|
|
256
|
+
f"mode='{s['mode']}' "
|
|
257
|
+
f"({s['tools_enabled']}/{s['tools_total']} tools enabled, "
|
|
258
|
+
f"{s['tools_blocked']} blocked)"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
__all__ = ["Mode", "ModeGate"]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""TLS verification config for MCP servers that talk to TLS endpoints directly.
|
|
2
|
+
|
|
3
|
+
Verify-by-default with two opt-outs that match what enterprise IT environments
|
|
4
|
+
actually need:
|
|
5
|
+
|
|
6
|
+
- ``{PREFIX}_TLS_VERIFY=false`` to disable verification entirely. Emits a
|
|
7
|
+
one-time stderr warning so the choice is visible in logs.
|
|
8
|
+
- ``{PREFIX}_TLS_CA=/path/to/ca.pem`` to load a custom CA bundle (typical for
|
|
9
|
+
internal-PKI-signed appliance certs). Setting this implies verify=true.
|
|
10
|
+
|
|
11
|
+
The ``PREFIX`` is per-MCP, e.g. ``PANOS_TLS_VERIFY``, ``PRTG_TLS_VERIFY``,
|
|
12
|
+
so users don't accidentally collide their settings across MCPs sharing a
|
|
13
|
+
shell session.
|
|
14
|
+
|
|
15
|
+
Most MCPs in the suite let their underlying SDK manage TLS internally
|
|
16
|
+
(``pan-os-python``, FreshService's httpx client). This module exists for the
|
|
17
|
+
ones that do raw httpx/requests calls — PRTG, Veeam, future direct REST
|
|
18
|
+
clients.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TlsConfig(BaseModel):
|
|
30
|
+
"""Resolved TLS settings for an outbound HTTPS client."""
|
|
31
|
+
|
|
32
|
+
verify: bool = Field(
|
|
33
|
+
default=True,
|
|
34
|
+
description="Whether to verify the server certificate against the trust store.",
|
|
35
|
+
)
|
|
36
|
+
ca_path: Path | None = Field(
|
|
37
|
+
default=None,
|
|
38
|
+
description="Path to a PEM-encoded CA bundle to add to the trust store, if any.",
|
|
39
|
+
)
|
|
40
|
+
ca_bundle: bytes | None = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
description="Loaded contents of ca_path, when readable. None if path missing or unreadable.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def description(self) -> str:
|
|
49
|
+
"""Human-readable summary for startup logs."""
|
|
50
|
+
if not self.verify:
|
|
51
|
+
return "verify=OFF (disabled via env)"
|
|
52
|
+
if self.ca_bundle is not None:
|
|
53
|
+
return f"verify=ON (custom CA: {self.ca_path})"
|
|
54
|
+
return "verify=ON (system trust store)"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_TRUTHY = frozenset({"true", "1", "yes", "on"})
|
|
58
|
+
_FALSY = frozenset({"false", "0", "no", "off"})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_bool(raw: str | None) -> bool | None:
|
|
62
|
+
"""Parse an env-var string to bool with fail-safe semantics.
|
|
63
|
+
|
|
64
|
+
Returns None when the value is unrecognized so the caller can decide the
|
|
65
|
+
default. We never silently coerce a typo to False — that's how production
|
|
66
|
+
accidents happen.
|
|
67
|
+
"""
|
|
68
|
+
if raw is None:
|
|
69
|
+
return None
|
|
70
|
+
v = raw.strip().lower()
|
|
71
|
+
if not v:
|
|
72
|
+
return None
|
|
73
|
+
if v in _TRUTHY:
|
|
74
|
+
return True
|
|
75
|
+
if v in _FALSY:
|
|
76
|
+
return False
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_tls_config(
|
|
81
|
+
env: dict[str, str],
|
|
82
|
+
*,
|
|
83
|
+
prefix: str,
|
|
84
|
+
warn_on_disable: bool = True,
|
|
85
|
+
) -> TlsConfig:
|
|
86
|
+
"""Resolve TLS settings from environment.
|
|
87
|
+
|
|
88
|
+
Reads ``{PREFIX}_TLS_VERIFY`` (default true on missing/invalid) and
|
|
89
|
+
``{PREFIX}_TLS_CA`` (optional PEM file path).
|
|
90
|
+
|
|
91
|
+
A CA bundle that fails to load (missing file, unreadable) warns to
|
|
92
|
+
stderr and falls back to the system trust store with verify still on.
|
|
93
|
+
Callers should treat this as a "soft failure" — the connection will
|
|
94
|
+
still attempt verification, just against the default trust store.
|
|
95
|
+
|
|
96
|
+
When verify is explicitly disabled and ``warn_on_disable=True``, prints
|
|
97
|
+
a one-time stderr warning so the operator sees the choice.
|
|
98
|
+
"""
|
|
99
|
+
verify_var = f"{prefix}_TLS_VERIFY"
|
|
100
|
+
ca_var = f"{prefix}_TLS_CA"
|
|
101
|
+
|
|
102
|
+
explicit = _parse_bool(env.get(verify_var))
|
|
103
|
+
verify = True if explicit is None else explicit
|
|
104
|
+
|
|
105
|
+
ca_path: Path | None = None
|
|
106
|
+
ca_bundle: bytes | None = None
|
|
107
|
+
raw_path = env.get(ca_var, "").strip() or None
|
|
108
|
+
if raw_path:
|
|
109
|
+
ca_path = Path(raw_path)
|
|
110
|
+
try:
|
|
111
|
+
ca_bundle = ca_path.read_bytes()
|
|
112
|
+
except OSError as err:
|
|
113
|
+
print(
|
|
114
|
+
f"[node804-mcp-toolkit] WARNING: {ca_var}='{raw_path}' could not be read ({err}). "
|
|
115
|
+
"Falling back to system trust store.",
|
|
116
|
+
file=sys.stderr,
|
|
117
|
+
)
|
|
118
|
+
ca_bundle = None
|
|
119
|
+
|
|
120
|
+
if not verify and warn_on_disable:
|
|
121
|
+
print(
|
|
122
|
+
f"[node804-mcp-toolkit] WARNING: TLS certificate verification is DISABLED ({verify_var}=false). "
|
|
123
|
+
"Connections are vulnerable to man-in-the-middle attacks. "
|
|
124
|
+
f"Use {ca_var}=/path/to/ca.pem to trust an internal CA instead.",
|
|
125
|
+
file=sys.stderr,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return TlsConfig(verify=verify, ca_path=ca_path, ca_bundle=ca_bundle)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def describe_tls(config: TlsConfig) -> str:
|
|
132
|
+
"""One-line description for startup logs (delegates to TlsConfig.description)."""
|
|
133
|
+
return config.description
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
__all__ = ["TlsConfig", "describe_tls", "resolve_tls_config"]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: node804-mcp-toolkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared utilities for token-efficient MCP servers — RBAC, audit logging, lean response shaping, TLS config
|
|
5
|
+
Project-URL: Homepage, https://github.com/Node804/node804-mcp-toolkit
|
|
6
|
+
Project-URL: Repository, https://github.com/Node804/node804-mcp-toolkit
|
|
7
|
+
Project-URL: Issues, https://github.com/Node804/node804-mcp-toolkit/issues
|
|
8
|
+
Author-email: Michael Pope <me@michaelpope.cv>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: audit,mcp,model-context-protocol,rbac,tls
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: pydantic>=2.10.6
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# node804-mcp-toolkit
|
|
33
|
+
|
|
34
|
+
[](https://github.com/Node804/node804-mcp-toolkit/actions/workflows/ci.yml)
|
|
35
|
+
[](https://pypi.org/project/node804-mcp-toolkit/)
|
|
36
|
+
[](https://pypi.org/project/node804-mcp-toolkit/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+
Shared utilities for building token-efficient, security-conscious [MCP](https://modelcontextprotocol.io) servers for IT operations. Provides the cross-cutting concerns every ops MCP needs — permission gating, audit logging, response shaping, TLS config — so each server in a suite implements them the same way.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install node804-mcp-toolkit
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## What's in here
|
|
48
|
+
|
|
49
|
+
| Module | Purpose |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `rbac` | Hierarchical permission modes (`read` / `standard` / `full` / `admin`); a gate decorator that only registers tools with the MCP server when the active mode qualifies — ungated tools are invisible to the AI client, not just blocked |
|
|
52
|
+
| `audit` | JSON-lines audit logging of every tool call with timing, success/error state, sensitive-key redaction, and long-value truncation |
|
|
53
|
+
| `lean` | Pure response-shaping helpers: field whitelisting, internal-key stripping, pagination, server-side pattern filtering |
|
|
54
|
+
| `tls` | Verify-by-default TLS config from env vars, with custom CA bundle support for internal PKI |
|
|
55
|
+
| `params` | Shared Pydantic parameter types (`VerboseFlag`, `FieldsList`, `Pagination`, `Pattern`) so every tool uses the same names, defaults, and descriptions |
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
Gate tools by permission mode, with audit logging composed in:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from mcp.server.fastmcp import FastMCP
|
|
63
|
+
from node804_mcp_toolkit import Mode, ModeGate, open_sink
|
|
64
|
+
|
|
65
|
+
mcp = FastMCP("panos-mcp")
|
|
66
|
+
|
|
67
|
+
# Audit sink: writes JSONL when PANOS_AUDIT_LOG is set, no-op otherwise.
|
|
68
|
+
sink = open_sink(env_var="PANOS_AUDIT_LOG")
|
|
69
|
+
|
|
70
|
+
# Mode comes from env. Missing or invalid values fail safe to read-only.
|
|
71
|
+
gate = ModeGate.from_env(env_var="PANOS_MODE", audit_sink=sink)
|
|
72
|
+
|
|
73
|
+
@gate.tool(mcp, required=Mode.READ)
|
|
74
|
+
async def get_security_rules(...): ...
|
|
75
|
+
|
|
76
|
+
@gate.tool(mcp, required=Mode.ADMIN)
|
|
77
|
+
async def commit(...): ... # not registered at all unless PANOS_MODE=admin
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Shape responses for token efficiency:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from node804_mcp_toolkit import filter_by_pattern, paginate, strip_keys, whitelist
|
|
84
|
+
|
|
85
|
+
rules = strip_keys(raw_rules, ["@uuid", "@loc"]) # drop SDK internals
|
|
86
|
+
rules = filter_by_pattern(rules, pattern, name_field="name")
|
|
87
|
+
rules = paginate(rules, limit=100, offset=0)
|
|
88
|
+
rules = whitelist(rules, ["name", "action", "source", "destination"])
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Resolve TLS settings from environment (verify-by-default):
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from node804_mcp_toolkit import resolve_tls_config
|
|
95
|
+
import httpx, os
|
|
96
|
+
|
|
97
|
+
tls = resolve_tls_config(dict(os.environ), prefix="PRTG")
|
|
98
|
+
# PRTG_TLS_VERIFY=false → verify off, with a loud stderr warning
|
|
99
|
+
# PRTG_TLS_CA=/path.pem → custom CA bundle for internal PKI
|
|
100
|
+
client = httpx.Client(verify=str(tls.ca_path) if tls.ca_bundle else tls.verify)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Design philosophy
|
|
104
|
+
|
|
105
|
+
- **One toolkit, one set of patterns.** A user who learns the parameter conventions in any one MCP — `verbose`, `fields`, `limit`, `offset`, `pattern` — gets the same conventions in all of them.
|
|
106
|
+
- **Default deny.** Unset or misconfigured mode env vars fall back to read-only. Tools above the active mode are never registered, so the AI client can't even see them.
|
|
107
|
+
- **Token-lean by default.** Responses expose the fields the AI actually reasons over; `verbose=true` opts into the full payload.
|
|
108
|
+
- **Best-effort observability.** Audit logging never breaks a tool call — sink failures warn once to stderr and move on.
|
|
109
|
+
|
|
110
|
+
## Used by
|
|
111
|
+
|
|
112
|
+
- [`node804-panos-mcp`](https://github.com/Node804) — Palo Alto firewall management
|
|
113
|
+
- [`node804-freshservice-mcp`](https://github.com/Node804) — Freshservice ticketing
|
|
114
|
+
- Planned: PRTG and Veeam MCPs
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install -e ".[dev]"
|
|
120
|
+
pytest
|
|
121
|
+
ruff check .
|
|
122
|
+
mypy src
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Requires Python 3.11+. Fully typed (`py.typed` included), `mypy --strict` clean.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
node804_mcp_toolkit/__init__.py,sha256=7UUdbPIEaxYASi4LJM5pbht6fcbhDRDz4W1hl8kqpZw,951
|
|
2
|
+
node804_mcp_toolkit/audit.py,sha256=l8RhknEUXIuTqZpq93-zFJVF3GcOccwAp2k9gNhqEps,10416
|
|
3
|
+
node804_mcp_toolkit/lean.py,sha256=V03HgVZwQBp7aZABjyJSBoOhtuxPMS-Ndu19dMBel4g,4418
|
|
4
|
+
node804_mcp_toolkit/params.py,sha256=3FX0629InyipVEQFogFrDZL5WRNjHqCcITFkqb8n83A,3195
|
|
5
|
+
node804_mcp_toolkit/rbac.py,sha256=q3phtq8q8yW0VuXceppBmSEDgiux_l81hTpSiX4BJ14,9464
|
|
6
|
+
node804_mcp_toolkit/tls.py,sha256=3Gl5EUJvX5SmNiAY4YxQupUUNPw4Z1kfX7nz-_zYGSo,4614
|
|
7
|
+
node804_mcp_toolkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
node804_mcp_toolkit-0.1.0.dist-info/METADATA,sha256=WT1WKLnBD9iQgl1HcWrrR37gaY3tAzzVRtDT5L3ucO0,5584
|
|
9
|
+
node804_mcp_toolkit-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
node804_mcp_toolkit-0.1.0.dist-info/licenses/LICENSE,sha256=MWZG7mvTKQAmF-bl5ZJ9VkzcpQy-ZPgzRoMe_rKekTc,1069
|
|
11
|
+
node804_mcp_toolkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Pope
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|