main
py 141 lines 4.52 KB
Raw
1 """MCP invocation gateway: lookup, validation, policy, timeout and normalization."""
2 from __future__ import annotations
3
4 import asyncio
5 import time
6 from typing import Any
7
8 from pydantic import ValidationError
9
10 from app.audit import McpAuditSink
11 from app.contracts import (
12 McpAuthContext,
13 McpErrorCode,
14 McpGatewayError,
15 McpResultEnvelope,
16 normalize_request_id,
17 )
18 from app.policy import McpToolPolicy
19 from app.registry import McpToolRegistry
20
21
22 class McpInvocationService:
23 def __init__(
24 self,
25 registry: McpToolRegistry,
26 policy: McpToolPolicy,
27 audit: McpAuditSink,
28 *,
29 timeout_seconds: float,
30 ) -> None:
31 if timeout_seconds <= 0:
32 raise ValueError("timeout_seconds must be positive")
33 self.registry = registry
34 self.policy = policy
35 self.audit = audit
36 self.timeout_seconds = timeout_seconds
37
38 async def invoke(
39 self,
40 tool: str,
41 arguments: dict[str, Any] | None,
42 auth: McpAuthContext,
43 *,
44 request_id: str | int | None = None,
45 ) -> dict[str, Any]:
46 request_id_value = normalize_request_id(request_id)
47 started = time.perf_counter()
48 definition = self.registry.get(tool)
49 self.audit.emit(
50 "MCP_TOOL_INVOKED",
51 tool=tool,
52 request_id=request_id_value,
53 auth=auth,
54 risk_class=definition.risk_class if definition else None,
55 kind=definition.kind if definition else None,
56 )
57
58 if definition is None:
59 return self._failed(
60 tool, request_id_value, auth, McpErrorCode.MCP_TOOL_NOT_FOUND, started
61 )
62
63 try:
64 self.policy.authorize(definition, auth)
65 except McpGatewayError as exc:
66 return self._denied(tool, request_id_value, auth, definition, exc.code, started)
67
68 try:
69 validated = definition.input_model.model_validate(arguments or {})
70 except ValidationError:
71 return self._failed(
72 tool, request_id_value, auth, McpErrorCode.INVALID_ARGUMENT, started, definition
73 )
74
75 try:
76 async with asyncio.timeout(self.timeout_seconds):
77 result = await definition.handler(validated, auth, request_id_value)
78 except TimeoutError:
79 return self._failed(
80 tool, request_id_value, auth, McpErrorCode.DOWNSTREAM_TIMEOUT, started, definition
81 )
82 except McpGatewayError as exc:
83 return self._failed(tool, request_id_value, auth, exc.code, started, definition)
84 except Exception:
85 return self._failed(
86 tool,
87 request_id_value,
88 auth,
89 McpErrorCode.DOWNSTREAM_UNAVAILABLE,
90 started,
91 definition,
92 )
93
94 envelope = McpResultEnvelope.success(
95 tool=tool,
96 request_id=request_id_value,
97 data=result.data,
98 generated_at=result.generated_at,
99 rule_engine_version=result.rule_engine_version,
100 warnings=list(result.warnings),
101 )
102 self.audit.emit(
103 "MCP_TOOL_SUCCEEDED",
104 tool=tool,
105 request_id=request_id_value,
106 auth=auth,
107 risk_class=definition.risk_class,
108 kind=definition.kind,
109 duration_ms=round((time.perf_counter() - started) * 1000),
110 )
111 return envelope.wire()
112
113 def _denied(self, tool, request_id, auth, definition, code, started):
114 self.audit.emit(
115 "MCP_TOOL_DENIED",
116 tool=tool,
117 request_id=request_id,
118 auth=auth,
119 risk_class=definition.risk_class,
120 kind=definition.kind,
121 code=code.value,
122 duration_ms=round((time.perf_counter() - started) * 1000),
123 )
124 return McpResultEnvelope.failure(
125 tool=tool, request_id=request_id, code=code
126 ).wire()
127
128 def _failed(self, tool, request_id, auth, code, started, definition=None):
129 self.audit.emit(
130 "MCP_TOOL_FAILED",
131 tool=tool,
132 request_id=request_id,
133 auth=auth,
134 risk_class=definition.risk_class if definition else None,
135 kind=definition.kind if definition else None,
136 code=code.value,
137 duration_ms=round((time.perf_counter() - started) * 1000),
138 )
139 return McpResultEnvelope.failure(
140 tool=tool, request_id=request_id, code=code
141 ).wire()