agentenv-framework-protocol 0.1.269__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.
- agentenv_framework_protocol-0.1.269.dist-info/METADATA +599 -0
- agentenv_framework_protocol-0.1.269.dist-info/RECORD +20 -0
- agentenv_framework_protocol-0.1.269.dist-info/WHEEL +4 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/LICENSE +202 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/NOTICE +4 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/THIRD_PARTY_NOTICES.md +1701 -0
- agentenv_protocol/__init__.py +121 -0
- agentenv_protocol/a2a_agent/__init__.py +204 -0
- agentenv_protocol/a2a_agent/_triggers.py +489 -0
- agentenv_protocol/a2a_agent/extensions.py +1151 -0
- agentenv_protocol/a2a_agent/framework.py +1283 -0
- agentenv_protocol/a2a_agent/registry.py +449 -0
- agentenv_protocol/a2a_agent/tasks/__init__.py +39 -0
- agentenv_protocol/a2a_agent/tasks/v1.py +408 -0
- agentenv_protocol/agent_env_environment.py +653 -0
- agentenv_protocol/client.py +185 -0
- agentenv_protocol/manifest.py +203 -0
- agentenv_protocol/preflight.py +81 -0
- agentenv_protocol/transfers.py +554 -0
- agentenv_protocol/types.py +165 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""Server SDK: decorate a handler's methods with @reset_data/@add_data/@get_data (JSON-RPC data plane at /agentenv; each optional, advertised as capabilities.operations), @extension (card-advertised REST routes), and @tool (MCP tools; FastMCP-backed apps only). Mount onto a FastMCP, Starlette, or FastAPI app via a per-framework AgentEnvApplication subclass — or subclass AgentEnvEnvironment, configure its card with @environment_card(...), and serve() it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import decimal
|
|
6
|
+
import enum
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import types
|
|
12
|
+
import uuid
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from typing import Annotated, Any, Callable, ClassVar, Literal, NamedTuple, Union, get_args, get_origin, get_type_hints
|
|
15
|
+
|
|
16
|
+
from pydantic.fields import FieldInfo
|
|
17
|
+
from starlette.requests import Request
|
|
18
|
+
from starlette.responses import Response
|
|
19
|
+
from starlette.routing import Route
|
|
20
|
+
|
|
21
|
+
from .types import MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataRequest, AddDataResponse, EnvironmentCapabilities, EnvironmentCard, EnvironmentExtension, EnvironmentInterface, EnvironmentTool, GetDataResponse, ResetDataResponse, error_body
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_OP_ATTR = "_aee_op"
|
|
26
|
+
_EXT_ATTR = "_aee_ext"
|
|
27
|
+
_EXT_ROUTE_ATTR = "_aee_ext_route"
|
|
28
|
+
_TOOL_ATTR = "_aee_tool"
|
|
29
|
+
_CARD_CONFIG_ATTR = "_aee_card_config"
|
|
30
|
+
|
|
31
|
+
OP_RESET_DATA = "reset_data"
|
|
32
|
+
OP_ADD_DATA = "add_data"
|
|
33
|
+
OP_GET_DATA = "get_data"
|
|
34
|
+
|
|
35
|
+
RPC_ERROR_CODE_PARSE_ERROR = -32700
|
|
36
|
+
RPC_ERROR_CODE_INVALID_REQUEST = -32600
|
|
37
|
+
RPC_ERROR_CODE_METHOD_NOT_FOUND = -32601
|
|
38
|
+
RPC_ERROR_CODE_INVALID_PARAMS = -32602
|
|
39
|
+
RPC_ERROR_CODE_SERVER_ERROR = -32000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def reset_data(fn: Callable) -> Callable:
|
|
43
|
+
setattr(fn, _OP_ATTR, OP_RESET_DATA)
|
|
44
|
+
return fn
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def add_data(fn: Callable) -> Callable:
|
|
48
|
+
setattr(fn, _OP_ATTR, OP_ADD_DATA)
|
|
49
|
+
return fn
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_data(fn: Callable) -> Callable:
|
|
53
|
+
setattr(fn, _OP_ATTR, OP_GET_DATA)
|
|
54
|
+
return fn
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extension(uri: str, *, description: str | None = None, params: dict | None = None,
|
|
58
|
+
required: bool | None = None, method: str = "POST", path: str | None = None) -> Callable:
|
|
59
|
+
"""Mark a handler method as the invocation handler for a card extension.
|
|
60
|
+
|
|
61
|
+
The extension is served as its own REST route and auto-advertised on the EnvironmentCard,
|
|
62
|
+
mirroring A2A's extension shape. By default the route is ``<RPC_PATH>/ext/<method-name>``
|
|
63
|
+
(the handler's name, verbatim) with HTTP ``POST``; pass ``path`` / ``method`` to override.
|
|
64
|
+
The call's params are bound to the handler's named parameters (``fn(**params)``): the JSON body
|
|
65
|
+
for POST, or the query string for GET. Either way the values are coerced to the handler's
|
|
66
|
+
annotated types (datetime/date/UUID/Decimal/enum/bool/int/float) so the handler receives the
|
|
67
|
+
runtime types the advertised schema implies. The return value is the JSON result (None -> {}).
|
|
68
|
+
|
|
69
|
+
The advertised ``params`` are A2A-shaped::
|
|
70
|
+
|
|
71
|
+
{"endpoint": <path>, "methods": {<op>: {"method": <verb>, "request": <JSON-Schema>}}}
|
|
72
|
+
|
|
73
|
+
where the request JSON-Schema is derived from the signature. Pass an explicit ``params`` to
|
|
74
|
+
override the advertisement. A handler that wants the raw params dict can declare ``**params``.
|
|
75
|
+
"""
|
|
76
|
+
# Normalize the HTTP verb once, here, so the advertised card method, the
|
|
77
|
+
# route registration, and the handler's request-binding (which checks for
|
|
78
|
+
# the exact string "GET") all agree. Without this, ``@extension(method="get")``
|
|
79
|
+
# advertises/invokes GET while the handler parses the request body instead of
|
|
80
|
+
# query params, so GET extensions with required query params return 400.
|
|
81
|
+
http_method = method.upper()
|
|
82
|
+
|
|
83
|
+
def deco(fn: Callable) -> Callable:
|
|
84
|
+
op = fn.__name__ or "invoke"
|
|
85
|
+
route_path = path or f"{RPC_PATH}/ext/{op}"
|
|
86
|
+
advertised = params if params is not None else {
|
|
87
|
+
"endpoint": route_path,
|
|
88
|
+
"methods": {op: {"method": http_method, "request": _schema_from_signature(fn)}},
|
|
89
|
+
}
|
|
90
|
+
setattr(fn, _EXT_ATTR, EnvironmentExtension(uri=uri, description=description, params=advertised, required=required))
|
|
91
|
+
setattr(fn, _EXT_ROUTE_ATTR, (route_path, http_method))
|
|
92
|
+
return fn
|
|
93
|
+
|
|
94
|
+
return deco
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def tool(name: str | None = None, *, description: str | None = None) -> Callable:
|
|
98
|
+
"""Mark a handler method as an MCP tool.
|
|
99
|
+
|
|
100
|
+
At mount the bound method is registered as a real MCP tool on the server app and advertised
|
|
101
|
+
on the EnvironmentCard under ``capabilities.tools`` (name, description, signature-derived
|
|
102
|
+
``inputSchema`` including ``Annotated[..., Field]`` param descriptions). ``name`` defaults to
|
|
103
|
+
the method name; a literal ``{environment_name}`` token in an explicit name is resolved to the
|
|
104
|
+
environment card's name at mount (any other ``{...}`` token raises). ``description`` defaults to
|
|
105
|
+
the docstring and is used verbatim on both
|
|
106
|
+
the card and the registration. Requires a FastMCP-backed application (non-FastMCP apps raise
|
|
107
|
+
at construction). Duplicate resolved names raise; a card-declared name wins the advertisement
|
|
108
|
+
while the method is still registered.
|
|
109
|
+
"""
|
|
110
|
+
if callable(name):
|
|
111
|
+
raise TypeError("use @tool(...) with parentheses, not bare @tool")
|
|
112
|
+
|
|
113
|
+
def deco(fn: Callable) -> Callable:
|
|
114
|
+
setattr(fn, _TOOL_ATTR, EnvironmentTool(
|
|
115
|
+
name=name or fn.__name__,
|
|
116
|
+
description=description or inspect.getdoc(fn),
|
|
117
|
+
inputSchema=_schema_from_signature(fn),
|
|
118
|
+
))
|
|
119
|
+
return fn
|
|
120
|
+
|
|
121
|
+
return deco
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def environment_card(_cls: type | None = None, **config: Any) -> Callable:
|
|
125
|
+
"""Configure the class's EnvironmentCard; kwargs are EnvironmentCard fields.
|
|
126
|
+
|
|
127
|
+
The SDK assembles the card at mount: ENVIRONMENT_NAME wins, then a card-declared name,
|
|
128
|
+
then the class name; decorator-discovered tools/extensions/operations are merged in.
|
|
129
|
+
agent-env injects ENVIRONMENT_NAME as the env's registered name, so the card follows a
|
|
130
|
+
registration that overrides the code's name. SERVICE_NAME is no longer consulted.
|
|
131
|
+
"""
|
|
132
|
+
if _cls is not None:
|
|
133
|
+
raise TypeError("use @environment_card(...) with parentheses")
|
|
134
|
+
unknown = set(config) - set(EnvironmentCard.model_fields)
|
|
135
|
+
if unknown:
|
|
136
|
+
raise TypeError(f"unknown EnvironmentCard field(s): {sorted(unknown)}")
|
|
137
|
+
|
|
138
|
+
def deco(cls: type) -> type:
|
|
139
|
+
setattr(cls, _CARD_CONFIG_ATTR, config)
|
|
140
|
+
return cls
|
|
141
|
+
|
|
142
|
+
return deco
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class AgentEnvApplication(ABC):
|
|
146
|
+
"""Holds the environment card + handler and builds the JSON-RPC data-plane routes. Subclasses mount them on a specific server framework."""
|
|
147
|
+
|
|
148
|
+
supports_tools: ClassVar[bool] = False
|
|
149
|
+
|
|
150
|
+
def __init__(self, environment_card: EnvironmentCard, handler: Any) -> None:
|
|
151
|
+
self.handler = handler
|
|
152
|
+
ext_handlers = _discover_extensions(handler)
|
|
153
|
+
environment_card = _merge_extensions(environment_card, ext_handlers)
|
|
154
|
+
tool_handlers = _discover_tools(handler, environment_card.name)
|
|
155
|
+
if tool_handlers and not self.supports_tools:
|
|
156
|
+
raise ValueError(f"@tool methods require a FastMCP-backed application; {type(self).__name__} cannot serve MCP tools")
|
|
157
|
+
environment_card = _merge_tools(environment_card, tool_handlers)
|
|
158
|
+
methods = _discover_methods(handler)
|
|
159
|
+
environment_card = _merge_operations(environment_card, methods)
|
|
160
|
+
self.environment_card = environment_card
|
|
161
|
+
self._dispatch = _jsonrpc_handler(methods)
|
|
162
|
+
self._ext_routes = [(path, method, _extension_handler(fn, method)) for _descriptor, fn, path, method in ext_handlers]
|
|
163
|
+
# Register with the advertised descriptor (card-declared wins in _merge_tools) so
|
|
164
|
+
# tools/list cannot drift from the card; falls back to the discovered descriptor.
|
|
165
|
+
caps_tools = (environment_card.capabilities.tools if environment_card.capabilities else None) or []
|
|
166
|
+
advertised = {t.name: t for t in caps_tools}
|
|
167
|
+
self._tools = [(advertised.get(descriptor.name, descriptor), fn) for descriptor, fn in tool_handlers]
|
|
168
|
+
|
|
169
|
+
def add_routes_to_app(self, app: Any, *, rpc_url: str = RPC_PATH, card_url: str = WELL_KNOWN_PATH) -> None:
|
|
170
|
+
self.environment_card = self._card_for(app)
|
|
171
|
+
self._add_route(app, rpc_url, ["POST"], self._dispatch)
|
|
172
|
+
self._add_route(app, card_url, ["GET"], _card_handler(self.environment_card))
|
|
173
|
+
for path, method, handler in self._ext_routes:
|
|
174
|
+
self._add_route(app, path, [method], handler)
|
|
175
|
+
for descriptor, fn in self._tools:
|
|
176
|
+
self._register_tool(app, descriptor, fn)
|
|
177
|
+
|
|
178
|
+
@abstractmethod
|
|
179
|
+
def _add_route(self, app: Any, path: str, methods: list, handler: Callable) -> None:
|
|
180
|
+
...
|
|
181
|
+
|
|
182
|
+
def _register_tool(self, app: Any, descriptor: EnvironmentTool, fn: Callable) -> None:
|
|
183
|
+
raise NotImplementedError(f"{type(self).__name__} does not serve MCP tools")
|
|
184
|
+
|
|
185
|
+
def _card_for(self, app: Any) -> EnvironmentCard:
|
|
186
|
+
return self.environment_card
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class AgentEnvFastMCPApplication(AgentEnvApplication):
|
|
190
|
+
supports_tools: ClassVar[bool] = True
|
|
191
|
+
|
|
192
|
+
def _add_route(self, app: Any, path: str, methods: list, handler: Callable) -> None:
|
|
193
|
+
app.custom_route(path, methods=methods)(handler)
|
|
194
|
+
|
|
195
|
+
def _register_tool(self, app: Any, descriptor: EnvironmentTool, fn: Callable) -> None:
|
|
196
|
+
app.tool(name=descriptor.name, description=descriptor.description)(fn)
|
|
197
|
+
|
|
198
|
+
def _card_for(self, app: Any) -> EnvironmentCard:
|
|
199
|
+
"""Declare the MCP endpoint only when the app says where it serves it; FastMCP-shaped targets may not."""
|
|
200
|
+
path = getattr(getattr(app, "settings", None), "streamable_http_path", None)
|
|
201
|
+
return _merge_mcp_interface(self.environment_card, path) if path else self.environment_card
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class AgentEnvStarletteApplication(AgentEnvApplication):
|
|
205
|
+
def _add_route(self, app: Any, path: str, methods: list, handler: Callable) -> None:
|
|
206
|
+
app.router.routes.append(Route(path, handler, methods=methods))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def create_fastmcp_app(handler: Any, *, card: EnvironmentCard, **fastmcp_kwargs: Any) -> Any:
|
|
210
|
+
"""FastMCP app wired for the agent-env deploy contract, with ``handler`` mounted under ``card``; returned un-served."""
|
|
211
|
+
try:
|
|
212
|
+
from mcp.server.fastmcp import FastMCP
|
|
213
|
+
except ImportError as e:
|
|
214
|
+
raise ImportError("create_fastmcp_app requires the 'mcp' package: pip install 'mcp>=1.25,<2'") from e
|
|
215
|
+
app = FastMCP(card.name, **fastmcp_kwargs)
|
|
216
|
+
app.settings.host = os.environ.get("MCP_HOST", "0.0.0.0")
|
|
217
|
+
app.settings.port = int(os.environ.get("MCP_PORT", "18765"))
|
|
218
|
+
app.settings.transport_security.enable_dns_rebinding_protection = False
|
|
219
|
+
AgentEnvFastMCPApplication(card, handler).add_routes_to_app(app)
|
|
220
|
+
return app
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class AgentEnvEnvironment:
|
|
224
|
+
"""MCP environment server base: decorate with ``@environment_card(...)``, decorate methods, ``serve()``.
|
|
225
|
+
|
|
226
|
+
``serve()`` builds the app via ``create_fastmcp_app`` unless the subclass set its own
|
|
227
|
+
``self.mcp`` — then it mounts if needed and runs it as-is, never altering caller settings.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
mcp: Any = None
|
|
231
|
+
_mounted: bool = False
|
|
232
|
+
|
|
233
|
+
def _build_card(self) -> EnvironmentCard:
|
|
234
|
+
config = dict(getattr(self, _CARD_CONFIG_ATTR, None) or {})
|
|
235
|
+
name = os.environ.get("ENVIRONMENT_NAME") or config.get("name") or type(self).__name__
|
|
236
|
+
return EnvironmentCard(**{**config, "name": name})
|
|
237
|
+
|
|
238
|
+
def create_app(self) -> Any:
|
|
239
|
+
if self._mounted:
|
|
240
|
+
raise RuntimeError(f"{type(self).__name__} is already mounted")
|
|
241
|
+
self.mcp = create_fastmcp_app(self, card=self._build_card())
|
|
242
|
+
self._mounted = True
|
|
243
|
+
return self.mcp
|
|
244
|
+
|
|
245
|
+
def mount(self, app: Any) -> Any:
|
|
246
|
+
if self._mounted:
|
|
247
|
+
raise RuntimeError(f"{type(self).__name__} is already mounted")
|
|
248
|
+
AgentEnvFastMCPApplication(self._build_card(), self).add_routes_to_app(app)
|
|
249
|
+
self.mcp = app
|
|
250
|
+
self._mounted = True
|
|
251
|
+
return app
|
|
252
|
+
|
|
253
|
+
def serve(self, transport: str = "streamable-http") -> None:
|
|
254
|
+
if self.mcp is None:
|
|
255
|
+
self.create_app()
|
|
256
|
+
elif not self._mounted:
|
|
257
|
+
self.mount(self.mcp)
|
|
258
|
+
self.mcp.run(transport=transport)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
_SCALAR_TYPES = {str: "string", int: "integer", float: "number", bool: "boolean", list: "array", dict: "object"}
|
|
262
|
+
_STRING_FORMATS = {datetime.datetime: "date-time", datetime.date: "date", datetime.time: "time", uuid.UUID: "uuid"}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _enum_schema(values: list) -> dict:
|
|
266
|
+
"""JSON-Schema for an enum/Literal: the allowed values, plus a `type` if they're homogeneous."""
|
|
267
|
+
schema: dict = {"enum": values}
|
|
268
|
+
value_types = {_SCALAR_TYPES.get(type(v)) for v in values}
|
|
269
|
+
if len(value_types) == 1 and None not in value_types:
|
|
270
|
+
schema["type"] = value_types.pop()
|
|
271
|
+
return schema
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _schema_for_annotation(annotation: Any) -> dict:
|
|
275
|
+
"""Map a Python annotation to a JSON-Schema fragment (type/enum/format/description); {} = unconstrained."""
|
|
276
|
+
if annotation is inspect.Parameter.empty or annotation is Any:
|
|
277
|
+
return {}
|
|
278
|
+
origin = get_origin(annotation)
|
|
279
|
+
if origin is Annotated:
|
|
280
|
+
base, *metadata = get_args(annotation)
|
|
281
|
+
schema = _schema_for_annotation(base)
|
|
282
|
+
for meta in metadata:
|
|
283
|
+
if isinstance(meta, FieldInfo) and meta.description:
|
|
284
|
+
schema["description"] = meta.description
|
|
285
|
+
return schema
|
|
286
|
+
if origin is Union or origin is getattr(types, "UnionType", None):
|
|
287
|
+
non_none = [a for a in get_args(annotation) if a is not type(None)]
|
|
288
|
+
return _schema_for_annotation(non_none[0]) if len(non_none) == 1 else {}
|
|
289
|
+
if origin is Literal:
|
|
290
|
+
return _enum_schema(list(get_args(annotation)))
|
|
291
|
+
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
|
|
292
|
+
return _enum_schema([m.value for m in annotation])
|
|
293
|
+
fmt = _STRING_FORMATS.get(annotation)
|
|
294
|
+
if fmt is not None:
|
|
295
|
+
return {"type": "string", "format": fmt}
|
|
296
|
+
if annotation is decimal.Decimal:
|
|
297
|
+
return {"type": "string"}
|
|
298
|
+
if origin in (tuple, set, frozenset) or annotation in (tuple, set, frozenset):
|
|
299
|
+
return {"type": "array"}
|
|
300
|
+
json_type = _SCALAR_TYPES.get(origin or annotation)
|
|
301
|
+
return {"type": json_type} if json_type is not None else {}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _schema_from_signature(fn: Callable) -> dict:
|
|
305
|
+
"""Derive a JSON-Schema object for a handler's call params from its signature (Annotated[Field] descriptions included)."""
|
|
306
|
+
sig = inspect.signature(fn)
|
|
307
|
+
try:
|
|
308
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
309
|
+
except Exception:
|
|
310
|
+
hints = {}
|
|
311
|
+
properties: dict = {}
|
|
312
|
+
required: list = []
|
|
313
|
+
for name, param in sig.parameters.items():
|
|
314
|
+
if name in ("self", "cls") or param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
315
|
+
continue
|
|
316
|
+
prop = _schema_for_annotation(hints.get(name, param.annotation))
|
|
317
|
+
if param.default is inspect.Parameter.empty:
|
|
318
|
+
required.append(name)
|
|
319
|
+
else:
|
|
320
|
+
prop["default"] = param.default.value if isinstance(param.default, enum.Enum) else param.default
|
|
321
|
+
properties[name] = prop
|
|
322
|
+
schema: dict = {"type": "object", "properties": properties}
|
|
323
|
+
if required:
|
|
324
|
+
schema["required"] = required
|
|
325
|
+
return schema
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _marked_members(handler: Any, marker_attr: str):
|
|
329
|
+
"""Yield (bound_member, marker) for every handler member carrying ``marker_attr``."""
|
|
330
|
+
for name in dir(handler):
|
|
331
|
+
if name.startswith("__"):
|
|
332
|
+
continue
|
|
333
|
+
attr = getattr(handler, name)
|
|
334
|
+
marker = getattr(attr, marker_attr, None)
|
|
335
|
+
if marker is not None:
|
|
336
|
+
yield attr, marker
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _discover_methods(handler: Any) -> dict:
|
|
340
|
+
found: dict[str, Callable] = {}
|
|
341
|
+
for attr, op in _marked_members(handler, _OP_ATTR):
|
|
342
|
+
if op in found:
|
|
343
|
+
raise ValueError(f"Multiple methods marked @{op}")
|
|
344
|
+
found[op] = attr
|
|
345
|
+
methods: dict = {}
|
|
346
|
+
for op in _OPERATIONS:
|
|
347
|
+
fn = found.get(op.name)
|
|
348
|
+
if fn is not None:
|
|
349
|
+
methods[op.rpc_method] = (op, fn)
|
|
350
|
+
return methods
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _discover_extensions(handler: Any) -> list:
|
|
354
|
+
"""Collect (descriptor, bound_method, route_path, http_method) for every @extension method."""
|
|
355
|
+
seen_uris: set = set()
|
|
356
|
+
seen_paths: set = set()
|
|
357
|
+
ordered: list = []
|
|
358
|
+
for attr, descriptor in _marked_members(handler, _EXT_ATTR):
|
|
359
|
+
route_path, http_method = getattr(attr, _EXT_ROUTE_ATTR)
|
|
360
|
+
if descriptor.uri in seen_uris:
|
|
361
|
+
raise ValueError(f"Multiple handlers for extension {descriptor.uri}")
|
|
362
|
+
if route_path in seen_paths:
|
|
363
|
+
raise ValueError(f"Multiple handlers for extension path {route_path}")
|
|
364
|
+
seen_uris.add(descriptor.uri)
|
|
365
|
+
seen_paths.add(route_path)
|
|
366
|
+
ordered.append((descriptor, attr, route_path, http_method))
|
|
367
|
+
return ordered
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _merge_extensions(card: EnvironmentCard, ext_handlers: list) -> EnvironmentCard:
|
|
371
|
+
"""Advertise decorator-declared extensions under card.capabilities; card-declared URIs win."""
|
|
372
|
+
if not ext_handlers:
|
|
373
|
+
return card
|
|
374
|
+
caps = card.capabilities or EnvironmentCapabilities()
|
|
375
|
+
advertised = list(caps.extensions or [])
|
|
376
|
+
seen = {e.uri for e in advertised}
|
|
377
|
+
for descriptor, *_ in ext_handlers:
|
|
378
|
+
if descriptor.uri not in seen:
|
|
379
|
+
advertised.append(descriptor)
|
|
380
|
+
seen.add(descriptor.uri)
|
|
381
|
+
return card.model_copy(update={"capabilities": caps.model_copy(update={"extensions": advertised or None})})
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
_ENV_NAME_TOKEN = "{environment_name}"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _discover_tools(handler: Any, environment_name: str) -> list:
|
|
388
|
+
"""Collect (EnvironmentTool, bound_method) for every @tool method, resolving the
|
|
389
|
+
``{environment_name}`` placeholder in each name against the environment card name.
|
|
390
|
+
Any other unresolved ``{...}`` token raises."""
|
|
391
|
+
seen: set = set()
|
|
392
|
+
ordered: list = []
|
|
393
|
+
for attr, marker in _marked_members(handler, _TOOL_ATTR):
|
|
394
|
+
name = marker.name.replace(_ENV_NAME_TOKEN, environment_name)
|
|
395
|
+
if "{" in name or "}" in name:
|
|
396
|
+
raise ValueError(
|
|
397
|
+
f"@tool name {marker.name!r} has an unresolved placeholder; "
|
|
398
|
+
f"the only supported token is {_ENV_NAME_TOKEN!r}"
|
|
399
|
+
)
|
|
400
|
+
descriptor = marker.model_copy(update={"name": name})
|
|
401
|
+
if descriptor.name in seen:
|
|
402
|
+
raise ValueError(f"Multiple handlers for tool {descriptor.name}")
|
|
403
|
+
seen.add(descriptor.name)
|
|
404
|
+
ordered.append((descriptor, attr))
|
|
405
|
+
return ordered
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _merge_tools(card: EnvironmentCard, tool_handlers: list) -> EnvironmentCard:
|
|
409
|
+
"""Advertise decorator-declared tools under card.capabilities; card-declared names win."""
|
|
410
|
+
if not tool_handlers:
|
|
411
|
+
return card
|
|
412
|
+
caps = card.capabilities or EnvironmentCapabilities()
|
|
413
|
+
advertised = list(caps.tools or [])
|
|
414
|
+
seen = {t.name for t in advertised}
|
|
415
|
+
for descriptor, _fn in tool_handlers:
|
|
416
|
+
if descriptor.name not in seen:
|
|
417
|
+
advertised.append(descriptor)
|
|
418
|
+
seen.add(descriptor.name)
|
|
419
|
+
return card.model_copy(update={"capabilities": caps.model_copy(update={"tools": advertised or None})})
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _merge_operations(card: EnvironmentCard, methods: dict) -> EnvironmentCard:
|
|
423
|
+
"""Advertise the discovered operations; always overwritten — registration is the truth.
|
|
424
|
+
[] is distinct from absent, which marks a pre-advertisement card (full trio required)."""
|
|
425
|
+
caps = card.capabilities or EnvironmentCapabilities()
|
|
426
|
+
return card.model_copy(update={"capabilities": caps.model_copy(update={"operations": list(methods)})})
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _merge_mcp_interface(card: EnvironmentCard, path: str) -> EnvironmentCard:
|
|
430
|
+
"""Declare the MCP endpoint at the path the app serves it on; a card-declared `MCP_TRANSPORT` interface wins.
|
|
431
|
+
|
|
432
|
+
Only that transport counts, because it is the only one `client.mcp_path` reads.
|
|
433
|
+
"""
|
|
434
|
+
if any(i.transport == MCP_TRANSPORT for i in card.additionalInterfaces):
|
|
435
|
+
return card
|
|
436
|
+
interfaces = [*card.additionalInterfaces, EnvironmentInterface(url=path, transport=MCP_TRANSPORT)]
|
|
437
|
+
return card.model_copy(update={"additionalInterfaces": interfaces})
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class _InvalidParams(Exception):
|
|
441
|
+
pass
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class _OperationError(Exception):
|
|
445
|
+
def __init__(self, code: str, message: str) -> None:
|
|
446
|
+
super().__init__(message)
|
|
447
|
+
self.code = code
|
|
448
|
+
self.message = message
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def _invoke_reset(fn: Callable, params: dict) -> dict:
|
|
452
|
+
try:
|
|
453
|
+
await _maybe_await(fn())
|
|
454
|
+
except Exception as e:
|
|
455
|
+
logger.exception("data/reset failed")
|
|
456
|
+
raise _OperationError("reset_failed", str(e))
|
|
457
|
+
return ResetDataResponse().model_dump()
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
async def _invoke_add(fn: Callable, params: dict) -> dict:
|
|
461
|
+
try:
|
|
462
|
+
req = AddDataRequest(**params)
|
|
463
|
+
except Exception as e:
|
|
464
|
+
raise _InvalidParams(str(e))
|
|
465
|
+
try:
|
|
466
|
+
await _maybe_await(fn(req.parts))
|
|
467
|
+
except Exception as e:
|
|
468
|
+
logger.exception("data/add failed")
|
|
469
|
+
raise _OperationError("add_failed", str(e))
|
|
470
|
+
return AddDataResponse().model_dump()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
async def _invoke_get(fn: Callable, params: dict) -> dict:
|
|
474
|
+
try:
|
|
475
|
+
parts = await _maybe_await(fn())
|
|
476
|
+
return GetDataResponse(parts=parts).model_dump(exclude_none=True)
|
|
477
|
+
except Exception as e:
|
|
478
|
+
logger.exception("data/get failed")
|
|
479
|
+
raise _OperationError("get_failed", str(e))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _coerce_literal(value: Any, target: Any) -> Any:
|
|
483
|
+
"""Validate (and coerce) a request value against a ``Literal[...]`` annotation.
|
|
484
|
+
|
|
485
|
+
``_schema_for_annotation`` advertises a ``Literal`` as an enum, so the request path must enforce
|
|
486
|
+
it: when every allowed value shares one homogeneous scalar type (e.g. ``Literal[1, 2]``) a raw
|
|
487
|
+
GET query string is coerced to that type so ``"1"`` matches ``1``. The value must then be one of
|
|
488
|
+
the allowed literals — anything else raises ``ValueError`` (turned into a 400 ``invalid_params``)
|
|
489
|
+
so a handler with ``mode: Literal["live", "fixed"]`` can't receive ``"delete"`` and run anyway."""
|
|
490
|
+
allowed = get_args(target)
|
|
491
|
+
value_types = {type(a) for a in allowed}
|
|
492
|
+
if isinstance(value, str) and len(value_types) == 1:
|
|
493
|
+
(lit_type,) = value_types
|
|
494
|
+
if lit_type is not str:
|
|
495
|
+
try:
|
|
496
|
+
value = _coerce_scalar(value, lit_type)
|
|
497
|
+
except Exception:
|
|
498
|
+
pass # leave as-is; the membership check below rejects it
|
|
499
|
+
# `is`-aware membership so bool literals aren't matched by 0/1 (since 1 == True in Python).
|
|
500
|
+
if not any(v is value or (type(v) is type(value) and v == value) for v in allowed):
|
|
501
|
+
raise ValueError(f"invalid value {value!r}; expected one of {list(allowed)}")
|
|
502
|
+
return value
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _coerce_scalar(value: Any, target: Any) -> Any:
|
|
506
|
+
"""Coerce a request value to the handler's annotated type so the handler receives the runtime
|
|
507
|
+
type its signature (and the advertised JSON-Schema) implies — not a raw ``str``.
|
|
508
|
+
|
|
509
|
+
Covers every type ``_schema_for_annotation`` advertises as a concrete input: bool/int/float,
|
|
510
|
+
``datetime``/``date``/``time`` (``date-time``/``date``/``time`` formats), ``UUID`` (``uuid``),
|
|
511
|
+
``Decimal`` (advertised as ``string``), and ``Enum`` subclasses. Without this, a client that
|
|
512
|
+
sends a value matching the advertised schema (e.g. a UUID or ISO datetime string) hands the
|
|
513
|
+
handler a plain ``str``, so ``ident.hex`` / datetime comparisons / ``Decimal`` arithmetic /
|
|
514
|
+
enum checks fail with a 500 even though the request matched the card. ``Optional[T]`` is
|
|
515
|
+
unwrapped; ``Literal[...]`` values are validated against (and coerced to) their advertised
|
|
516
|
+
allowed values; unknown/unannotated targets pass through unchanged. Invalid inputs (e.g. a
|
|
517
|
+
non-ISO datetime, or a value outside a ``Literal``) raise — the caller turns that into a 400
|
|
518
|
+
``invalid_params``."""
|
|
519
|
+
origin = get_origin(target)
|
|
520
|
+
if origin is Union or origin is getattr(types, "UnionType", None):
|
|
521
|
+
non_none = [a for a in get_args(target) if a is not type(None)]
|
|
522
|
+
target = non_none[0] if len(non_none) == 1 else None
|
|
523
|
+
origin = get_origin(target)
|
|
524
|
+
if origin is Literal:
|
|
525
|
+
return _coerce_literal(value, target)
|
|
526
|
+
if target is None or not isinstance(target, type):
|
|
527
|
+
return value
|
|
528
|
+
# Enum: accept the member's underlying value (str/int/…) or pass an already-built member through.
|
|
529
|
+
if issubclass(target, enum.Enum):
|
|
530
|
+
return value if isinstance(value, target) else target(value)
|
|
531
|
+
# Everything below maps from a string; native JSON ints/floats/bools/None pass straight through.
|
|
532
|
+
if not isinstance(value, str):
|
|
533
|
+
return value
|
|
534
|
+
if target is bool:
|
|
535
|
+
low = value.strip().lower()
|
|
536
|
+
if low in ("true", "1", "yes", "on"):
|
|
537
|
+
return True
|
|
538
|
+
if low in ("false", "0", "no", "off"):
|
|
539
|
+
return False
|
|
540
|
+
raise ValueError(f"invalid boolean: {value!r}")
|
|
541
|
+
if target is int:
|
|
542
|
+
return int(value)
|
|
543
|
+
if target is float:
|
|
544
|
+
return float(value)
|
|
545
|
+
if target is decimal.Decimal:
|
|
546
|
+
return decimal.Decimal(value)
|
|
547
|
+
if issubclass(target, datetime.datetime): # before datetime.date — datetime subclasses date
|
|
548
|
+
return datetime.datetime.fromisoformat(value)
|
|
549
|
+
if issubclass(target, datetime.date):
|
|
550
|
+
return datetime.date.fromisoformat(value)
|
|
551
|
+
if issubclass(target, datetime.time):
|
|
552
|
+
return datetime.time.fromisoformat(value)
|
|
553
|
+
if issubclass(target, uuid.UUID):
|
|
554
|
+
return uuid.UUID(value)
|
|
555
|
+
return value
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _coerce_params(fn: Callable, params: dict) -> dict:
|
|
559
|
+
"""Coerce request params (GET query strings OR a POST JSON body) to the handler's annotated
|
|
560
|
+
types — see ``_coerce_scalar``. Keys with no annotation (or no hint) pass through untouched."""
|
|
561
|
+
try:
|
|
562
|
+
hints = get_type_hints(fn)
|
|
563
|
+
except Exception:
|
|
564
|
+
return params
|
|
565
|
+
return {k: _coerce_scalar(v, hints[k]) if k in hints else v for k, v in params.items()}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _extension_handler(fn: Callable, http_method: str) -> Callable:
|
|
569
|
+
"""REST handler for an @extension route: bind request params to the handler and return JSON."""
|
|
570
|
+
async def handler(request: Request) -> Response:
|
|
571
|
+
try:
|
|
572
|
+
if http_method == "GET":
|
|
573
|
+
params = _coerce_params(fn, dict(request.query_params))
|
|
574
|
+
else:
|
|
575
|
+
raw = await request.body()
|
|
576
|
+
params = json.loads(raw) if raw else {}
|
|
577
|
+
if not isinstance(params, dict):
|
|
578
|
+
raise ValueError("request body must be a JSON object")
|
|
579
|
+
params = _coerce_params(fn, params)
|
|
580
|
+
except Exception as e:
|
|
581
|
+
return _json_response(error_body("invalid_params", str(e)), 400)
|
|
582
|
+
try:
|
|
583
|
+
inspect.signature(fn).bind(**params)
|
|
584
|
+
except TypeError as e:
|
|
585
|
+
return _json_response(error_body("invalid_params", str(e)), 400)
|
|
586
|
+
try:
|
|
587
|
+
result = await _maybe_await(fn(**params))
|
|
588
|
+
except Exception as e:
|
|
589
|
+
logger.exception("extension invocation failed")
|
|
590
|
+
return _json_response(error_body("extension_failed", str(e)), 500)
|
|
591
|
+
return _json_response(result if result is not None else {})
|
|
592
|
+
return handler
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
class _Op(NamedTuple):
|
|
596
|
+
name: str
|
|
597
|
+
rpc_method: str
|
|
598
|
+
invoke: Callable
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
_OPERATIONS = [
|
|
602
|
+
_Op(OP_RESET_DATA, METHOD_RESET, _invoke_reset),
|
|
603
|
+
_Op(OP_ADD_DATA, METHOD_ADD, _invoke_add),
|
|
604
|
+
_Op(OP_GET_DATA, METHOD_GET, _invoke_get),
|
|
605
|
+
]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _jsonrpc_handler(methods: dict) -> Callable:
|
|
609
|
+
async def handler(request: Request) -> Response:
|
|
610
|
+
raw = await request.body()
|
|
611
|
+
try:
|
|
612
|
+
req = json.loads(raw) if raw else None
|
|
613
|
+
except Exception:
|
|
614
|
+
return _rpc_error(None, RPC_ERROR_CODE_PARSE_ERROR, "parse error")
|
|
615
|
+
if not isinstance(req, dict) or req.get("jsonrpc") != "2.0" or "method" not in req:
|
|
616
|
+
rid = req.get("id") if isinstance(req, dict) else None
|
|
617
|
+
return _rpc_error(rid, RPC_ERROR_CODE_INVALID_REQUEST, "invalid request")
|
|
618
|
+
rid = req.get("id")
|
|
619
|
+
entry = methods.get(req["method"])
|
|
620
|
+
if entry is None:
|
|
621
|
+
return _rpc_error(rid, RPC_ERROR_CODE_METHOD_NOT_FOUND, f"method not found: {req['method']}")
|
|
622
|
+
op, fn = entry
|
|
623
|
+
try:
|
|
624
|
+
result = await op.invoke(fn, req.get("params") or {})
|
|
625
|
+
except _InvalidParams as e:
|
|
626
|
+
return _rpc_error(rid, RPC_ERROR_CODE_INVALID_PARAMS, "invalid_request", {"detail": str(e)})
|
|
627
|
+
except _OperationError as e:
|
|
628
|
+
return _rpc_error(rid, RPC_ERROR_CODE_SERVER_ERROR, e.message, {"code": e.code})
|
|
629
|
+
return _json_response({"jsonrpc": "2.0", "id": rid, "result": result})
|
|
630
|
+
return handler
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _card_handler(card: EnvironmentCard) -> Callable:
|
|
634
|
+
async def handler(request: Request) -> Response:
|
|
635
|
+
return _json_response(card.model_dump())
|
|
636
|
+
return handler
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _rpc_error(rid: Any, code: int, message: str, data: Any = None) -> Response:
|
|
640
|
+
error: dict = {"code": code, "message": message}
|
|
641
|
+
if data is not None:
|
|
642
|
+
error["data"] = data
|
|
643
|
+
return _json_response({"jsonrpc": "2.0", "id": rid, "error": error})
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _json_response(body: Any, status: int = 200) -> Response:
|
|
647
|
+
return Response(json.dumps(body, default=str), status_code=status, media_type="application/json")
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
async def _maybe_await(result: Any) -> Any:
|
|
651
|
+
if inspect.isawaitable(result):
|
|
652
|
+
return await result
|
|
653
|
+
return result
|