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.
@@ -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
+ [![CI](https://github.com/Node804/node804-mcp-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/Node804/node804-mcp-toolkit/actions/workflows/ci.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/node804-mcp-toolkit)](https://pypi.org/project/node804-mcp-toolkit/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/node804-mcp-toolkit)](https://pypi.org/project/node804-mcp-toolkit/)
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.