agent-mcp-gateway 0.1.5__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.

Potentially problematic release.


This version of agent-mcp-gateway might be problematic. Click here for more details.

src/metrics.py ADDED
@@ -0,0 +1,299 @@
1
+ """Metrics collection for Agent MCP Gateway."""
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass, field
5
+ from typing import Dict, List
6
+
7
+
8
+ @dataclass
9
+ class OperationMetrics:
10
+ """Metrics for a specific operation.
11
+
12
+ Attributes:
13
+ count: Total number of operations recorded
14
+ total_latency_ms: Cumulative latency in milliseconds
15
+ latencies: List of individual latency measurements
16
+ errors: Number of operations that resulted in errors
17
+ """
18
+ count: int = 0
19
+ total_latency_ms: float = 0.0
20
+ latencies: List[float] = field(default_factory=list)
21
+ errors: int = 0
22
+
23
+ def record(self, latency_ms: float, is_error: bool = False):
24
+ """Record a single operation.
25
+
26
+ Args:
27
+ latency_ms: Operation latency in milliseconds
28
+ is_error: Whether the operation resulted in an error
29
+ """
30
+ self.count += 1
31
+ self.total_latency_ms += latency_ms
32
+ self.latencies.append(latency_ms)
33
+ if is_error:
34
+ self.errors += 1
35
+
36
+ def get_summary(self) -> dict:
37
+ """Generate summary statistics for this operation.
38
+
39
+ Returns:
40
+ Dictionary containing count, avg, percentiles, and error_rate
41
+ """
42
+ if self.count == 0:
43
+ return {
44
+ "count": 0,
45
+ "avg_latency_ms": 0.0,
46
+ "p50_latency_ms": 0.0,
47
+ "p95_latency_ms": 0.0,
48
+ "p99_latency_ms": 0.0,
49
+ "error_rate": 0.0
50
+ }
51
+
52
+ avg_latency = self.total_latency_ms / self.count
53
+ error_rate = self.errors / self.count
54
+
55
+ # Calculate percentiles
56
+ sorted_latencies = sorted(self.latencies)
57
+ p50 = self._percentile(sorted_latencies, 50)
58
+ p95 = self._percentile(sorted_latencies, 95)
59
+ p99 = self._percentile(sorted_latencies, 99)
60
+
61
+ return {
62
+ "count": self.count,
63
+ "avg_latency_ms": round(avg_latency, 2),
64
+ "p50_latency_ms": round(p50, 2),
65
+ "p95_latency_ms": round(p95, 2),
66
+ "p99_latency_ms": round(p99, 2),
67
+ "error_rate": round(error_rate, 4)
68
+ }
69
+
70
+ @staticmethod
71
+ def _percentile(sorted_values: List[float], percentile: int) -> float:
72
+ """Calculate percentile from sorted values.
73
+
74
+ Args:
75
+ sorted_values: List of values sorted in ascending order
76
+ percentile: Percentile to calculate (0-100)
77
+
78
+ Returns:
79
+ Value at the specified percentile
80
+ """
81
+ if not sorted_values:
82
+ return 0.0
83
+
84
+ if len(sorted_values) == 1:
85
+ return sorted_values[0]
86
+
87
+ # Use linear interpolation method
88
+ k = (len(sorted_values) - 1) * (percentile / 100.0)
89
+ f = int(k)
90
+ c = f + 1
91
+
92
+ if c >= len(sorted_values):
93
+ return sorted_values[-1]
94
+
95
+ # Interpolate between floor and ceiling
96
+ d0 = sorted_values[f] * (c - k)
97
+ d1 = sorted_values[c] * (k - f)
98
+
99
+ return d0 + d1
100
+
101
+
102
+ class MetricsCollector:
103
+ """Collects and aggregates metrics for gateway operations.
104
+
105
+ Thread-safe metrics collection with per-agent and per-operation tracking.
106
+ Metrics are stored in memory and can be aggregated for monitoring.
107
+ """
108
+
109
+ def __init__(self):
110
+ """Initialize metrics collector with empty storage."""
111
+ # Overall metrics: operation -> OperationMetrics
112
+ self._metrics: Dict[str, OperationMetrics] = {}
113
+
114
+ # Per-agent metrics: agent_id -> operation -> OperationMetrics
115
+ self._agent_metrics: Dict[str, Dict[str, OperationMetrics]] = {}
116
+
117
+ # Lock for thread-safe operations
118
+ self._lock = asyncio.Lock()
119
+
120
+ async def record(
121
+ self,
122
+ agent_id: str,
123
+ operation: str,
124
+ latency_ms: float,
125
+ is_error: bool = False
126
+ ):
127
+ """Record a single operation metric.
128
+
129
+ Args:
130
+ agent_id: Agent identifier
131
+ operation: Operation name (list_servers, execute_tool, etc.)
132
+ latency_ms: Operation latency in milliseconds
133
+ is_error: Whether the operation resulted in an error
134
+ """
135
+ async with self._lock:
136
+ # Record overall metrics
137
+ if operation not in self._metrics:
138
+ self._metrics[operation] = OperationMetrics()
139
+ self._metrics[operation].record(latency_ms, is_error)
140
+
141
+ # Record per-agent metrics
142
+ if agent_id not in self._agent_metrics:
143
+ self._agent_metrics[agent_id] = {}
144
+ if operation not in self._agent_metrics[agent_id]:
145
+ self._agent_metrics[agent_id][operation] = OperationMetrics()
146
+ self._agent_metrics[agent_id][operation].record(latency_ms, is_error)
147
+
148
+ def record_sync(
149
+ self,
150
+ agent_id: str,
151
+ operation: str,
152
+ latency_ms: float,
153
+ is_error: bool = False
154
+ ):
155
+ """Record a single operation metric (synchronous version).
156
+
157
+ Note: This is not thread-safe. Use async record() for concurrent access.
158
+
159
+ Args:
160
+ agent_id: Agent identifier
161
+ operation: Operation name (list_servers, execute_tool, etc.)
162
+ latency_ms: Operation latency in milliseconds
163
+ is_error: Whether the operation resulted in an error
164
+ """
165
+ # Record overall metrics
166
+ if operation not in self._metrics:
167
+ self._metrics[operation] = OperationMetrics()
168
+ self._metrics[operation].record(latency_ms, is_error)
169
+
170
+ # Record per-agent metrics
171
+ if agent_id not in self._agent_metrics:
172
+ self._agent_metrics[agent_id] = {}
173
+ if operation not in self._agent_metrics[agent_id]:
174
+ self._agent_metrics[agent_id][operation] = OperationMetrics()
175
+ self._agent_metrics[agent_id][operation].record(latency_ms, is_error)
176
+
177
+ async def get_summary(self) -> dict:
178
+ """Get overall summary of all operations.
179
+
180
+ Returns:
181
+ Dictionary mapping operation names to their summary statistics
182
+ """
183
+ async with self._lock:
184
+ return self._get_summary_internal()
185
+
186
+ def get_summary_sync(self) -> dict:
187
+ """Get overall summary of all operations (synchronous version).
188
+
189
+ Returns:
190
+ Dictionary mapping operation names to their summary statistics
191
+ """
192
+ return self._get_summary_internal()
193
+
194
+ def _get_summary_internal(self) -> dict:
195
+ """Internal method to get summary without locking."""
196
+ return {
197
+ operation: metrics.get_summary()
198
+ for operation, metrics in self._metrics.items()
199
+ }
200
+
201
+ async def get_agent_summary(self, agent_id: str) -> dict:
202
+ """Get summary for a specific agent.
203
+
204
+ Args:
205
+ agent_id: Agent identifier
206
+
207
+ Returns:
208
+ Dictionary mapping operation names to summary statistics for this agent,
209
+ or empty dict if agent has no recorded metrics
210
+ """
211
+ async with self._lock:
212
+ return self._get_agent_summary_internal(agent_id)
213
+
214
+ def get_agent_summary_sync(self, agent_id: str) -> dict:
215
+ """Get summary for a specific agent (synchronous version).
216
+
217
+ Args:
218
+ agent_id: Agent identifier
219
+
220
+ Returns:
221
+ Dictionary mapping operation names to summary statistics for this agent,
222
+ or empty dict if agent has no recorded metrics
223
+ """
224
+ return self._get_agent_summary_internal(agent_id)
225
+
226
+ def _get_agent_summary_internal(self, agent_id: str) -> dict:
227
+ """Internal method to get agent summary without locking."""
228
+ if agent_id not in self._agent_metrics:
229
+ return {}
230
+
231
+ return {
232
+ operation: metrics.get_summary()
233
+ for operation, metrics in self._agent_metrics[agent_id].items()
234
+ }
235
+
236
+ async def get_operation_summary(self, operation: str) -> dict:
237
+ """Get summary for a specific operation.
238
+
239
+ Args:
240
+ operation: Operation name
241
+
242
+ Returns:
243
+ Summary statistics for this operation, or empty metrics if not found
244
+ """
245
+ async with self._lock:
246
+ return self._get_operation_summary_internal(operation)
247
+
248
+ def get_operation_summary_sync(self, operation: str) -> dict:
249
+ """Get summary for a specific operation (synchronous version).
250
+
251
+ Args:
252
+ operation: Operation name
253
+
254
+ Returns:
255
+ Summary statistics for this operation, or empty metrics if not found
256
+ """
257
+ return self._get_operation_summary_internal(operation)
258
+
259
+ def _get_operation_summary_internal(self, operation: str) -> dict:
260
+ """Internal method to get operation summary without locking."""
261
+ if operation not in self._metrics:
262
+ return {
263
+ "count": 0,
264
+ "avg_latency_ms": 0.0,
265
+ "p50_latency_ms": 0.0,
266
+ "p95_latency_ms": 0.0,
267
+ "p99_latency_ms": 0.0,
268
+ "error_rate": 0.0
269
+ }
270
+
271
+ return self._metrics[operation].get_summary()
272
+
273
+ async def get_all_agents(self) -> List[str]:
274
+ """Get list of all agents with recorded metrics.
275
+
276
+ Returns:
277
+ List of agent identifiers
278
+ """
279
+ async with self._lock:
280
+ return list(self._agent_metrics.keys())
281
+
282
+ def get_all_agents_sync(self) -> List[str]:
283
+ """Get list of all agents with recorded metrics (synchronous version).
284
+
285
+ Returns:
286
+ List of agent identifiers
287
+ """
288
+ return list(self._agent_metrics.keys())
289
+
290
+ async def reset(self):
291
+ """Reset all metrics (useful for testing)."""
292
+ async with self._lock:
293
+ self._metrics.clear()
294
+ self._agent_metrics.clear()
295
+
296
+ def reset_sync(self):
297
+ """Reset all metrics (synchronous version, useful for testing)."""
298
+ self._metrics.clear()
299
+ self._agent_metrics.clear()
src/middleware.py ADDED
@@ -0,0 +1,166 @@
1
+ """Access control middleware for Agent MCP Gateway.
2
+
3
+ This module implements the AgentAccessControl middleware that enforces
4
+ per-agent access rules for gateway tools. It extracts agent identity from
5
+ tool call arguments, validates permissions, and manages agent context state.
6
+ """
7
+
8
+ from fastmcp.server.middleware import Middleware, MiddlewareContext
9
+ from fastmcp.exceptions import ToolError
10
+ from .policy import PolicyEngine
11
+
12
+
13
+ class AgentAccessControl(Middleware):
14
+ """Enforces per-agent access rules for gateway operations.
15
+
16
+ This middleware intercepts tool calls to:
17
+ 1. Extract agent_id from arguments
18
+ 2. Validate agent identity (handle missing agent_id based on default policy)
19
+ 3. Store agent in context state for downstream use
20
+ 4. Remove agent_id from arguments before forwarding to tools
21
+ 5. Allow gateway tools to perform their own authorization
22
+
23
+ Gateway tools (list_servers, get_server_tools, execute_tool) handle their
24
+ own permission checks, so the middleware just extracts and cleans agent_id
25
+ without blocking them.
26
+ """
27
+
28
+ def __init__(self, policy_engine: PolicyEngine):
29
+ """Initialize middleware with policy engine.
30
+
31
+ Args:
32
+ policy_engine: PolicyEngine instance for evaluating access rules
33
+ """
34
+ self.policy_engine = policy_engine
35
+
36
+ async def on_call_tool(self, context: MiddlewareContext, call_next):
37
+ """Intercept tool calls to extract and validate agent identity.
38
+
39
+ This hook:
40
+ - Extracts agent_id from tool arguments
41
+ - Validates agent identity based on default policy
42
+ - Applies fallback chain if agent_id is missing (when deny_on_missing_agent: false)
43
+ - Stores agent in context state for downstream tools
44
+ - Keeps agent_id in arguments (gateway tools need it)
45
+ - Allows gateway tools to pass through (they do own auth)
46
+
47
+ Args:
48
+ context: Middleware context containing the tool call message
49
+ call_next: Callable to invoke next middleware/handler in chain
50
+
51
+ Returns:
52
+ Result from downstream handler
53
+
54
+ Raises:
55
+ ToolError: If agent_id is missing and default policy denies access,
56
+ or if fallback chain fails to find a valid agent
57
+ """
58
+ # Extract the tool call message
59
+ tool_call = context.message
60
+ arguments = tool_call.arguments or {}
61
+
62
+ # Extract agent_id from arguments
63
+ agent_id = arguments.get("agent_id")
64
+
65
+ # Handle missing agent_id based on default policy
66
+ if not agent_id:
67
+ # Check if default policy allows missing agents
68
+ deny_on_missing = self.policy_engine.defaults.get("deny_on_missing_agent", True)
69
+ if deny_on_missing:
70
+ raise ToolError(
71
+ "Missing required parameter 'agent_id'. "
72
+ "All tool calls must include agent identity."
73
+ )
74
+
75
+ # Apply fallback chain (deny_on_missing_agent: false)
76
+ # Priority: 1. GATEWAY_DEFAULT_AGENT env var, 2. "default" agent in rules
77
+ agent_id = self._resolve_fallback_agent(context)
78
+
79
+ # Inject the resolved agent_id back into arguments for gateway tools
80
+ if agent_id:
81
+ arguments["agent_id"] = agent_id
82
+ tool_call.arguments = arguments
83
+
84
+ # Store agent in context state for downstream tools
85
+ # This allows gateway tools to access the current agent
86
+ if context.fastmcp_context:
87
+ context.fastmcp_context.set_state("current_agent", agent_id)
88
+
89
+ # NOTE: We do NOT remove agent_id from arguments because the gateway
90
+ # tools (list_servers, get_server_tools, execute_tool) need it as
91
+ # a parameter to perform their authorization checks.
92
+ # If we ever add direct proxying to downstream servers in the future,
93
+ # we would need to remove it at that point.
94
+
95
+ # Gateway tools (list_servers, get_server_tools, execute_tool) are
96
+ # allowed through - they perform their own permission checks using
97
+ # the agent_id parameter
98
+ return await call_next(context)
99
+
100
+ def _resolve_fallback_agent(self, context: MiddlewareContext) -> str:
101
+ """Resolve fallback agent when agent_id is missing and deny_on_missing_agent: false.
102
+
103
+ Fallback priority order:
104
+ 1. GATEWAY_DEFAULT_AGENT environment variable (read from gateway module)
105
+ 2. Agent named "default" in gateway rules configuration
106
+ 3. Raise helpful error if neither is configured
107
+
108
+ Args:
109
+ context: Middleware context to access gateway state
110
+
111
+ Returns:
112
+ Resolved agent_id from fallback chain
113
+
114
+ Raises:
115
+ ToolError: If no fallback agent is configured or if fallback agent doesn't exist in rules
116
+ """
117
+ # Import here to avoid circular dependency
118
+ from .gateway import get_default_agent_id
119
+
120
+ # Try to get default agent from environment variable (stored in gateway module)
121
+ default_agent_from_env = get_default_agent_id()
122
+
123
+ # Priority 1: GATEWAY_DEFAULT_AGENT environment variable
124
+ if default_agent_from_env:
125
+ # Validate that this agent exists in policy rules
126
+ if default_agent_from_env in self.policy_engine.agents:
127
+ return default_agent_from_env
128
+ else:
129
+ raise ToolError(
130
+ f"Missing 'agent_id' parameter and fallback agent '{default_agent_from_env}' "
131
+ f"is not configured in gateway rules.\n"
132
+ f"Either provide 'agent_id' in your tool calls, or ask the user to configure "
133
+ f"the gateway fallback settings. See gateway documentation for configuration options."
134
+ )
135
+
136
+ # Priority 2: Agent named "default" in gateway rules
137
+ if "default" in self.policy_engine.agents:
138
+ return "default"
139
+
140
+ # Priority 3: No fallback configured - provide helpful error
141
+ raise ToolError(
142
+ "Missing 'agent_id' parameter and no fallback agent configured.\n"
143
+ "Either provide 'agent_id' in your tool calls, or ask the user to configure "
144
+ "the gateway fallback settings. See gateway documentation for configuration options."
145
+ )
146
+
147
+ async def on_list_tools(self, context: MiddlewareContext, call_next):
148
+ """Pass through list_tools requests without filtering.
149
+
150
+ Gateway tools (list_servers, get_server_tools, execute_tool) should
151
+ always be visible to all agents since they perform their own
152
+ authorization based on agent_id passed in arguments.
153
+
154
+ This differs from a traditional MCP proxy that might filter downstream
155
+ tools at the middleware level. Our gateway exposes only 3 gateway tools
156
+ that act as an API for discovering and executing downstream tools.
157
+
158
+ Args:
159
+ context: Middleware context containing the list request
160
+ call_next: Callable to invoke next middleware/handler in chain
161
+
162
+ Returns:
163
+ Full list of gateway tools from downstream handler
164
+ """
165
+ # No filtering needed - gateway tools handle their own authorization
166
+ return await call_next(context)