svkrishna commited on
Commit
d817b84
·
1 Parent(s): 28c234f

Phase 1 : Pluggable Policy Engine — design an interface that allows policies to be loaded/unloaded at runtime; each policy should define machine-checkable invariants and decision logic.

Browse files
examples/policy_example.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastMCP Policy Engine Example
3
+
4
+ This example demonstrates the pluggable policy engine with:
5
+ - Minimum Necessary Access Policy
6
+ - RBAC Policy
7
+ - Policy evaluation via HTTP endpoint
8
+ - Hot reloading of policies
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ from pathlib import Path
14
+
15
+ from fastmcp import FastMCP
16
+ from fastmcp.policy import PolicyEngine
17
+ from fastmcp.policy.policies import MinimumNecessaryAccessPolicy, RBACPolicy
18
+
19
+
20
+ def create_policy_example():
21
+ """Create a FastMCP server with policy engine enabled."""
22
+
23
+ # Create server
24
+ server = FastMCP("Policy Example Server")
25
+
26
+ # Enable policy engine
27
+ policy_engine = server.enable_policy_engine()
28
+
29
+ # Register built-in policies
30
+ policy_engine.register_policy(MinimumNecessaryAccessPolicy())
31
+ policy_engine.register_policy(RBACPolicy())
32
+
33
+ # Add a simple tool that demonstrates policy evaluation
34
+ @server.tool
35
+ async def access_resource(user_id: str, action: str, resource_type: str) -> dict:
36
+ """Access a resource with policy evaluation."""
37
+
38
+ # Create context for policy evaluation
39
+ context = {
40
+ "user": {
41
+ "id": user_id,
42
+ "roles": ["user"] if user_id != "admin" else ["admin"],
43
+ "permissions": ["read", "write"] if user_id != "admin" else ["*"]
44
+ },
45
+ "action": action,
46
+ "resource": {
47
+ "type": resource_type,
48
+ "id": f"{resource_type}_123",
49
+ "owner": user_id,
50
+ "visibility": "private"
51
+ }
52
+ }
53
+
54
+ # Evaluate policies
55
+ decision = await policy_engine.evaluate(context)
56
+
57
+ return {
58
+ "access_granted": decision.allow,
59
+ "reason": decision.reason,
60
+ "obligations": decision.obligations,
61
+ "proof": decision.proof
62
+ }
63
+
64
+ return server
65
+
66
+
67
+ async def demonstrate_policy_evaluation():
68
+ """Demonstrate policy evaluation with different scenarios."""
69
+
70
+ server = create_policy_example()
71
+ policy_engine = server.get_policy_engine()
72
+ assert policy_engine is not None # Ensure policy engine is enabled
73
+
74
+ print("🔐 FastMCP Policy Engine Example")
75
+ print("=" * 50)
76
+
77
+ # Test scenarios
78
+ scenarios = [
79
+ {
80
+ "name": "Regular user reading document",
81
+ "user_id": "user123",
82
+ "action": "read",
83
+ "resource_type": "document"
84
+ },
85
+ {
86
+ "name": "Regular user deleting sensitive data",
87
+ "user_id": "user123",
88
+ "action": "delete",
89
+ "resource_type": "user_data"
90
+ },
91
+ {
92
+ "name": "Admin accessing sensitive data",
93
+ "user_id": "admin",
94
+ "action": "delete",
95
+ "resource_type": "user_data"
96
+ },
97
+ {
98
+ "name": "User without roles",
99
+ "user_id": "guest",
100
+ "action": "read",
101
+ "resource_type": "document"
102
+ }
103
+ ]
104
+
105
+ for scenario in scenarios:
106
+ print(f"\n📋 Scenario: {scenario['name']}")
107
+ print("-" * 40)
108
+
109
+ # Create context
110
+ context = {
111
+ "user": {
112
+ "id": scenario["user_id"],
113
+ "roles": ["user"] if scenario["user_id"] != "admin" else ["admin"],
114
+ "permissions": ["read", "write"] if scenario["user_id"] != "admin" else ["*"]
115
+ },
116
+ "action": scenario["action"],
117
+ "resource": {
118
+ "type": scenario["resource_type"],
119
+ "id": f"{scenario['resource_type']}_123",
120
+ "owner": scenario["user_id"],
121
+ "visibility": "private"
122
+ }
123
+ }
124
+
125
+ # Evaluate policies
126
+ decision = await policy_engine.evaluate(context)
127
+
128
+ print(f"User: {scenario['user_id']}")
129
+ print(f"Action: {scenario['action']}")
130
+ print(f"Resource: {scenario['resource_type']}")
131
+ print(f"Decision: {'✅ ALLOW' if decision.allow else '❌ DENY'}")
132
+ print(f"Reason: {decision.reason}")
133
+
134
+ if decision.obligations:
135
+ print("Obligations:")
136
+ for obligation in decision.obligations:
137
+ print(f" - {obligation.get('type', 'unknown')}: {obligation.get('description', 'No description')}")
138
+
139
+ if decision.proof:
140
+ print(f"Proof: {json.dumps(decision.proof, indent=2)}")
141
+
142
+
143
+ def create_yaml_config():
144
+ """Create a YAML configuration file for policies."""
145
+
146
+ config_content = """
147
+ policies:
148
+ - name: custom_minimum_necessary
149
+ type: minimum_necessary
150
+ parameters:
151
+ required_justification: true
152
+ sensitive_actions:
153
+ - "delete"
154
+ - "admin"
155
+ - "privileged"
156
+ sensitive_resources:
157
+ - "user_data"
158
+ - "financial"
159
+ - "medical"
160
+
161
+ - name: custom_rbac
162
+ type: rbac
163
+ parameters:
164
+ version: "1.0.0"
165
+ roles:
166
+ admin:
167
+ description: "Administrator with full access"
168
+ permissions: ["*"]
169
+ user:
170
+ description: "Regular user with basic access"
171
+ permissions: ["read", "write"]
172
+ guest:
173
+ description: "Guest user with read-only access"
174
+ permissions: ["read"]
175
+ """
176
+
177
+ config_path = Path("policies.yaml")
178
+ config_path.write_text(config_content)
179
+ print(f"📄 Created YAML config: {config_path}")
180
+ return config_path
181
+
182
+
183
+ async def demonstrate_hot_reload():
184
+ """Demonstrate hot reloading of policies."""
185
+
186
+ print("\n🔄 Hot Reload Demonstration")
187
+ print("=" * 50)
188
+
189
+ # Create server with policy engine
190
+ server = create_policy_example()
191
+ policy_engine = server.get_policy_engine()
192
+ assert policy_engine is not None # Ensure policy engine is enabled
193
+
194
+ # Register policy classes for YAML loading
195
+ policy_engine.registry.register_policy_class("minimum_necessary", MinimumNecessaryAccessPolicy)
196
+ policy_engine.registry.register_policy_class("rbac", RBACPolicy)
197
+
198
+ # Create YAML config
199
+ config_path = create_yaml_config()
200
+
201
+ # Load policies from YAML
202
+ print("Loading policies from YAML...")
203
+ policy_engine.registry.load_policies_from_yaml(config_path)
204
+
205
+ # List loaded policies
206
+ policies = policy_engine.get_policy_metadata()
207
+ print(f"Loaded {len(policies)} policies:")
208
+ for policy in policies:
209
+ print(f" - {policy['name']} ({policy['type']}) v{policy['version']}")
210
+
211
+ # Demonstrate hot reload
212
+ print("\nHot reloading policies...")
213
+ policy_engine.registry.hot_reload_policies(config_path)
214
+
215
+ # Clean up
216
+ config_path.unlink()
217
+ print("✅ Hot reload demonstration complete")
218
+
219
+
220
+ def main():
221
+ """Run the policy engine example."""
222
+
223
+ async def run_example():
224
+ # Demonstrate policy evaluation
225
+ await demonstrate_policy_evaluation()
226
+
227
+ # Demonstrate hot reload
228
+ await demonstrate_hot_reload()
229
+
230
+ print("\n🚀 Policy Engine Example Complete!")
231
+ print("\nTo test the HTTP endpoint:")
232
+ print("1. Run: fastmcp run examples/policy_example.py --transport http")
233
+ print("2. POST to /policy/evaluate with JSON body:")
234
+ print("""
235
+ {
236
+ "context": {
237
+ "user": {"id": "user123", "roles": ["user"]},
238
+ "action": "read",
239
+ "resource": {"type": "document", "id": "doc123"}
240
+ }
241
+ }
242
+ """)
243
+
244
+ asyncio.run(run_example())
245
+
246
+
247
+ if __name__ == "__main__":
248
+ main()
examples/smart_home/requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fastmcp@git+https://github.com/jlowin/fastmcp.git
2
+ phue2
3
+
requirements-dev.txt ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core dependencies
2
+ python-dotenv>=1.1.0
3
+ exceptiongroup>=1.2.2
4
+ httpx>=0.28.1
5
+ mcp>=1.12.4,<2.0.0
6
+ openapi-pydantic>=0.5.1
7
+ rich>=13.9.4
8
+ cyclopts>=3.0.0
9
+ authlib>=1.5.2
10
+ pydantic[email]>=2.11.7
11
+ pyperclip>=1.9.0
12
+ openapi-core>=0.19.5
13
+
14
+ # Development dependencies
15
+ copychat>=0.5.2
16
+ dirty-equals>=0.9.0
17
+ fastapi>=0.115.12
18
+ ipython>=8.12.3
19
+ pdbpp>=0.10.3
20
+ pre-commit
21
+ psutil
22
+ pyinstrument>=5.0.2
23
+ pyright>=1.1.389
24
+ pytest>=8.3.3
25
+ pytest-asyncio>=0.23.5
26
+ pytest-cov>=6.1.1
27
+ pytest-env>=1.1.5
28
+ pytest-flakefinder
29
+ pytest-httpx>=0.35.0
30
+ pytest-report>=0.2.1
31
+ pytest-timeout>=2.4.0
32
+ pytest-xdist>=3.6.1
33
+ ruff
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ python-dotenv>=1.1.0
2
+ exceptiongroup>=1.2.2
3
+ httpx>=0.28.1
4
+ mcp>=1.12.4,<2.0.0
5
+ openapi-pydantic>=0.5.1
6
+ rich>=13.9.4
7
+ cyclopts>=3.0.0
8
+ authlib>=1.5.2
9
+ pydantic[email]>=2.11.7
10
+ pyperclip>=1.9.0
11
+ openapi-core>=0.19.5
src/fastmcp/policy/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """FastMCP Policy Engine - Pluggable authorization and access control."""
2
+
3
+ from .decision import Decision
4
+ from .engine import PolicyEngine
5
+ from .policy import Policy
6
+ from .registry import PolicyRegistry
7
+
8
+ __all__ = ["Policy", "PolicyEngine", "PolicyRegistry", "Decision"]
src/fastmcp/policy/decision.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Policy decision result types."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, List, Optional
5
+
6
+
7
+ @dataclass
8
+ class Decision:
9
+ """Result of a policy evaluation."""
10
+
11
+ allow: bool
12
+ """Whether the action is allowed."""
13
+
14
+ obligations: List[Dict[str, Any]]
15
+ """List of obligations that must be fulfilled."""
16
+
17
+ reason: str
18
+ """Human-readable reason for the decision."""
19
+
20
+ proof: Optional[Dict[str, Any]] = None
21
+ """Optional proof or evidence for the decision."""
22
+
23
+ def to_dict(self) -> Dict[str, Any]:
24
+ """Convert decision to dictionary for JSON serialization."""
25
+ return {
26
+ "allow": self.allow,
27
+ "obligations": self.obligations,
28
+ "reason": self.reason,
29
+ "proof": self.proof,
30
+ }
31
+
32
+ @classmethod
33
+ def allow_decision(
34
+ cls,
35
+ reason: str = "Access granted",
36
+ obligations: Optional[List[Dict[str, Any]]] = None,
37
+ proof: Optional[Dict[str, Any]] = None
38
+ ) -> "Decision":
39
+ """Create an allow decision."""
40
+ return cls(
41
+ allow=True,
42
+ obligations=obligations or [],
43
+ reason=reason,
44
+ proof=proof,
45
+ )
46
+
47
+ @classmethod
48
+ def deny_decision(
49
+ cls,
50
+ reason: str = "Access denied",
51
+ obligations: Optional[List[Dict[str, Any]]] = None,
52
+ proof: Optional[Dict[str, Any]] = None
53
+ ) -> "Decision":
54
+ """Create a deny decision."""
55
+ return cls(
56
+ allow=False,
57
+ obligations=obligations or [],
58
+ reason=reason,
59
+ proof=proof,
60
+ )
src/fastmcp/policy/engine.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Policy engine for coordinating policy evaluation."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from fastmcp.utilities.logging import get_logger
6
+
7
+ from .decision import Decision
8
+ from .policy import Policy, PolicyContext
9
+ from .registry import PolicyRegistry
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class PolicyEngine:
15
+ """Engine for evaluating policies."""
16
+
17
+ def __init__(self, registry: Optional[PolicyRegistry] = None):
18
+ self.registry = registry or PolicyRegistry()
19
+ self._evaluation_order: List[str] = []
20
+
21
+ def set_evaluation_order(self, policy_names: List[str]) -> None:
22
+ """Set the order in which policies should be evaluated.
23
+
24
+ Args:
25
+ policy_names: List of policy names in evaluation order
26
+ """
27
+ self._evaluation_order = policy_names.copy()
28
+ logger.info(f"Set policy evaluation order: {policy_names}")
29
+
30
+ async def evaluate(
31
+ self,
32
+ context: Dict[str, Any],
33
+ policy_names: Optional[List[str]] = None
34
+ ) -> Decision:
35
+ """Evaluate policies against the given context.
36
+
37
+ Args:
38
+ context: The context for evaluation
39
+ policy_names: Optional list of policy names to evaluate.
40
+ If None, evaluates all registered policies.
41
+
42
+ Returns:
43
+ The final decision after evaluating all policies
44
+ """
45
+ if policy_names is None:
46
+ # Use evaluation order if set, otherwise use all policies
47
+ if self._evaluation_order:
48
+ policy_names = self._evaluation_order
49
+ else:
50
+ policy_names = list(self.registry._policies.keys())
51
+
52
+ logger.debug(f"Evaluating policies: {policy_names}")
53
+
54
+ # Evaluate each policy in order
55
+ for policy_name in policy_names:
56
+ policy = self.registry.get_policy(policy_name)
57
+ if not policy:
58
+ logger.warning(f"Policy not found: {policy_name}")
59
+ continue
60
+
61
+ try:
62
+ decision = await policy.evaluate(context)
63
+ logger.debug(f"Policy {policy_name} decision: {decision.allow} - {decision.reason}")
64
+
65
+ # If any policy denies, return deny decision
66
+ if not decision.allow:
67
+ return decision
68
+
69
+ except Exception as e:
70
+ logger.error(f"Error evaluating policy {policy_name}: {e}")
71
+ return Decision.deny_decision(
72
+ reason=f"Policy evaluation error: {e}",
73
+ proof={"policy": policy_name, "error": str(e)}
74
+ )
75
+
76
+ # All policies allowed
77
+ return Decision.allow_decision(
78
+ reason="All policies evaluated successfully",
79
+ proof={"evaluated_policies": policy_names}
80
+ )
81
+
82
+ async def evaluate_single_policy(
83
+ self,
84
+ policy_name: str,
85
+ context: Dict[str, Any]
86
+ ) -> Optional[Decision]:
87
+ """Evaluate a single policy.
88
+
89
+ Args:
90
+ policy_name: The name of the policy to evaluate
91
+ context: The context for evaluation
92
+
93
+ Returns:
94
+ The decision, or None if policy not found
95
+ """
96
+ policy = self.registry.get_policy(policy_name)
97
+ if not policy:
98
+ logger.warning(f"Policy not found: {policy_name}")
99
+ return None
100
+
101
+ try:
102
+ decision = await policy.evaluate(context)
103
+ logger.debug(f"Single policy {policy_name} decision: {decision.allow} - {decision.reason}")
104
+ return decision
105
+ except Exception as e:
106
+ logger.error(f"Error evaluating policy {policy_name}: {e}")
107
+ return Decision.deny_decision(
108
+ reason=f"Policy evaluation error: {e}",
109
+ proof={"policy": policy_name, "error": str(e)}
110
+ )
111
+
112
+ def get_policy_metadata(self) -> List[Dict[str, Any]]:
113
+ """Get metadata for all registered policies.
114
+
115
+ Returns:
116
+ List of policy metadata dictionaries
117
+ """
118
+ return self.registry.list_policies()
119
+
120
+ def register_policy(self, policy: Policy) -> None:
121
+ """Register a policy with the engine.
122
+
123
+ Args:
124
+ policy: The policy to register
125
+ """
126
+ self.registry.register_policy(policy)
127
+
128
+ def unregister_policy(self, name: str) -> Optional[Policy]:
129
+ """Unregister a policy from the engine.
130
+
131
+ Args:
132
+ name: The name of the policy to unregister
133
+
134
+ Returns:
135
+ The unregistered policy, or None if not found
136
+ """
137
+ return self.registry.unregister_policy(name)
src/fastmcp/policy/policies/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Built-in policy implementations."""
2
+
3
+ from .minimum_necessary import MinimumNecessaryAccessPolicy
4
+ from .rbac import RBACPolicy
5
+
6
+ __all__ = ["MinimumNecessaryAccessPolicy", "RBACPolicy"]
src/fastmcp/policy/policies/minimum_necessary.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimum Necessary Access Policy implementation."""
2
+
3
+ from typing import Any, Dict, List, Set, Optional
4
+
5
+ from ..policy import Policy
6
+ from ..decision import Decision
7
+
8
+
9
+ class MinimumNecessaryAccessPolicy(Policy):
10
+ """Policy that enforces minimum necessary access principles."""
11
+
12
+ def __init__(
13
+ self,
14
+ name: str = "minimum_necessary_access",
15
+ version: str = "1.0.0",
16
+ sensitive_actions: Optional[List[str]] = None,
17
+ sensitive_resources: Optional[List[str]] = None,
18
+ required_justification: bool = True
19
+ ):
20
+ super().__init__(name, version)
21
+ self.sensitive_actions = set(sensitive_actions or [
22
+ "delete", "modify", "admin", "root", "sudo", "privileged"
23
+ ])
24
+ self.sensitive_resources = set(sensitive_resources or [
25
+ "user_data", "financial", "medical", "personal", "confidential"
26
+ ])
27
+ self.required_justification = required_justification
28
+
29
+ async def evaluate(self, context: Dict[str, Any]) -> Decision:
30
+ """Evaluate minimum necessary access policy.
31
+
32
+ Args:
33
+ context: The context containing user, action, resource information
34
+
35
+ Returns:
36
+ Decision indicating whether access is allowed
37
+ """
38
+ user = context.get("user", {})
39
+ action = context.get("action", "")
40
+ resource = context.get("resource", {})
41
+
42
+ # Check if action is sensitive
43
+ action_lower = action.lower()
44
+ is_sensitive_action = any(
45
+ sensitive in action_lower for sensitive in self.sensitive_actions
46
+ )
47
+
48
+ # Check if resource is sensitive
49
+ resource_type = resource.get("type", "")
50
+ resource_tags = resource.get("tags", [])
51
+ is_sensitive_resource = (
52
+ resource_type in self.sensitive_resources or
53
+ any(tag in self.sensitive_resources for tag in resource_tags)
54
+ )
55
+
56
+ # If neither action nor resource is sensitive, allow
57
+ if not is_sensitive_action and not is_sensitive_resource:
58
+ return Decision.allow_decision(
59
+ reason="Action and resource are not sensitive",
60
+ proof={
61
+ "action_sensitive": False,
62
+ "resource_sensitive": False
63
+ }
64
+ )
65
+
66
+ # Check for justification if required
67
+ if self.required_justification:
68
+ justification = context.get("justification", "")
69
+ if not justification or len(justification.strip()) < 10:
70
+ return Decision.deny_decision(
71
+ reason="Sensitive operation requires justification",
72
+ obligations=[
73
+ {
74
+ "type": "provide_justification",
75
+ "description": "Provide a detailed justification for this sensitive operation"
76
+ }
77
+ ],
78
+ proof={
79
+ "action_sensitive": is_sensitive_action,
80
+ "resource_sensitive": is_sensitive_resource,
81
+ "justification_provided": bool(justification),
82
+ "justification_length": len(justification) if justification else 0
83
+ }
84
+ )
85
+
86
+ # Check user permissions
87
+ user_roles = user.get("roles", [])
88
+ user_permissions = user.get("permissions", [])
89
+
90
+ # Allow if user has explicit permission
91
+ if "admin" in user_roles or "privileged" in user_permissions:
92
+ return Decision.allow_decision(
93
+ reason="User has privileged access",
94
+ obligations=[
95
+ {
96
+ "type": "audit_log",
97
+ "description": "Log this sensitive operation for audit purposes"
98
+ }
99
+ ],
100
+ proof={
101
+ "user_roles": user_roles,
102
+ "user_permissions": user_permissions,
103
+ "action_sensitive": is_sensitive_action,
104
+ "resource_sensitive": is_sensitive_resource
105
+ }
106
+ )
107
+
108
+ # Check for time-based restrictions
109
+ time_context = context.get("time", {})
110
+ current_hour = time_context.get("hour", 0)
111
+
112
+ # Restrict sensitive operations during off-hours (example: 10 PM to 6 AM)
113
+ if is_sensitive_action and (current_hour >= 22 or current_hour < 6):
114
+ return Decision.deny_decision(
115
+ reason="Sensitive operations restricted during off-hours",
116
+ obligations=[
117
+ {
118
+ "type": "schedule_operation",
119
+ "description": "Schedule this operation during business hours"
120
+ }
121
+ ],
122
+ proof={
123
+ "current_hour": current_hour,
124
+ "off_hours": True,
125
+ "action_sensitive": True
126
+ }
127
+ )
128
+
129
+ # Default deny for sensitive operations without proper authorization
130
+ return Decision.deny_decision(
131
+ reason="Insufficient permissions for sensitive operation",
132
+ obligations=[
133
+ {
134
+ "type": "request_approval",
135
+ "description": "Request approval from administrator"
136
+ }
137
+ ],
138
+ proof={
139
+ "action_sensitive": is_sensitive_action,
140
+ "resource_sensitive": is_sensitive_resource,
141
+ "user_roles": user_roles,
142
+ "user_permissions": user_permissions
143
+ }
144
+ )
src/fastmcp/policy/policies/rbac.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Role-Based Access Control (RBAC) Policy implementation."""
2
+
3
+ from typing import Any, Dict, List, Optional, Set
4
+
5
+ from ..policy import Policy
6
+ from ..decision import Decision
7
+
8
+
9
+ class RBACPolicy(Policy):
10
+ """Policy that implements Role-Based Access Control."""
11
+
12
+ def __init__(
13
+ self,
14
+ name: str = "rbac",
15
+ version: str = "1.0.0",
16
+ roles: Optional[Dict[str, Dict[str, Any]]] = None,
17
+ permissions: Optional[Dict[str, List[str]]] = None,
18
+ role_hierarchy: Optional[Dict[str, List[str]]] = None
19
+ ):
20
+ super().__init__(name, version)
21
+
22
+ # Default roles and permissions
23
+ self.roles = roles or {
24
+ "admin": {
25
+ "description": "Administrator with full access",
26
+ "permissions": ["*"]
27
+ },
28
+ "user": {
29
+ "description": "Regular user with basic access",
30
+ "permissions": ["read", "write"]
31
+ },
32
+ "guest": {
33
+ "description": "Guest user with read-only access",
34
+ "permissions": ["read"]
35
+ }
36
+ }
37
+
38
+ # Custom permissions mapping
39
+ self.permissions = permissions or {
40
+ "read": ["get", "list", "view", "read"],
41
+ "write": ["create", "update", "modify", "write"],
42
+ "delete": ["remove", "delete", "destroy"],
43
+ "admin": ["admin", "manage", "configure", "privileged"]
44
+ }
45
+
46
+ # Role hierarchy (inheritance)
47
+ self.role_hierarchy = role_hierarchy or {
48
+ "admin": ["user", "guest"],
49
+ "user": ["guest"]
50
+ }
51
+
52
+ def _get_user_permissions(self, user_roles: List[str]) -> Set[str]:
53
+ """Get all permissions for a user based on their roles and hierarchy.
54
+
55
+ Args:
56
+ user_roles: List of user roles
57
+
58
+ Returns:
59
+ Set of all permissions the user has
60
+ """
61
+ all_permissions = set()
62
+
63
+ for role in user_roles:
64
+ if role not in self.roles:
65
+ continue
66
+
67
+ # Get direct permissions for this role
68
+ role_permissions = self.roles[role].get("permissions", [])
69
+ if "*" in role_permissions:
70
+ # Wildcard permission - user has all permissions
71
+ return {"*"}
72
+
73
+ all_permissions.update(role_permissions)
74
+
75
+ # Get inherited permissions from role hierarchy
76
+ inherited_roles = self.role_hierarchy.get(role, [])
77
+ for inherited_role in inherited_roles:
78
+ if inherited_role in self.roles:
79
+ inherited_permissions = self.roles[inherited_role].get("permissions", [])
80
+ all_permissions.update(inherited_permissions)
81
+
82
+ return all_permissions
83
+
84
+ def _check_permission(self, user_permissions: Set[str], required_action: str) -> bool:
85
+ """Check if user has permission for the required action.
86
+
87
+ Args:
88
+ user_permissions: Set of user permissions
89
+ required_action: The action being performed
90
+
91
+ Returns:
92
+ True if user has permission, False otherwise
93
+ """
94
+ # Check for wildcard permission
95
+ if "*" in user_permissions:
96
+ return True
97
+
98
+ # Check direct permission match
99
+ if required_action in user_permissions:
100
+ return True
101
+
102
+ # Check permission mappings
103
+ for permission, actions in self.permissions.items():
104
+ if permission in user_permissions and required_action in actions:
105
+ return True
106
+
107
+ return False
108
+
109
+ async def evaluate(self, context: Dict[str, Any]) -> Decision:
110
+ """Evaluate RBAC policy.
111
+
112
+ Args:
113
+ context: The context containing user, action, resource information
114
+
115
+ Returns:
116
+ Decision indicating whether access is allowed
117
+ """
118
+ user = context.get("user", {})
119
+ action = context.get("action", "")
120
+ resource = context.get("resource", {})
121
+
122
+ # Get user roles
123
+ user_roles = user.get("roles", [])
124
+ if not user_roles:
125
+ return Decision.deny_decision(
126
+ reason="User has no assigned roles",
127
+ proof={
128
+ "user_roles": user_roles,
129
+ "action": action
130
+ }
131
+ )
132
+
133
+ # Get all user permissions
134
+ user_permissions = self._get_user_permissions(user_roles)
135
+
136
+ # Check if user has permission for the action
137
+ has_permission = self._check_permission(user_permissions, action)
138
+
139
+ if has_permission:
140
+ # Check resource-specific restrictions
141
+ resource_type = resource.get("type", "")
142
+ resource_owner = resource.get("owner", "")
143
+ user_id = user.get("id", "")
144
+
145
+ # Allow if user owns the resource or has admin role
146
+ if (resource_owner == user_id or
147
+ "admin" in user_roles or
148
+ "*" in user_permissions):
149
+ return Decision.allow_decision(
150
+ reason="User has permission and owns resource or is admin",
151
+ obligations=[
152
+ {
153
+ "type": "audit_log",
154
+ "description": "Log this RBAC-authorized operation"
155
+ }
156
+ ],
157
+ proof={
158
+ "user_roles": user_roles,
159
+ "user_permissions": list(user_permissions),
160
+ "action": action,
161
+ "resource_owner": resource_owner,
162
+ "user_id": user_id,
163
+ "permission_check": True
164
+ }
165
+ )
166
+
167
+ # Check if resource is public/shared
168
+ resource_visibility = resource.get("visibility", "private")
169
+ if resource_visibility in ["public", "shared"]:
170
+ return Decision.allow_decision(
171
+ reason="User has permission and resource is public/shared",
172
+ proof={
173
+ "user_roles": user_roles,
174
+ "user_permissions": list(user_permissions),
175
+ "action": action,
176
+ "resource_visibility": resource_visibility,
177
+ "permission_check": True
178
+ }
179
+ )
180
+
181
+ # Check for explicit resource permissions
182
+ resource_permissions = resource.get("permissions", {})
183
+ if user_id in resource_permissions:
184
+ user_resource_permissions = resource_permissions[user_id]
185
+ if action in user_resource_permissions or "*" in user_resource_permissions:
186
+ return Decision.allow_decision(
187
+ reason="User has explicit resource permission",
188
+ proof={
189
+ "user_roles": user_roles,
190
+ "user_permissions": list(user_permissions),
191
+ "action": action,
192
+ "resource_permissions": user_resource_permissions,
193
+ "permission_check": True
194
+ }
195
+ )
196
+
197
+ # Deny access to private resource without ownership
198
+ return Decision.deny_decision(
199
+ reason="User lacks permission for this private resource",
200
+ obligations=[
201
+ {
202
+ "type": "request_access",
203
+ "description": "Request access from resource owner"
204
+ }
205
+ ],
206
+ proof={
207
+ "user_roles": user_roles,
208
+ "user_permissions": list(user_permissions),
209
+ "action": action,
210
+ "resource_owner": resource_owner,
211
+ "user_id": user_id,
212
+ "resource_visibility": resource_visibility,
213
+ "permission_check": True,
214
+ "ownership_check": False
215
+ }
216
+ )
217
+ else:
218
+ # User doesn't have permission for the action
219
+ return Decision.deny_decision(
220
+ reason="User lacks permission for this action",
221
+ obligations=[
222
+ {
223
+ "type": "request_permission",
224
+ "description": "Request permission from administrator"
225
+ }
226
+ ],
227
+ proof={
228
+ "user_roles": user_roles,
229
+ "user_permissions": list(user_permissions),
230
+ "action": action,
231
+ "permission_check": False
232
+ }
233
+ )
src/fastmcp/policy/policy.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Policy interface and base classes."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, Dict, Optional
5
+
6
+ from .decision import Decision
7
+
8
+
9
+ class Policy(ABC):
10
+ """Base class for all policies."""
11
+
12
+ def __init__(self, name: str, version: str = "1.0.0"):
13
+ self.name = name
14
+ self.version = version
15
+
16
+ @abstractmethod
17
+ async def evaluate(self, context: Dict[str, Any]) -> Decision:
18
+ """Evaluate the policy against the given context.
19
+
20
+ Args:
21
+ context: The context containing information about the request,
22
+ user, resource, action, etc.
23
+
24
+ Returns:
25
+ A Decision object indicating whether access is allowed or denied.
26
+ """
27
+ pass
28
+
29
+ def get_metadata(self) -> Dict[str, Any]:
30
+ """Get policy metadata."""
31
+ return {
32
+ "name": self.name,
33
+ "version": self.version,
34
+ "type": self.__class__.__name__,
35
+ }
36
+
37
+
38
+ class PolicyContext:
39
+ """Context for policy evaluation."""
40
+
41
+ def __init__(
42
+ self,
43
+ user: Optional[Dict[str, Any]] = None,
44
+ resource: Optional[Dict[str, Any]] = None,
45
+ action: Optional[str] = None,
46
+ environment: Optional[Dict[str, Any]] = None,
47
+ **kwargs
48
+ ):
49
+ self.user = user or {}
50
+ self.resource = resource or {}
51
+ self.action = action
52
+ self.environment = environment or {}
53
+ self.extra = kwargs
54
+
55
+ def to_dict(self) -> Dict[str, Any]:
56
+ """Convert context to dictionary."""
57
+ return {
58
+ "user": self.user,
59
+ "resource": self.resource,
60
+ "action": self.action,
61
+ "environment": self.environment,
62
+ **self.extra,
63
+ }
src/fastmcp/policy/registry.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Policy registry for managing policies at runtime."""
2
+
3
+ import importlib
4
+ import importlib.metadata
5
+ import json
6
+ import yaml
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Type
9
+
10
+ from fastmcp.utilities.logging import get_logger
11
+
12
+ from .policy import Policy
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class PolicyRegistry:
18
+ """Registry for managing policies at runtime."""
19
+
20
+ def __init__(self):
21
+ self._policies: Dict[str, Policy] = {}
22
+ self._policy_classes: Dict[str, Type[Policy]] = {}
23
+
24
+ def register_policy(self, policy: Policy) -> None:
25
+ """Register a policy instance.
26
+
27
+ Args:
28
+ policy: The policy instance to register
29
+ """
30
+ self._policies[policy.name] = policy
31
+ logger.info(f"Registered policy: {policy.name} v{policy.version}")
32
+
33
+ def unregister_policy(self, name: str) -> Optional[Policy]:
34
+ """Unregister a policy by name.
35
+
36
+ Args:
37
+ name: The name of the policy to unregister
38
+
39
+ Returns:
40
+ The unregistered policy, or None if not found
41
+ """
42
+ policy = self._policies.pop(name, None)
43
+ if policy:
44
+ logger.info(f"Unregistered policy: {name}")
45
+ return policy
46
+
47
+ def get_policy(self, name: str) -> Optional[Policy]:
48
+ """Get a policy by name.
49
+
50
+ Args:
51
+ name: The name of the policy
52
+
53
+ Returns:
54
+ The policy instance, or None if not found
55
+ """
56
+ return self._policies.get(name)
57
+
58
+ def list_policies(self) -> List[Dict[str, Any]]:
59
+ """List all registered policies.
60
+
61
+ Returns:
62
+ List of policy metadata dictionaries
63
+ """
64
+ return [policy.get_metadata() for policy in self._policies.values()]
65
+
66
+ def load_policy_from_entry_point(self, entry_point_name: str) -> None:
67
+ """Load policies from entry points.
68
+
69
+ Args:
70
+ entry_point_name: The entry point name to load from
71
+ """
72
+ try:
73
+ entry_points = importlib.metadata.entry_points()
74
+ if hasattr(entry_points, 'select'):
75
+ # Python 3.10+
76
+ policy_entry_points = entry_points.select(group=entry_point_name)
77
+ else:
78
+ # Python 3.8-3.9
79
+ policy_entry_points = entry_points.get(entry_point_name, [])
80
+
81
+ for entry_point in policy_entry_points:
82
+ try:
83
+ policy_class = entry_point.load()
84
+ if issubclass(policy_class, Policy):
85
+ # Create policy with default name from entry point
86
+ policy = policy_class(name=entry_point.name)
87
+ self.register_policy(policy)
88
+ else:
89
+ logger.warning(f"Entry point {entry_point.name} does not return a Policy class")
90
+ except Exception as e:
91
+ logger.error(f"Failed to load policy from entry point {entry_point.name}: {e}")
92
+ except Exception as e:
93
+ logger.error(f"Failed to load policies from entry points: {e}")
94
+
95
+ def load_policies_from_yaml(self, yaml_path: Path) -> None:
96
+ """Load policies from a YAML specification file.
97
+
98
+ Args:
99
+ yaml_path: Path to the YAML file
100
+ """
101
+ try:
102
+ with open(yaml_path, 'r') as f:
103
+ config = yaml.safe_load(f)
104
+
105
+ policies_config = config.get('policies', [])
106
+ for policy_config in policies_config:
107
+ try:
108
+ policy_name = policy_config['name']
109
+ policy_type = policy_config['type']
110
+ policy_params = policy_config.get('parameters', {})
111
+
112
+ # Get the policy class
113
+ policy_class = self._policy_classes.get(policy_type)
114
+ if not policy_class:
115
+ logger.error(f"Unknown policy type: {policy_type}")
116
+ continue
117
+
118
+ # Create and register the policy
119
+ policy = policy_class(name=policy_name, **policy_params)
120
+ self.register_policy(policy)
121
+
122
+ except KeyError as e:
123
+ logger.error(f"Missing required field in policy config: {e}")
124
+ except Exception as e:
125
+ logger.error(f"Failed to load policy from config: {e}")
126
+
127
+ except Exception as e:
128
+ logger.error(f"Failed to load policies from YAML file {yaml_path}: {e}")
129
+
130
+ def register_policy_class(self, name: str, policy_class: Type[Policy]) -> None:
131
+ """Register a policy class for dynamic instantiation.
132
+
133
+ Args:
134
+ name: The name to register the policy class under
135
+ policy_class: The policy class to register
136
+ """
137
+ if not issubclass(policy_class, Policy):
138
+ raise ValueError(f"Class {policy_class} must inherit from Policy")
139
+
140
+ self._policy_classes[name] = policy_class
141
+ logger.info(f"Registered policy class: {name}")
142
+
143
+ def create_policy_from_config(self, config: Dict[str, Any]) -> Optional[Policy]:
144
+ """Create a policy instance from configuration.
145
+
146
+ Args:
147
+ config: Configuration dictionary
148
+
149
+ Returns:
150
+ The created policy instance, or None if creation failed
151
+ """
152
+ try:
153
+ policy_type = config.get('type')
154
+ if not policy_type:
155
+ logger.error("Policy config missing 'type' field")
156
+ return None
157
+
158
+ policy_class = self._policy_classes.get(policy_type)
159
+ if not policy_class:
160
+ logger.error(f"Unknown policy type: {policy_type}")
161
+ return None
162
+
163
+ policy_params = config.get('parameters', {})
164
+ policy = policy_class(**policy_params)
165
+ return policy
166
+
167
+ except Exception as e:
168
+ logger.error(f"Failed to create policy from config: {e}")
169
+ return None
170
+
171
+ def hot_reload_policies(self, yaml_path: Path) -> None:
172
+ """Hot reload policies from YAML file.
173
+
174
+ Args:
175
+ yaml_path: Path to the YAML file
176
+ """
177
+ logger.info(f"Hot reloading policies from {yaml_path}")
178
+
179
+ # Clear existing policies
180
+ self._policies.clear()
181
+
182
+ # Reload from entry points
183
+ self.load_policy_from_entry_point("fastmcp.policies")
184
+
185
+ # Reload from YAML
186
+ if yaml_path.exists():
187
+ self.load_policies_from_yaml(yaml_path)
188
+
189
+ logger.info(f"Hot reload complete. {len(self._policies)} policies loaded")
src/fastmcp/server/policy_routes.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Policy evaluation HTTP routes."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ from starlette.requests import Request
6
+ from starlette.responses import JSONResponse
7
+ from starlette.routing import Route
8
+
9
+ from fastmcp.policy import PolicyEngine
10
+ from fastmcp.utilities.logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ async def policy_evaluate_endpoint(request: Request) -> JSONResponse:
16
+ """HTTP endpoint for policy evaluation.
17
+
18
+ Expected JSON body:
19
+ {
20
+ "context": {
21
+ "user": {...},
22
+ "action": "read",
23
+ "resource": {...},
24
+ ...
25
+ },
26
+ "policy_names": ["policy1", "policy2"] // optional
27
+ }
28
+ """
29
+ try:
30
+ # Parse request body
31
+ body = await request.json()
32
+
33
+ # Get policy engine from request state
34
+ policy_engine: PolicyEngine = request.app.state.policy_engine
35
+
36
+ # Extract context and optional policy names
37
+ context = body.get("context", {})
38
+ policy_names = body.get("policy_names")
39
+
40
+ if not context:
41
+ return JSONResponse(
42
+ status_code=400,
43
+ content={
44
+ "error": "Missing required 'context' field",
45
+ "reason": "Policy evaluation requires a context object"
46
+ }
47
+ )
48
+
49
+ # Evaluate policies
50
+ decision = await policy_engine.evaluate(context, policy_names)
51
+
52
+ # Return structured decision
53
+ return JSONResponse(
54
+ status_code=200,
55
+ content=decision.to_dict()
56
+ )
57
+
58
+ except Exception as e:
59
+ logger.error(f"Policy evaluation error: {e}")
60
+ return JSONResponse(
61
+ status_code=500,
62
+ content={
63
+ "error": "Policy evaluation failed",
64
+ "reason": str(e)
65
+ }
66
+ )
67
+
68
+
69
+ def create_policy_evaluate_route(policy_engine: PolicyEngine) -> Route:
70
+ """Create the policy evaluation route.
71
+
72
+ Args:
73
+ policy_engine: The policy engine instance
74
+
75
+ Returns:
76
+ Starlette Route for policy evaluation
77
+ """
78
+ async def endpoint_with_engine(request: Request) -> JSONResponse:
79
+ # Store policy engine in app state for access in endpoint
80
+ request.app.state.policy_engine = policy_engine
81
+ return await policy_evaluate_endpoint(request)
82
+
83
+ return Route(
84
+ path="/policy/evaluate",
85
+ endpoint=endpoint_with_engine,
86
+ methods=["POST"]
87
+ )
src/fastmcp/server/server.py CHANGED
@@ -14,7 +14,7 @@ from contextlib import (
14
  from dataclasses import dataclass
15
  from functools import partial
16
  from pathlib import Path
17
- from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload
18
 
19
  import anyio
20
  import httpx
@@ -62,6 +62,7 @@ from fastmcp.settings import Settings
62
  from fastmcp.tools import ToolManager
63
  from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
64
  from fastmcp.tools.tool_transform import ToolTransformConfig
 
65
  from fastmcp.utilities.cli import log_server_banner
66
  from fastmcp.utilities.components import FastMCPComponent
67
  from fastmcp.utilities.logging import get_logger
@@ -173,6 +174,7 @@ class FastMCP(Generic[LifespanResultT]):
173
 
174
  self._additional_http_routes: list[BaseRoute] = []
175
  self._mounted_servers: list[MountedServer] = []
 
176
  self._tool_manager = ToolManager(
177
  duplicate_behavior=on_duplicate_tools,
178
  mask_error_details=mask_error_details,
@@ -487,12 +489,43 @@ class FastMCP(Generic[LifespanResultT]):
487
  """
488
  routes = list(self._additional_http_routes)
489
 
 
 
 
 
 
 
 
490
  # Recursively get routes from mounted servers
491
  for mounted_server in self._mounted_servers:
492
  mounted_routes = mounted_server.server._get_additional_http_routes()
493
  routes.extend(mounted_routes)
494
 
495
  return routes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
 
497
  async def _mcp_list_tools(self) -> list[MCPTool]:
498
  logger.debug("Handler called: list_tools")
 
14
  from dataclasses import dataclass
15
  from functools import partial
16
  from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any, Generic, Literal, Optional, cast, overload
18
 
19
  import anyio
20
  import httpx
 
62
  from fastmcp.tools import ToolManager
63
  from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
64
  from fastmcp.tools.tool_transform import ToolTransformConfig
65
+ from fastmcp.policy import PolicyEngine
66
  from fastmcp.utilities.cli import log_server_banner
67
  from fastmcp.utilities.components import FastMCPComponent
68
  from fastmcp.utilities.logging import get_logger
 
174
 
175
  self._additional_http_routes: list[BaseRoute] = []
176
  self._mounted_servers: list[MountedServer] = []
177
+ self._policy_engine: Optional[PolicyEngine] = None
178
  self._tool_manager = ToolManager(
179
  duplicate_behavior=on_duplicate_tools,
180
  mask_error_details=mask_error_details,
 
489
  """
490
  routes = list(self._additional_http_routes)
491
 
492
+ # Add policy evaluation endpoint if policy engine is configured
493
+ if self._policy_engine is not None:
494
+ from fastmcp.server.policy_routes import create_policy_evaluate_route
495
+
496
+ policy_route = create_policy_evaluate_route(self._policy_engine)
497
+ routes.append(policy_route)
498
+
499
  # Recursively get routes from mounted servers
500
  for mounted_server in self._mounted_servers:
501
  mounted_routes = mounted_server.server._get_additional_http_routes()
502
  routes.extend(mounted_routes)
503
 
504
  return routes
505
+
506
+ def enable_policy_engine(self, policy_engine: Optional[PolicyEngine] = None) -> PolicyEngine:
507
+ """Enable the policy engine for this server.
508
+
509
+ Args:
510
+ policy_engine: Optional policy engine instance. If None, creates a new one.
511
+
512
+ Returns:
513
+ The policy engine instance
514
+ """
515
+ if policy_engine is None:
516
+ policy_engine = PolicyEngine()
517
+
518
+ self._policy_engine = policy_engine
519
+ logger.info("Policy engine enabled for server")
520
+ return policy_engine
521
+
522
+ def get_policy_engine(self) -> Optional[PolicyEngine]:
523
+ """Get the policy engine instance.
524
+
525
+ Returns:
526
+ The policy engine instance, or None if not enabled
527
+ """
528
+ return self._policy_engine
529
 
530
  async def _mcp_list_tools(self) -> list[MCPTool]:
531
  logger.debug("Handler called: list_tools")
tests/policy/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Policy engine tests."""
tests/policy/test_policy_engine.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the policy engine."""
2
+
3
+ import pytest
4
+ from pathlib import Path
5
+ from unittest.mock import Mock, patch
6
+
7
+ from fastmcp.policy import PolicyEngine, PolicyRegistry, Decision
8
+ from fastmcp.policy.policies import MinimumNecessaryAccessPolicy, RBACPolicy
9
+
10
+
11
+ class TestPolicyEngine:
12
+ """Test the policy engine functionality."""
13
+
14
+ @pytest.fixture
15
+ def policy_engine(self):
16
+ """Create a policy engine for testing."""
17
+ return PolicyEngine()
18
+
19
+ @pytest.fixture
20
+ def sample_context(self):
21
+ """Create a sample context for testing."""
22
+ return {
23
+ "user": {
24
+ "id": "user123",
25
+ "roles": ["user"],
26
+ "permissions": ["read", "write"]
27
+ },
28
+ "action": "read",
29
+ "resource": {
30
+ "type": "document",
31
+ "id": "doc123",
32
+ "owner": "user123",
33
+ "visibility": "private"
34
+ }
35
+ }
36
+
37
+ def test_policy_engine_initialization(self, policy_engine):
38
+ """Test policy engine initialization."""
39
+ assert policy_engine.registry is not None
40
+ assert isinstance(policy_engine.registry, PolicyRegistry)
41
+ assert policy_engine._evaluation_order == []
42
+
43
+ def test_set_evaluation_order(self, policy_engine):
44
+ """Test setting evaluation order."""
45
+ order = ["policy1", "policy2", "policy3"]
46
+ policy_engine.set_evaluation_order(order)
47
+ assert policy_engine._evaluation_order == order
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_evaluate_no_policies(self, policy_engine, sample_context):
51
+ """Test evaluation when no policies are registered."""
52
+ decision = await policy_engine.evaluate(sample_context)
53
+ assert decision.allow is True
54
+ assert "All policies evaluated successfully" in decision.reason
55
+
56
+ @pytest.mark.asyncio
57
+ async def test_evaluate_single_policy(self, policy_engine, sample_context):
58
+ """Test evaluation of a single policy."""
59
+ # Register a policy
60
+ policy = MinimumNecessaryAccessPolicy()
61
+ policy_engine.register_policy(policy)
62
+
63
+ # Evaluate
64
+ decision = await policy_engine.evaluate(sample_context)
65
+ assert decision.allow is True
66
+ # The policy engine returns its own response, not the individual policy response
67
+ assert "All policies evaluated successfully" in decision.reason
68
+
69
+ @pytest.mark.asyncio
70
+ async def test_evaluate_specific_policies(self, policy_engine, sample_context):
71
+ """Test evaluation of specific policies."""
72
+ # Register multiple policies
73
+ policy1 = MinimumNecessaryAccessPolicy(name="policy1")
74
+ policy2 = RBACPolicy(name="policy2")
75
+ policy_engine.register_policy(policy1)
76
+ policy_engine.register_policy(policy2)
77
+
78
+ # Evaluate only policy1
79
+ decision = await policy_engine.evaluate(sample_context, ["policy1"])
80
+ assert decision.allow is True
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_evaluate_policy_denial(self, policy_engine):
84
+ """Test evaluation when a policy denies access."""
85
+ # Create context with sensitive action
86
+ context = {
87
+ "user": {"roles": ["user"]},
88
+ "action": "delete",
89
+ "resource": {"type": "user_data"}
90
+ }
91
+
92
+ # Register minimum necessary policy
93
+ policy = MinimumNecessaryAccessPolicy()
94
+ policy_engine.register_policy(policy)
95
+
96
+ # Evaluate
97
+ decision = await policy_engine.evaluate(context)
98
+ assert decision.allow is False
99
+ assert "requires justification" in decision.reason
100
+
101
+ @pytest.mark.asyncio
102
+ async def test_evaluate_single_policy_method(self, policy_engine, sample_context):
103
+ """Test evaluate_single_policy method."""
104
+ # Register a policy
105
+ policy = MinimumNecessaryAccessPolicy(name="test_policy")
106
+ policy_engine.register_policy(policy)
107
+
108
+ # Evaluate single policy
109
+ decision = await policy_engine.evaluate_single_policy("test_policy", sample_context)
110
+ assert decision is not None
111
+ assert decision.allow is True
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_evaluate_single_policy_not_found(self, policy_engine, sample_context):
115
+ """Test evaluate_single_policy with non-existent policy."""
116
+ decision = await policy_engine.evaluate_single_policy("non_existent", sample_context)
117
+ assert decision is None
118
+
119
+ def test_get_policy_metadata(self, policy_engine):
120
+ """Test getting policy metadata."""
121
+ # Register policies
122
+ policy1 = MinimumNecessaryAccessPolicy(name="policy1")
123
+ policy2 = RBACPolicy(name="policy2")
124
+ policy_engine.register_policy(policy1)
125
+ policy_engine.register_policy(policy2)
126
+
127
+ metadata = policy_engine.get_policy_metadata()
128
+ assert len(metadata) == 2
129
+ assert any(p["name"] == "policy1" for p in metadata)
130
+ assert any(p["name"] == "policy2" for p in metadata)
131
+
132
+ def test_register_unregister_policy(self, policy_engine):
133
+ """Test policy registration and unregistration."""
134
+ policy = MinimumNecessaryAccessPolicy(name="test_policy")
135
+
136
+ # Register
137
+ policy_engine.register_policy(policy)
138
+ assert policy_engine.registry.get_policy("test_policy") is not None
139
+
140
+ # Unregister
141
+ unregistered = policy_engine.unregister_policy("test_policy")
142
+ assert unregistered is not None
143
+ assert unregistered.name == "test_policy"
144
+ assert policy_engine.registry.get_policy("test_policy") is None
145
+
146
+
147
+ class TestPolicyRegistry:
148
+ """Test the policy registry functionality."""
149
+
150
+ @pytest.fixture
151
+ def registry(self):
152
+ """Create a policy registry for testing."""
153
+ return PolicyRegistry()
154
+
155
+ def test_registry_initialization(self, registry):
156
+ """Test registry initialization."""
157
+ assert registry._policies == {}
158
+ assert registry._policy_classes == {}
159
+
160
+ def test_register_policy(self, registry):
161
+ """Test policy registration."""
162
+ policy = MinimumNecessaryAccessPolicy()
163
+ registry.register_policy(policy)
164
+ assert "minimum_necessary_access" in registry._policies
165
+
166
+ def test_unregister_policy(self, registry):
167
+ """Test policy unregistration."""
168
+ policy = MinimumNecessaryAccessPolicy()
169
+ registry.register_policy(policy)
170
+
171
+ unregistered = registry.unregister_policy("minimum_necessary_access")
172
+ assert unregistered is not None
173
+ assert "minimum_necessary_access" not in registry._policies
174
+
175
+ def test_get_policy(self, registry):
176
+ """Test getting a policy."""
177
+ policy = MinimumNecessaryAccessPolicy()
178
+ registry.register_policy(policy)
179
+
180
+ retrieved = registry.get_policy("minimum_necessary_access")
181
+ assert retrieved is not None
182
+ assert retrieved.name == "minimum_necessary_access"
183
+
184
+ def test_list_policies(self, registry):
185
+ """Test listing policies."""
186
+ policy1 = MinimumNecessaryAccessPolicy(name="policy1")
187
+ policy2 = RBACPolicy(name="policy2")
188
+ registry.register_policy(policy1)
189
+ registry.register_policy(policy2)
190
+
191
+ policies = registry.list_policies()
192
+ assert len(policies) == 2
193
+ assert any(p["name"] == "policy1" for p in policies)
194
+ assert any(p["name"] == "policy2" for p in policies)
195
+
196
+ def test_register_policy_class(self, registry):
197
+ """Test registering a policy class."""
198
+ registry.register_policy_class("test_policy", MinimumNecessaryAccessPolicy)
199
+ assert "test_policy" in registry._policy_classes
200
+ assert registry._policy_classes["test_policy"] == MinimumNecessaryAccessPolicy
201
+
202
+ def test_register_invalid_policy_class(self, registry):
203
+ """Test registering an invalid policy class."""
204
+ with pytest.raises(ValueError):
205
+ registry.register_policy_class("invalid", str)
206
+
207
+ def test_create_policy_from_config(self, registry):
208
+ """Test creating policy from configuration."""
209
+ registry.register_policy_class("test_policy", MinimumNecessaryAccessPolicy)
210
+
211
+ config = {
212
+ "type": "test_policy",
213
+ "parameters": {
214
+ "name": "config_policy",
215
+ "required_justification": False
216
+ }
217
+ }
218
+
219
+ policy = registry.create_policy_from_config(config)
220
+ assert policy is not None
221
+ assert policy.name == "config_policy"
222
+ assert isinstance(policy, MinimumNecessaryAccessPolicy)
223
+
224
+ def test_create_policy_from_invalid_config(self, registry):
225
+ """Test creating policy from invalid configuration."""
226
+ config = {"type": "non_existent"}
227
+ policy = registry.create_policy_from_config(config)
228
+ assert policy is None
229
+
230
+
231
+ class TestPolicyLoadAndReload:
232
+ """Test policy loading and hot-reload functionality."""
233
+
234
+ @pytest.fixture
235
+ def registry(self):
236
+ """Create a policy registry for testing."""
237
+ return PolicyRegistry()
238
+
239
+ @pytest.fixture
240
+ def yaml_config_file(self, tmp_path):
241
+ """Create a temporary YAML config file."""
242
+ config_content = """
243
+ policies:
244
+ - name: yaml_policy1
245
+ type: minimum_necessary
246
+ parameters:
247
+ required_justification: false
248
+ - name: yaml_policy2
249
+ type: rbac
250
+ parameters:
251
+ version: "1.0.0"
252
+ """
253
+ config_file = tmp_path / "policies.yaml"
254
+ config_file.write_text(config_content)
255
+ return config_file
256
+
257
+ def test_load_policies_from_yaml(self, registry, yaml_config_file):
258
+ """Test loading policies from YAML file."""
259
+ # Register policy classes
260
+ registry.register_policy_class("minimum_necessary", MinimumNecessaryAccessPolicy)
261
+ registry.register_policy_class("rbac", RBACPolicy)
262
+
263
+ # Load from YAML
264
+ registry.load_policies_from_yaml(yaml_config_file)
265
+
266
+ # Check that policies were loaded
267
+ assert registry.get_policy("yaml_policy1") is not None
268
+ assert registry.get_policy("yaml_policy2") is not None
269
+
270
+ def test_hot_reload_policies(self, registry, yaml_config_file):
271
+ """Test hot reloading policies."""
272
+ # Register policy classes
273
+ registry.register_policy_class("minimum_necessary", MinimumNecessaryAccessPolicy)
274
+ registry.register_policy_class("rbac", RBACPolicy)
275
+
276
+ # Initial load
277
+ registry.load_policies_from_yaml(yaml_config_file)
278
+ initial_count = len(registry._policies)
279
+
280
+ # Hot reload
281
+ registry.hot_reload_policies(yaml_config_file)
282
+
283
+ # Check that policies were reloaded
284
+ assert len(registry._policies) == initial_count
285
+ assert registry.get_policy("yaml_policy1") is not None
286
+ assert registry.get_policy("yaml_policy2") is not None
287
+
288
+ @patch('importlib.metadata.entry_points')
289
+ def test_load_policy_from_entry_point(self, mock_entry_points, registry):
290
+ """Test loading policies from entry points."""
291
+ # Mock entry points
292
+ mock_entry_point = Mock()
293
+ mock_entry_point.name = "test_policy"
294
+ mock_entry_point.load.return_value = MinimumNecessaryAccessPolicy
295
+
296
+ mock_entry_points.return_value.select.return_value = [mock_entry_point]
297
+
298
+ # Load from entry points
299
+ registry.load_policy_from_entry_point("fastmcp.policies")
300
+
301
+ # Check that policy was loaded
302
+ assert registry.get_policy("test_policy") is not None
303
+
304
+
305
+ class TestPolicyIntegration:
306
+ """Test policy integration with FastMCP server."""
307
+
308
+ @pytest.mark.asyncio
309
+ async def test_policy_engine_with_server(self):
310
+ """Test policy engine integration with FastMCP server."""
311
+ from fastmcp import FastMCP
312
+
313
+ # Create server with policy engine
314
+ server = FastMCP("Test Server")
315
+ policy_engine = server.enable_policy_engine()
316
+
317
+ # Register policies
318
+ policy_engine.register_policy(MinimumNecessaryAccessPolicy())
319
+ policy_engine.register_policy(RBACPolicy())
320
+
321
+ # Test that policy engine is accessible
322
+ assert server.get_policy_engine() is not None
323
+ assert server.get_policy_engine() == policy_engine
324
+
325
+ # Test policy evaluation
326
+ context = {
327
+ "user": {"roles": ["user"]},
328
+ "action": "read",
329
+ "resource": {"type": "document"}
330
+ }
331
+
332
+ decision = await policy_engine.evaluate(context)
333
+ assert decision.allow is True
tests/policy/test_policy_http.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for policy HTTP endpoints."""
2
+
3
+ import pytest
4
+ from fastmcp import FastMCP
5
+ from fastmcp.policy import PolicyEngine
6
+ from fastmcp.policy.policies import MinimumNecessaryAccessPolicy, RBACPolicy
7
+ from starlette.routing import Route
8
+
9
+
10
+ class TestPolicyHTTPEndpoint:
11
+ """Test the policy evaluation HTTP endpoint."""
12
+
13
+ @pytest.fixture
14
+ def server_with_policy(self):
15
+ """Create a server with policy engine enabled."""
16
+ server = FastMCP("Test Policy Server")
17
+ policy_engine = server.enable_policy_engine()
18
+
19
+ # Register policies
20
+ policy_engine.register_policy(MinimumNecessaryAccessPolicy())
21
+ policy_engine.register_policy(RBACPolicy())
22
+
23
+ return server
24
+
25
+ @pytest.fixture
26
+ def app(self, server_with_policy):
27
+ """Create the HTTP app with policy endpoint."""
28
+ return server_with_policy.http_app(transport="sse")
29
+
30
+ def test_policy_evaluate_endpoint_exists(self, app):
31
+ """Test that the policy evaluation endpoint exists in the app."""
32
+ # Check that the policy route exists
33
+ policy_route_found = False
34
+ for route in app.routes:
35
+ if isinstance(route, Route) and route.path == "/policy/evaluate":
36
+ policy_route_found = True
37
+ break
38
+
39
+ assert policy_route_found, "Policy evaluation endpoint not found in app routes"
40
+
41
+ def test_policy_engine_integration(self, server_with_policy):
42
+ """Test that policy engine is properly integrated with the server."""
43
+ # Check that policy engine is enabled
44
+ assert server_with_policy.get_policy_engine() is not None
45
+
46
+ # Check that policies are registered
47
+ policy_engine = server_with_policy.get_policy_engine()
48
+ policies = policy_engine.get_policy_metadata()
49
+ assert len(policies) == 2
50
+
51
+ # Check that both policies are present
52
+ policy_names = [p["name"] for p in policies]
53
+ assert "minimum_necessary_access" in policy_names
54
+ assert "rbac" in policy_names