mcp-zero-trust-layer 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.
Files changed (64) hide show
  1. mcp_zero_trust_layer/__init__.py +4 -0
  2. mcp_zero_trust_layer/approvals/__init__.py +5 -0
  3. mcp_zero_trust_layer/approvals/models.py +33 -0
  4. mcp_zero_trust_layer/approvals/notifier.py +30 -0
  5. mcp_zero_trust_layer/approvals/store.py +138 -0
  6. mcp_zero_trust_layer/audit/__init__.py +3 -0
  7. mcp_zero_trust_layer/audit/logger.py +171 -0
  8. mcp_zero_trust_layer/capabilities/__init__.py +10 -0
  9. mcp_zero_trust_layer/capabilities/discovery.py +114 -0
  10. mcp_zero_trust_layer/capabilities/filtering.py +55 -0
  11. mcp_zero_trust_layer/capabilities/mapping.py +24 -0
  12. mcp_zero_trust_layer/cli/__init__.py +2 -0
  13. mcp_zero_trust_layer/cli/main.py +880 -0
  14. mcp_zero_trust_layer/config/__init__.py +5 -0
  15. mcp_zero_trust_layer/config/loader.py +26 -0
  16. mcp_zero_trust_layer/config/models.py +231 -0
  17. mcp_zero_trust_layer/config/secrets.py +156 -0
  18. mcp_zero_trust_layer/core/__init__.py +3 -0
  19. mcp_zero_trust_layer/core/context.py +27 -0
  20. mcp_zero_trust_layer/core/pipeline.py +401 -0
  21. mcp_zero_trust_layer/errors.py +14 -0
  22. mcp_zero_trust_layer/identity/__init__.py +4 -0
  23. mcp_zero_trust_layer/identity/auth.py +267 -0
  24. mcp_zero_trust_layer/identity/models.py +22 -0
  25. mcp_zero_trust_layer/observability/__init__.py +3 -0
  26. mcp_zero_trust_layer/observability/metrics.py +46 -0
  27. mcp_zero_trust_layer/output/__init__.py +4 -0
  28. mcp_zero_trust_layer/output/enforcer.py +44 -0
  29. mcp_zero_trust_layer/packs/__init__.py +4 -0
  30. mcp_zero_trust_layer/packs/filesystem-safe.yaml +34 -0
  31. mcp_zero_trust_layer/packs/github-readonly.yaml +35 -0
  32. mcp_zero_trust_layer/packs/postgres-readonly.yaml +20 -0
  33. mcp_zero_trust_layer/packs/registry.py +32 -0
  34. mcp_zero_trust_layer/policy/__init__.py +5 -0
  35. mcp_zero_trust_layer/policy/adapters.py +99 -0
  36. mcp_zero_trust_layer/policy/conditions.py +69 -0
  37. mcp_zero_trust_layer/policy/engine.py +292 -0
  38. mcp_zero_trust_layer/policy/models.py +21 -0
  39. mcp_zero_trust_layer/protocol/__init__.py +17 -0
  40. mcp_zero_trust_layer/protocol/jsonrpc.py +53 -0
  41. mcp_zero_trust_layer/py.typed +1 -0
  42. mcp_zero_trust_layer/security/__init__.py +3 -0
  43. mcp_zero_trust_layer/security/scanner.py +165 -0
  44. mcp_zero_trust_layer/transports/__init__.py +2 -0
  45. mcp_zero_trust_layer/transports/http/__init__.py +4 -0
  46. mcp_zero_trust_layer/transports/http/app.py +241 -0
  47. mcp_zero_trust_layer/transports/http/server.py +24 -0
  48. mcp_zero_trust_layer/transports/stdio/__init__.py +4 -0
  49. mcp_zero_trust_layer/transports/stdio/wrapper.py +75 -0
  50. mcp_zero_trust_layer/upstream/__init__.py +4 -0
  51. mcp_zero_trust_layer/upstream/base.py +17 -0
  52. mcp_zero_trust_layer/upstream/http.py +125 -0
  53. mcp_zero_trust_layer/upstream/stdio.py +74 -0
  54. mcp_zero_trust_layer/validators/__init__.py +5 -0
  55. mcp_zero_trust_layer/validators/basic.py +230 -0
  56. mcp_zero_trust_layer/validators/engine.py +46 -0
  57. mcp_zero_trust_layer/validators/input_policy.py +60 -0
  58. mcp_zero_trust_layer/validators/models.py +17 -0
  59. mcp_zero_trust_layer-0.1.0.dist-info/METADATA +961 -0
  60. mcp_zero_trust_layer-0.1.0.dist-info/RECORD +64 -0
  61. mcp_zero_trust_layer-0.1.0.dist-info/WHEEL +5 -0
  62. mcp_zero_trust_layer-0.1.0.dist-info/entry_points.txt +3 -0
  63. mcp_zero_trust_layer-0.1.0.dist-info/licenses/LICENSE +200 -0
  64. mcp_zero_trust_layer-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4 @@
