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.
- mcp_zero_trust_layer/__init__.py +4 -0
- mcp_zero_trust_layer/approvals/__init__.py +5 -0
- mcp_zero_trust_layer/approvals/models.py +33 -0
- mcp_zero_trust_layer/approvals/notifier.py +30 -0
- mcp_zero_trust_layer/approvals/store.py +138 -0
- mcp_zero_trust_layer/audit/__init__.py +3 -0
- mcp_zero_trust_layer/audit/logger.py +171 -0
- mcp_zero_trust_layer/capabilities/__init__.py +10 -0
- mcp_zero_trust_layer/capabilities/discovery.py +114 -0
- mcp_zero_trust_layer/capabilities/filtering.py +55 -0
- mcp_zero_trust_layer/capabilities/mapping.py +24 -0
- mcp_zero_trust_layer/cli/__init__.py +2 -0
- mcp_zero_trust_layer/cli/main.py +880 -0
- mcp_zero_trust_layer/config/__init__.py +5 -0
- mcp_zero_trust_layer/config/loader.py +26 -0
- mcp_zero_trust_layer/config/models.py +231 -0
- mcp_zero_trust_layer/config/secrets.py +156 -0
- mcp_zero_trust_layer/core/__init__.py +3 -0
- mcp_zero_trust_layer/core/context.py +27 -0
- mcp_zero_trust_layer/core/pipeline.py +401 -0
- mcp_zero_trust_layer/errors.py +14 -0
- mcp_zero_trust_layer/identity/__init__.py +4 -0
- mcp_zero_trust_layer/identity/auth.py +267 -0
- mcp_zero_trust_layer/identity/models.py +22 -0
- mcp_zero_trust_layer/observability/__init__.py +3 -0
- mcp_zero_trust_layer/observability/metrics.py +46 -0
- mcp_zero_trust_layer/output/__init__.py +4 -0
- mcp_zero_trust_layer/output/enforcer.py +44 -0
- mcp_zero_trust_layer/packs/__init__.py +4 -0
- mcp_zero_trust_layer/packs/filesystem-safe.yaml +34 -0
- mcp_zero_trust_layer/packs/github-readonly.yaml +35 -0
- mcp_zero_trust_layer/packs/postgres-readonly.yaml +20 -0
- mcp_zero_trust_layer/packs/registry.py +32 -0
- mcp_zero_trust_layer/policy/__init__.py +5 -0
- mcp_zero_trust_layer/policy/adapters.py +99 -0
- mcp_zero_trust_layer/policy/conditions.py +69 -0
- mcp_zero_trust_layer/policy/engine.py +292 -0
- mcp_zero_trust_layer/policy/models.py +21 -0
- mcp_zero_trust_layer/protocol/__init__.py +17 -0
- mcp_zero_trust_layer/protocol/jsonrpc.py +53 -0
- mcp_zero_trust_layer/py.typed +1 -0
- mcp_zero_trust_layer/security/__init__.py +3 -0
- mcp_zero_trust_layer/security/scanner.py +165 -0
- mcp_zero_trust_layer/transports/__init__.py +2 -0
- mcp_zero_trust_layer/transports/http/__init__.py +4 -0
- mcp_zero_trust_layer/transports/http/app.py +241 -0
- mcp_zero_trust_layer/transports/http/server.py +24 -0
- mcp_zero_trust_layer/transports/stdio/__init__.py +4 -0
- mcp_zero_trust_layer/transports/stdio/wrapper.py +75 -0
- mcp_zero_trust_layer/upstream/__init__.py +4 -0
- mcp_zero_trust_layer/upstream/base.py +17 -0
- mcp_zero_trust_layer/upstream/http.py +125 -0
- mcp_zero_trust_layer/upstream/stdio.py +74 -0
- mcp_zero_trust_layer/validators/__init__.py +5 -0
- mcp_zero_trust_layer/validators/basic.py +230 -0
- mcp_zero_trust_layer/validators/engine.py +46 -0
- mcp_zero_trust_layer/validators/input_policy.py +60 -0
- mcp_zero_trust_layer/validators/models.py +17 -0
- mcp_zero_trust_layer-0.1.0.dist-info/METADATA +961 -0
- mcp_zero_trust_layer-0.1.0.dist-info/RECORD +64 -0
- mcp_zero_trust_layer-0.1.0.dist-info/WHEEL +5 -0
- mcp_zero_trust_layer-0.1.0.dist-info/entry_points.txt +3 -0
- mcp_zero_trust_layer-0.1.0.dist-info/licenses/LICENSE +200 -0
- mcp_zero_trust_layer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|
+
|