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.
@@ -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