1
+ """MCP Zero Trust Layer package."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,5 @@
1
+ from .models import ApprovalRequest
2
+ from .notifier import ApprovalNotifier
3
+ from .store import ApprovalStore, hash_arguments
4
+
5
+ __all__ = ["ApprovalNotifier", "ApprovalRequest", "ApprovalStore", "hash_arguments"]
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Literal
5
+ from uuid import uuid4
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class ApprovalRequest(BaseModel):
11
+ id: str = Field(default_factory=lambda: f"appr_{uuid4().hex}")
12
+ status: Literal["pending", "approved", "denied", "expired"] = "pending"
13
+ server: str
14
+ capability: str | None = None
15
+ capability_type: str
16
+ policy_id: str
17
+ identity_subject: str
18
+ client_id: str | None = None
19
+ agent_id: str | None = None
20
+ arguments_hash: str
21
+ arguments_redacted: dict[str, Any] = Field(default_factory=dict)
22
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
23
+ expires_at: datetime | None = None
24
+ decided_at: datetime | None = None
25
+ decided_by: str | None = None
26
+ decision_comment: str | None = None
27
+
28
+ def is_active(self) -> bool:
29
+ if self.status != "approved":
30
+ return False
31
+ if self.expires_at is None:
32
+ return True
33
+ return self.expires_at > datetime.now(timezone.utc)
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from mcp_zero_trust_layer.audit import redact_sensitive
8
+ from mcp_zero_trust_layer.config.models import ApprovalsConfig
9
+ from mcp_zero_trust_layer.config.secrets import resolve_secret_value
10
+
11
+
12
+ class ApprovalNotifier:
13
+ def __init__(self, config: ApprovalsConfig):
14
+ self.config = config
15
+
16
+ def notify(self, action: str, approval: dict[str, Any]) -> None:
17
+ if not self.config.webhook_url:
18
+ return
19
+ url = resolve_secret_value(self.config.webhook_url, field="approvals.webhook_url")
20
+ payload = {
21
+ "event_type": "approval",
22
+ "action": action,
23
+ "approval": redact_sensitive(approval),
24
+ }
25
+ try:
26
+ response = httpx.post(url, json=payload, timeout=self.config.webhook_timeout)
27
+ response.raise_for_status()
28
+ except Exception:
29
+ if self.config.webhook_strict:
30
+ raise
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import hashlib
6
+ import json
7
+ from datetime import datetime, timedelta, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ try:
12
+ import fcntl
13
+ except ImportError: # pragma: no cover - Windows fallback
14
+ fcntl = None # type: ignore[assignment]
15
+
16
+ from mcp_zero_trust_layer.approvals.models import ApprovalRequest
17
+ from mcp_zero_trust_layer.audit import redact_sensitive
18
+ from mcp_zero_trust_layer.config.models import ApprovalsConfig
19
+ from mcp_zero_trust_layer.core.context import RequestContext
20
+
21
+
22
+ class ApprovalStore:
23
+ def __init__(self, config: ApprovalsConfig):
24
+ self.path = Path(config.path)
25
+ self.lock_path = Path(f"{self.path}.lock")
26
+ self.default_ttl_seconds = config.default_ttl_seconds
27
+
28
+ def create(self, context: RequestContext, policy_id: str) -> ApprovalRequest:
29
+ with self._locked():
30
+ request = ApprovalRequest(
31
+ server=context.server,
32
+ capability=context.capability,
33
+ capability_type=context.capability_type,
34
+ policy_id=policy_id,
35
+ identity_subject=context.identity.subject,
36
+ client_id=context.identity.client_id,
37
+ agent_id=context.identity.agent_id,
38
+ arguments_hash=hash_arguments(context.arguments),
39
+ arguments_redacted=redact_sensitive(context.arguments),
40
+ expires_at=datetime.now(timezone.utc) + timedelta(seconds=self.default_ttl_seconds),
41
+ )
42
+ approvals = self._load_unlocked()
43
+ approvals[request.id] = request
44
+ self._save_unlocked(approvals)
45
+ return request
46
+
47
+ def get(self, approval_id: str) -> ApprovalRequest | None:
48
+ return self._load().get(approval_id)
49
+
50
+ def list(self) -> list[ApprovalRequest]:
51
+ return sorted(self._load().values(), key=lambda item: item.created_at)
52
+
53
+ def set_status(
54
+ self,
55
+ approval_id: str,
56
+ status: Literal["pending", "approved", "denied", "expired"],
57
+ *,
58
+ decided_by: str | None = None,
59
+ decision_comment: str | None = None,
60
+ ) -> ApprovalRequest:
61
+ with self._locked():
62
+ approvals = self._load_unlocked()
63
+ if approval_id not in approvals:
64
+ raise KeyError(approval_id)
65
+ update: dict[str, Any] = {"status": status}
66
+ if status == "pending":
67
+ update.update(
68
+ {
69
+ "decided_at": None,
70
+ "decided_by": None,
71
+ "decision_comment": None,
72
+ }
73
+ )
74
+ else:
75
+ update["decided_at"] = datetime.now(timezone.utc)
76
+ update["decided_by"] = decided_by
77
+ update["decision_comment"] = decision_comment
78
+ updated = approvals[approval_id].model_copy(update=update)
79
+ approvals[approval_id] = updated
80
+ self._save_unlocked(approvals)
81
+ return updated
82
+
83
+ def is_valid_for(self, approval_id: str, context: RequestContext, policy_id: str) -> bool:
84
+ approval = self.get(approval_id)
85
+ if approval is None or not approval.is_active():
86
+ return False
87
+ return all(
88
+ [
89
+ approval.policy_id == policy_id,
90
+ approval.server == context.server,
91
+ approval.capability == context.capability,
92
+ approval.capability_type == context.capability_type,
93
+ approval.identity_subject == context.identity.subject,
94
+ approval.client_id == context.identity.client_id,
95
+ approval.agent_id == context.identity.agent_id,
96
+ approval.arguments_hash == hash_arguments(context.arguments),
97
+ ]
98
+ )
99
+
100
+ def _load(self) -> dict[str, ApprovalRequest]:
101
+ with self._locked():
102
+ return self._load_unlocked()
103
+
104
+ def _load_unlocked(self) -> dict[str, ApprovalRequest]:
105
+ if not self.path.exists():
106
+ return {}
107
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
108
+ return {
109
+ approval_id: ApprovalRequest.model_validate(payload)
110
+ for approval_id, payload in raw.items()
111
+ }
112
+
113
+ def _save_unlocked(self, approvals: dict[str, ApprovalRequest]) -> None:
114
+ self.path.parent.mkdir(parents=True, exist_ok=True)
115
+ payload = {
116
+ approval_id: approval.model_dump(mode="json")
117
+ for approval_id, approval in approvals.items()
118
+ }
119
+ tmp_path = self.path.with_name(f".{self.path.name}.{os.getpid()}.tmp")
120
+ tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
121
+ os.replace(tmp_path, self.path)
122
+
123
+ @contextlib.contextmanager
124
+ def _locked(self) -> Any:
125
+ self.lock_path.parent.mkdir(parents=True, exist_ok=True)
126
+ with self.lock_path.open("a+", encoding="utf-8") as handle:
127
+ if fcntl is not None:
128
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
129
+ try:
130
+ yield
131
+ finally:
132
+ if fcntl is not None:
133
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
134
+
135
+
136
+ def hash_arguments(arguments: dict[str, Any]) -> str:
137
+ canonical = json.dumps(arguments, sort_keys=True, separators=(",", ":"), default=str)
138
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
@@ -0,0 +1,3 @@
1
+ from .logger import AuditLogger, redact_sensitive, verify_audit_hash_chain
2
+
3
+ __all__ = ["AuditLogger", "redact_sensitive", "verify_audit_hash_chain"]
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import sys
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from uuid import uuid4
11
+
12
+ from mcp_zero_trust_layer.config.models import AuditConfig
13
+ from mcp_zero_trust_layer.core.context import RequestContext
14
+ from mcp_zero_trust_layer.policy import PolicyDecision
15
+
16
+ SECRET_KEY_RE = re.compile(r"(password|token|api[_-]?key|secret|authorization|cookie)", re.I)
17
+ SECRET_VALUE_RES = [
18
+ re.compile(r"Bearer\s+[A-Za-z0-9._\-]+", re.I),
19
+ re.compile(r"sk-[A-Za-z0-9]{20,}"),
20
+ ]
21
+
22
+
23
+ class AuditLogger:
24
+ def __init__(self, config: AuditConfig):
25
+ self.config = config
26
+
27
+ def log_decision(
28
+ self,
29
+ context: RequestContext,
30
+ decision: PolicyDecision,
31
+ *,
32
+ upstream_called: bool | None = None,
33
+ upstream_status: str | None = None,
34
+ ) -> dict[str, Any]:
35
+ event = {
36
+ "event_id": f"evt_{uuid4().hex}",
37
+ "timestamp": datetime.now(timezone.utc).isoformat(),
38
+ "correlation_id": context.correlation_id or f"corr_{uuid4().hex}",
39
+ "event_type": "policy_decision",
40
+ "identity": redact_sensitive(context.identity.model_dump()),
41
+ "server": context.server,
42
+ "method": context.method,
43
+ "capability_type": context.capability_type,
44
+ "capability": context.capability,
45
+ "decision": decision.decision,
46
+ "policy_id": decision.policy_id,
47
+ "reason": decision.reason,
48
+ "arguments_redacted": redact_sensitive(context.arguments),
49
+ "dry_run": decision.dry_run,
50
+ "approval_required": decision.approval_required,
51
+ "upstream_called": upstream_called,
52
+ "upstream_status": upstream_status,
53
+ }
54
+ self._write(event)
55
+ return event
56
+
57
+ def log_approval(self, action: str, approval: dict[str, Any]) -> dict[str, Any]:
58
+ event = {
59
+ "event_id": f"evt_{uuid4().hex}",
60
+ "timestamp": datetime.now(timezone.utc).isoformat(),
61
+ "event_type": "approval",
62
+ "action": action,
63
+ "approval": redact_sensitive(approval),
64
+ }
65
+ self._write(event)
66
+ return event
67
+
68
+ def _write(self, event: dict[str, Any]) -> None:
69
+ if self.config.hash_chain:
70
+ event = self._with_hash(event)
71
+ line = json.dumps(event, sort_keys=True)
72
+ if self.config.destination == "stdout":
73
+ print(line)
74
+ return
75
+
76
+ path = Path(self.config.path)
77
+ try:
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ with path.open("a", encoding="utf-8") as handle:
80
+ handle.write(line + "\n")
81
+ except OSError:
82
+ if self.config.strict:
83
+ raise
84
+ log_to_stderr(f"mcpzt audit write failed for {path}")
85
+
86
+ def _with_hash(self, event: dict[str, Any]) -> dict[str, Any]:
87
+ event = dict(event)
88
+ event["previous_event_hash"] = self._previous_hash()
89
+ event["event_hash"] = event_hash(event)
90
+ return event
91
+
92
+ def _previous_hash(self) -> str | None:
93
+ if self.config.destination != "file":
94
+ return None
95
+ path = Path(self.config.path)
96
+ if not path.exists():
97
+ return None
98
+ try:
99
+ with path.open("rb") as handle:
100
+ handle.seek(0, 2)
101
+ position = handle.tell()
102
+ if position == 0:
103
+ return None
104
+ buffer = bytearray()
105
+ position -= 1
106
+ while position >= 0:
107
+ handle.seek(position)
108
+ char = handle.read(1)
109
+ if char == b"\n" and buffer:
110
+ break
111
+ if char != b"\n":
112
+ buffer.extend(char)
113
+ position -= 1
114
+ last_line = bytes(reversed(buffer)).decode("utf-8")
115
+ if not last_line:
116
+ return None
117
+ event = json.loads(last_line)
118
+ previous = event.get("event_hash")
119
+ return previous if isinstance(previous, str) else None
120
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
121
+ return None
122
+
123
+
124
+ def event_hash(event: dict[str, Any]) -> str:
125
+ hashed = {key: value for key, value in event.items() if key != "event_hash"}
126
+ payload = json.dumps(hashed, sort_keys=True, separators=(",", ":"))
127
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
128
+
129
+
130
+ def verify_audit_hash_chain(path: str | Path) -> tuple[bool, str]:
131
+ previous: str | None = None
132
+ try:
133
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
134
+ except OSError as exc:
135
+ return False, str(exc)
136
+
137
+ for index, line in enumerate(lines, start=1):
138
+ try:
139
+ event = json.loads(line)
140
+ except json.JSONDecodeError as exc:
141
+ return False, f"line {index}: invalid JSON: {exc}"
142
+ if event.get("previous_event_hash") != previous:
143
+ return False, f"line {index}: previous_event_hash mismatch"
144
+ expected = event_hash(event)
145
+ if event.get("event_hash") != expected:
146
+ return False, f"line {index}: event_hash mismatch"
147
+ previous = expected
148
+ return True, f"verified {len(lines)} event(s)"
149
+
150
+
151
+ def redact_sensitive(value: Any) -> Any:
152
+ if isinstance(value, dict):
153
+ redacted: dict[str, Any] = {}
154
+ for key, item in value.items():
155
+ if SECRET_KEY_RE.search(str(key)):
156
+ redacted[key] = "[REDACTED]"
157
+ else:
158
+ redacted[key] = redact_sensitive(item)
159
+ return redacted
160
+ if isinstance(value, list):
161
+ return [redact_sensitive(item) for item in value]
162
+ if isinstance(value, str):
163
+ redacted = value
164
+ for pattern in SECRET_VALUE_RES:
165
+ redacted = pattern.sub("[REDACTED]", redacted)
166
+ return redacted
167
+ return value
168
+
169
+
170
+ def log_to_stderr(message: str) -> None:
171
+ print(message, file=sys.stderr)
@@ -0,0 +1,10 @@
1
+ from .discovery import CapabilityDiff, CapabilitySnapshot, diff_snapshots, discover_capabilities
2
+ from .mapping import lookup_capability_metadata
3
+
4
+ __all__ = [
5
+ "CapabilityDiff",
6
+ "CapabilitySnapshot",
7
+ "diff_snapshots",
8
+ "discover_capabilities",
9
+ "lookup_capability_metadata",
10
+ ]
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from mcp_zero_trust_layer.config.models import MCPZTConfig, ServerConfig
12
+ from mcp_zero_trust_layer.upstream import UpstreamClient
13
+
14
+ DISCOVERY_METHODS = {
15
+ "tools": ("tools/list", "tools", "name"),
16
+ "resources": ("resources/list", "resources", "uri"),
17
+ "prompts": ("prompts/list", "prompts", "name"),
18
+ }
19
+
20
+
21
+ class CapabilitySnapshot(BaseModel):
22
+ server: str
23
+ discovered_at: str
24
+ tools: list[dict[str, Any]] = Field(default_factory=list)
25
+ resources: list[dict[str, Any]] = Field(default_factory=list)
26
+ prompts: list[dict[str, Any]] = Field(default_factory=list)
27
+ errors: dict[str, str] = Field(default_factory=dict)
28
+
29
+
30
+ class CapabilityDiff(BaseModel):
31
+ server: str
32
+ added: dict[str, list[str]] = Field(default_factory=dict)
33
+ removed: dict[str, list[str]] = Field(default_factory=dict)
34
+ changed: dict[str, list[str]] = Field(default_factory=dict)
35
+
36
+ def has_changes(self) -> bool:
37
+ return any(self.added.values()) or any(self.removed.values()) or any(self.changed.values())
38
+
39
+
40
+ def discover_capabilities(
41
+ config: MCPZTConfig,
42
+ server_name: str,
43
+ upstream: UpstreamClient,
44
+ ) -> CapabilitySnapshot:
45
+ server = _server(config, server_name)
46
+ snapshot = CapabilitySnapshot(
47
+ server=server.name,
48
+ discovered_at=datetime.now(timezone.utc).isoformat(),
49
+ )
50
+ for field, (method, result_key, _identity_key) in DISCOVERY_METHODS.items():
51
+ request = {"jsonrpc": "2.0", "id": field, "method": method, "params": {}}
52
+ try:
53
+ response = upstream.send(server, request)
54
+ except Exception as exc: # discovery should collect per-capability errors
55
+ snapshot.errors[field] = str(exc)
56
+ continue
57
+ result = response.get("result") if isinstance(response, dict) else None
58
+ items = result.get(result_key) if isinstance(result, dict) else []
59
+ if isinstance(items, list):
60
+ setattr(snapshot, field, items)
61
+ return snapshot
62
+
63
+
64
+ def diff_snapshots(previous: CapabilitySnapshot, current: CapabilitySnapshot) -> CapabilityDiff:
65
+ diff = CapabilityDiff(server=current.server)
66
+ for field, (_method, _result_key, identity_key) in DISCOVERY_METHODS.items():
67
+ previous_items = _indexed(getattr(previous, field), identity_key)
68
+ current_items = _indexed(getattr(current, field), identity_key)
69
+ previous_names = set(previous_items)
70
+ current_names = set(current_items)
71
+ diff.added[field] = sorted(current_names - previous_names)
72
+ diff.removed[field] = sorted(previous_names - current_names)
73
+ diff.changed[field] = sorted(
74
+ name
75
+ for name in previous_names & current_names
76
+ if _fingerprint(previous_items[name]) != _fingerprint(current_items[name])
77
+ )
78
+ return diff
79
+
80
+
81
+ def write_snapshot(snapshot: CapabilitySnapshot, path: str | Path) -> None:
82
+ snapshot_path = Path(path)
83
+ snapshot_path.parent.mkdir(parents=True, exist_ok=True)
84
+ snapshot_path.write_text(snapshot.model_dump_json(indent=2) + "\n", encoding="utf-8")
85
+
86
+
87
+ def read_snapshot(path: str | Path) -> CapabilitySnapshot:
88
+ return CapabilitySnapshot.model_validate_json(Path(path).read_text(encoding="utf-8"))
89
+
90
+
91
+ def default_snapshot_path(server_name: str) -> Path:
92
+ return Path(".mcpzt-capabilities") / f"{server_name}.json"
93
+
94
+
95
+ def _server(config: MCPZTConfig, name: str) -> ServerConfig:
96
+ for server in config.servers:
97
+ if server.name == name:
98
+ return server
99
+ raise ValueError(f"unknown server: {name}")
100
+
101
+
102
+ def _indexed(items: list[dict[str, Any]], identity_key: str) -> dict[str, dict[str, Any]]:
103
+ indexed: dict[str, dict[str, Any]] = {}
104
+ for item in items:
105
+ identity = item.get(identity_key)
106
+ if isinstance(identity, str):
107
+ indexed[identity] = item
108
+ return indexed
109
+
110
+
111
+ def _fingerprint(item: dict[str, Any]) -> str:
112
+ canonical = json.dumps(item, sort_keys=True, separators=(",", ":"), default=str)
113
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
114
+
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from mcp_zero_trust_layer.config.models import MCPZTConfig
6
+ from mcp_zero_trust_layer.core import RequestContext
7
+ from mcp_zero_trust_layer.identity import Identity
8
+ from mcp_zero_trust_layer.policy.engine import PolicyEngine
9
+
10
+ CapabilityType = Literal["tool", "resource", "prompt"]
11
+
12
+
13
+ def filter_capabilities(
14
+ config: MCPZTConfig,
15
+ server: str,
16
+ capability_type: CapabilityType,
17
+ capabilities: list[dict[str, Any]],
18
+ *,
19
+ identity: Identity | None = None,
20
+ environment: str | None = None,
21
+ ) -> list[dict[str, Any]]:
22
+ """Return only capabilities visible to the supplied identity/context."""
23
+ if config.runtime.dry_run:
24
+ return capabilities
25
+
26
+ method_by_type = {
27
+ "tool": "tools/list",
28
+ "resource": "resources/list",
29
+ "prompt": "prompts/list",
30
+ }
31
+ name_key_by_type = {
32
+ "tool": "name",
33
+ "resource": "uri",
34
+ "prompt": "name",
35
+ }
36
+ engine = PolicyEngine(config)
37
+ visible: list[dict[str, Any]] = []
38
+ name_key = name_key_by_type[capability_type]
39
+
40
+ for capability in capabilities:
41
+ capability_name = capability.get(name_key)
42
+ context = RequestContext(
43
+ server=server,
44
+ method=method_by_type[capability_type],
45
+ capability_type=capability_type,
46
+ capability=capability_name,
47
+ identity=identity or Identity(),
48
+ environment=environment or config.project.environment,
49
+ config_base_dir=config.config_base_dir,
50
+ )
51
+ decision = engine.evaluate(context)
52
+ if decision.decision in {"allow", "require_approval", "redact", "limit", "transform", "log"}:
53
+ visible.append(capability)
54
+
55
+ return visible
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from mcp_zero_trust_layer.config.models import CapabilityMetadata, MCPZTConfig
4
+ from mcp_zero_trust_layer.core import RequestContext
5
+
6
+
7
+ def lookup_capability_metadata(
8
+ config: MCPZTConfig, context: RequestContext
9
+ ) -> CapabilityMetadata | None:
10
+ if not context.capability:
11
+ return None
12
+
13
+ server_mappings = config.capability_mappings.get(context.server)
14
+ if not server_mappings:
15
+ return None
16
+
17
+ if context.capability_type == "tool":
18
+ return server_mappings.tools.get(context.capability)
19
+ if context.capability_type == "resource":
20
+ return server_mappings.resources.get(context.capability)
21
+ if context.capability_type == "prompt":
22
+ return server_mappings.prompts.get(context.capability)
23
+ return None
24
+
@@ -0,0 +1,2 @@
1
+ """CLI package."""
2
+