agentenv-framework-protocol 0.1.323__tar.gz → 0.1.325__tar.gz

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.
Files changed (44) hide show
  1. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/PKG-INFO +37 -1
  2. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/README.md +36 -0
  3. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/pyproject.toml +1 -1
  4. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/__init__.py +20 -0
  5. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/agent_env_environment.py +275 -15
  6. agentenv_framework_protocol-0.1.325/src/agentenv_protocol/tool_context.py +200 -0
  7. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_agent_env_environment.py +6 -0
  8. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_data_objects.py +13 -0
  9. agentenv_framework_protocol-0.1.325/tests/test_tool_context.py +394 -0
  10. agentenv_framework_protocol-0.1.325/tests/test_tool_context_injection.py +479 -0
  11. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/.gitignore +0 -0
  12. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/LICENSE +0 -0
  13. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/NOTICE +0 -0
  14. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/THIRD_PARTY_NOTICES.md +0 -0
  15. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/__init__.py +0 -0
  16. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/advanced_agent.py +0 -0
  17. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/basic_agent.py +0 -0
  18. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/custom_extensions_agent.py +0 -0
  19. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/multimodal_agent.py +0 -0
  20. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/reference_model.py +0 -0
  21. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/streaming_agent.py +0 -0
  22. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  23. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
  24. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  25. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
  26. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
  27. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  28. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  29. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  30. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  31. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/client.py +0 -0
  32. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/manifest.py +0 -0
  33. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/preflight.py +0 -0
  34. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/transfers.py +0 -0
  35. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/types.py +0 -0
  36. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_a2a_agent.py +0 -0
  37. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_a2a_agent_examples.py +0 -0
  38. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_a2a_object_transfers.py +0 -0
  39. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_a2a_staging.py +0 -0
  40. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_a2a_trajectory_log.py +0 -0
  41. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_client.py +0 -0
  42. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_intake.py +0 -0
  43. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_manifest.py +0 -0
  44. {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_preflight.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentenv-framework-protocol
3
- Version: 0.1.323
3
+ Version: 0.1.325
4
4
  Summary: Open data-plane protocol and server SDK for agent environments.
5
5
  Project-URL: Homepage, https://www.agentenvframework.com
6
6
  Project-URL: Documentation, https://www.agentenvframework.com/docs
@@ -127,6 +127,42 @@ environment-prefixed tools without knowing the name at class-definition time; an
127
127
  FastMCP; since those apps have no MCP tool registry, constructing one with `@tool` methods
128
128
  raises.
129
129
 
130
+ ### Who is calling
131
+
132
+ A tool that needs the caller declares a parameter annotated `ToolContext` (any name) and receives
133
+ it on every call. The SDK fills it, never the client: it is absent from the advertised
134
+ `inputSchema` and from `tools/list`. Tools that declare no such parameter are registered unchanged.
135
+
136
+ ```python
137
+ from agentenv_protocol import ToolContext
138
+
139
+ @tool(name="{environment_name}_list_inbox")
140
+ def list_inbox(self, ctx: ToolContext) -> list[dict]:
141
+ """Messages addressed to the caller."""
142
+ return [m for m in self.messages if m["to"] == ctx.caller.role]
143
+ ```
144
+
145
+ `ctx.caller.role` is the role the agent was deployed with, as the gateway forwarded it (`None` when
146
+ nothing was forwarded; the gateway's `default` role counts as nothing). It is the caller's side's
147
+ assertion, trusted exactly as far as that side is: behind a gateway it is the gateway's view of the
148
+ agent it deployed; a caller that reaches the server directly chooses it in `AgentEnv-Role`. It says
149
+ whose data a call acts on, not that the caller was authenticated, so a server exposed to untrusted
150
+ direct callers keeps access control on its own boundary. `ctx.caller.session` tells
151
+ callers apart when the gateway shares one connection to the server. `ctx.tool`, `ctx.arguments`
152
+ and `ctx.call_id` describe the call. Code that runs on behalf
153
+ of the call without a parameter of its own, such as a database method, reads the same object through
154
+ `ToolContext.current()`, which returns an empty context outside any call. The binding is per call
155
+ and follows the request's task, so concurrent callers never see each other's context. It is made
156
+ at FastMCP's own tool dispatch, for which `mcp` 1.x offers no public hook, and the mount refuses an
157
+ app without one, so a server that mounted is a server whose tool calls are bound.
158
+
159
+ To answer a call before the tool runs, override `on_tool_call(self, context)`: return `None` to let
160
+ it proceed, or an `mcp.types.CallToolResult` (`isError=True` for a refusal) to send that as the
161
+ tool's reply. An `@extension` method may declare the same parameter; it is filled from the request's
162
+ `AgentEnv-Role` header. Code that acts as a caller outside any dispatch, such as a test calling a
163
+ handler directly or a seeding script, binds a context it builds:
164
+ `with bound(ToolContext.as_caller("alice")): ...`.
165
+
130
166
  ## Serving
131
167
 
132
168
  `serve()` builds the FastMCP app via `create_fastmcp_app()`, which encodes the agent-env deploy
@@ -83,6 +83,42 @@ environment-prefixed tools without knowing the name at class-definition time; an
83
83
  FastMCP; since those apps have no MCP tool registry, constructing one with `@tool` methods
84
84
  raises.
85
85
 
86
+ ### Who is calling
87
+
88
+ A tool that needs the caller declares a parameter annotated `ToolContext` (any name) and receives
89
+ it on every call. The SDK fills it, never the client: it is absent from the advertised
90
+ `inputSchema` and from `tools/list`. Tools that declare no such parameter are registered unchanged.
91
+
92
+ ```python
93
+ from agentenv_protocol import ToolContext
94
+
95
+ @tool(name="{environment_name}_list_inbox")
96
+ def list_inbox(self, ctx: ToolContext) -> list[dict]:
97
+ """Messages addressed to the caller."""
98
+ return [m for m in self.messages if m["to"] == ctx.caller.role]
99
+ ```
100
+
101
+ `ctx.caller.role` is the role the agent was deployed with, as the gateway forwarded it (`None` when
102
+ nothing was forwarded; the gateway's `default` role counts as nothing). It is the caller's side's
103
+ assertion, trusted exactly as far as that side is: behind a gateway it is the gateway's view of the
104
+ agent it deployed; a caller that reaches the server directly chooses it in `AgentEnv-Role`. It says
105
+ whose data a call acts on, not that the caller was authenticated, so a server exposed to untrusted
106
+ direct callers keeps access control on its own boundary. `ctx.caller.session` tells
107
+ callers apart when the gateway shares one connection to the server. `ctx.tool`, `ctx.arguments`
108
+ and `ctx.call_id` describe the call. Code that runs on behalf
109
+ of the call without a parameter of its own, such as a database method, reads the same object through
110
+ `ToolContext.current()`, which returns an empty context outside any call. The binding is per call
111
+ and follows the request's task, so concurrent callers never see each other's context. It is made
112
+ at FastMCP's own tool dispatch, for which `mcp` 1.x offers no public hook, and the mount refuses an
113
+ app without one, so a server that mounted is a server whose tool calls are bound.
114
+
115
+ To answer a call before the tool runs, override `on_tool_call(self, context)`: return `None` to let
116
+ it proceed, or an `mcp.types.CallToolResult` (`isError=True` for a refusal) to send that as the
117
+ tool's reply. An `@extension` method may declare the same parameter; it is filled from the request's
118
+ `AgentEnv-Role` header. Code that acts as a caller outside any dispatch, such as a test calling a
119
+ handler directly or a seeding script, binds a context it builds:
120
+ `with bound(ToolContext.as_caller("alice")): ...`.
121
+
86
122
  ## Serving
87
123
 
88
124
  `serve()` builds the FastMCP app via `create_fastmcp_app()`, which encodes the agent-env deploy
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.323"
3
+ version = "0.1.325"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -12,9 +12,20 @@ from .agent_env_environment import (
12
12
  environment_card,
13
13
  extension,
14
14
  get_data,
15
+ injecting,
15
16
  reset_data,
16
17
  tool,
17
18
  )
19
+ from .tool_context import (
20
+ DEFAULT_ROLE,
21
+ ROLE_HEADER,
22
+ ROLE_META_KEY,
23
+ SESSION_META_KEY,
24
+ Caller,
25
+ ToolContext,
26
+ bound,
27
+ normalize_role,
28
+ )
18
29
  from .manifest import (
19
30
  CLI_COMMANDS,
20
31
  GET_INTERFACES_EXTENSION_URI,
@@ -78,6 +89,15 @@ __all__ = [
78
89
  "get_data",
79
90
  "extension",
80
91
  "tool",
92
+ "injecting",
93
+ "ToolContext",
94
+ "Caller",
95
+ "normalize_role",
96
+ "bound",
97
+ "ROLE_META_KEY",
98
+ "SESSION_META_KEY",
99
+ "ROLE_HEADER",
100
+ "DEFAULT_ROLE",
81
101
  "EnvironmentCapabilities",
82
102
  "EnvironmentCard",
83
103
  "EnvironmentExtension",
@@ -1,13 +1,15 @@
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."""
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; a parameter annotated ToolContext receives the caller and is never advertised). 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
2
  from __future__ import annotations
3
3
 
4
4
  import datetime
5
5
  import decimal
6
6
  import enum
7
+ import functools
7
8
  import inspect
8
9
  import json
9
10
  import logging
10
11
  import os
12
+ import sys
11
13
  import types
12
14
  import uuid
13
15
  from abc import ABC, abstractmethod
@@ -19,6 +21,7 @@ from starlette.requests import Request
19
21
  from starlette.responses import Response
20
22
  from starlette.routing import Route
21
23
 
24
+ from .tool_context import EMPTY, ToolContext, bound, context_from_http, context_from_mcp
22
25
  from .transfers import WriteNamespaceGrant
23
26
  from .types import DATA_OBJECTS_EXTENSION_URI, MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataRequest, AddDataResponse, EnvironmentCapabilities, EnvironmentCard, EnvironmentExtension, EnvironmentInterface, EnvironmentTool, GetDataResponse, ResetDataResponse, error_body
24
27
 
@@ -29,6 +32,8 @@ _EXT_ATTR = "_aee_ext"
29
32
  _EXT_ROUTE_ATTR = "_aee_ext_route"
30
33
  _TOOL_ATTR = "_aee_tool"
31
34
  _CARD_CONFIG_ATTR = "_aee_card_config"
35
+ #: Set on the tool dispatch installed by ``_bind_tool_calls``; a guard downstream checks it at boot.
36
+ _TOOL_CONTEXT_BOUND_ATTR = "__agentenv_tool_context__"
32
37
 
33
38
  OP_RESET_DATA = "reset_data"
34
39
  OP_ADD_DATA = "add_data"
@@ -111,6 +116,11 @@ def tool(name: str | None = None, *, description: str | None = None) -> Callable
111
116
  the card and the registration. Requires a FastMCP-backed application (non-FastMCP apps raise
112
117
  at construction). Duplicate resolved names raise; a card-declared name wins the advertisement
113
118
  while the method is still registered.
119
+
120
+ A parameter annotated ``ToolContext`` (or ``Optional[ToolContext]``; any name) is filled by the
121
+ SDK with the caller of the current call and is omitted from the advertised ``inputSchema`` and
122
+ from tools/list; a FastMCP ``Context`` parameter is likewise omitted from the card while FastMCP
123
+ keeps injecting it.
114
124
  """
115
125
  if callable(name):
116
126
  raise TypeError("use @tool(...) with parentheses, not bare @tool")
@@ -173,6 +183,7 @@ class AgentEnvApplication(ABC):
173
183
  self._tools = [(advertised.get(descriptor.name, descriptor), fn) for descriptor, fn in tool_handlers]
174
184
 
175
185
  def add_routes_to_app(self, app: Any, *, rpc_url: str = RPC_PATH, card_url: str = WELL_KNOWN_PATH) -> None:
186
+ self._check_tool_binding(app)
176
187
  self.environment_card = self._card_for(app)
177
188
  self._add_route(app, rpc_url, ["POST"], self._dispatch)
178
189
  self._add_route(app, card_url, ["GET"], _card_handler(self.environment_card))
@@ -180,6 +191,9 @@ class AgentEnvApplication(ABC):
180
191
  self._add_route(app, path, [method], handler)
181
192
  for descriptor, fn in self._tools:
182
193
  self._register_tool(app, descriptor, fn)
194
+ # Last, so it is the outermost layer: a dispatch guard the handler installed before mounting
195
+ # then runs under the bound ToolContext instead of outside it.
196
+ self._bind_tool_calls(app)
183
197
 
184
198
  @abstractmethod
185
199
  def _add_route(self, app: Any, path: str, methods: list, handler: Callable) -> None:
@@ -188,18 +202,74 @@ class AgentEnvApplication(ABC):
188
202
  def _register_tool(self, app: Any, descriptor: EnvironmentTool, fn: Callable) -> None:
189
203
  raise NotImplementedError(f"{type(self).__name__} does not serve MCP tools")
190
204
 
205
+ def _check_tool_binding(self, app: Any) -> None:
206
+ """Everything that can refuse the mount, before the app is touched; nothing to refuse without a tool dispatch."""
207
+ return None
208
+
209
+ def _bind_tool_calls(self, app: Any) -> None:
210
+ """Frameworks with a tool dispatch chokepoint bind a ToolContext around every call there; the others serve no tools."""
211
+ return None
212
+
191
213
  def _card_for(self, app: Any) -> EnvironmentCard:
192
214
  return self.environment_card
193
215
 
194
216
 
195
217
  class AgentEnvFastMCPApplication(AgentEnvApplication):
196
218
  supports_tools: ClassVar[bool] = True
219
+ _hook: Callable | None = None
197
220
 
198
221
  def _add_route(self, app: Any, path: str, methods: list, handler: Callable) -> None:
199
222
  app.custom_route(path, methods=methods)(handler)
200
223
 
201
224
  def _register_tool(self, app: Any, descriptor: EnvironmentTool, fn: Callable) -> None:
202
- app.tool(name=descriptor.name, description=descriptor.description)(fn)
225
+ app.tool(name=descriptor.name, description=descriptor.description)(injecting(fn))
226
+
227
+ def _check_tool_binding(self, app: Any) -> None:
228
+ """Refuse before any route or tool is added: an app without the dispatch the binding wraps (its tool
229
+ calls would run unbound, which nothing reading ``ToolContext.current()`` can tell from an empty
230
+ caller), a dispatch this protocol already wraps (one handler per app, or the second hook would answer
231
+ the first handler's tools) and an ``on_tool_call`` of the wrong shape. The hook is kept for
232
+ ``_bind_tool_calls``."""
233
+ manager = getattr(app, "_tool_manager", None)
234
+ if not (callable(getattr(manager, "call_tool", None)) and callable(getattr(manager, "get_tool", None))):
235
+ raise RuntimeError(
236
+ "this app has no FastMCP tool dispatch to bind a ToolContext around "
237
+ "(mcp>=1.25,<2 keeps it at FastMCP._tool_manager); "
238
+ "an app that serves no tools mounts through AgentEnvStarletteApplication"
239
+ )
240
+ if getattr(manager.call_tool, _TOOL_CONTEXT_BOUND_ATTR, False):
241
+ raise RuntimeError("this FastMCP app already carries an AgentEnv tool dispatch; mount one handler per app")
242
+ self._hook = _tool_call_hook(self.handler)
243
+
244
+ def _bind_tool_calls(self, app: Any) -> None:
245
+ """Wrap the tool manager's ``call_tool``, which every tools/call resolves at call time, so each call
246
+ of a registered tool runs under its ToolContext and the handler's ``on_tool_call`` may answer
247
+ it first. An unknown tool name goes straight to the dispatch, whose error answers it. FastMCP
248
+ has no public hook for this (its low-level handler is ``FastMCP.call_tool``, bound at
249
+ construction), so the manager is the one private seam, required by ``_check_tool_binding``.
250
+
251
+ The installed callable carries ``__agentenv_tool_context__ = True`` and ``__wrapped__`` (the
252
+ dispatch it wraps): the handles a downstream guard uses to assert the ordering contract.
253
+ """
254
+ manager = app._tool_manager
255
+ inner = manager.call_tool
256
+ hook = self._hook
257
+
258
+ async def call_tool(name: str, arguments: dict, *args: Any, **kwargs: Any) -> Any:
259
+ if manager.get_tool(name) is None:
260
+ return await inner(name, arguments, *args, **kwargs)
261
+ context = kwargs.get("context", args[0] if args else None)
262
+ with bound(context_from_mcp(context, name, arguments)) as tool_context:
263
+ if hook is not None:
264
+ answer = await _maybe_await(hook(tool_context))
265
+ if answer is not None:
266
+ _check_call_tool_result(answer)
267
+ return answer
268
+ return await inner(name, arguments, *args, **kwargs)
269
+
270
+ functools.update_wrapper(call_tool, inner)
271
+ setattr(call_tool, _TOOL_CONTEXT_BOUND_ATTR, True)
272
+ manager.call_tool = call_tool
203
273
 
204
274
  def _card_for(self, app: Any) -> EnvironmentCard:
205
275
  """Declare the MCP endpoint only when the app says where it serves it; FastMCP-shaped targets may not."""
@@ -263,6 +333,16 @@ class AgentEnvEnvironment:
263
333
  self.mount(self.mcp)
264
334
  self.mcp.run(transport=transport)
265
335
 
336
+ def on_tool_call(self, context: ToolContext) -> Any:
337
+ """Runs before every tools/call on this environment's app, under the bound ``context``.
338
+
339
+ Return None to let the call proceed, or an ``mcp.types.CallToolResult`` (``isError=True`` for
340
+ a refusal) to answer the call without running the tool; it reaches the wire verbatim, before
341
+ output validation. Anything else raises TypeError. May be ``async``. Not invoked for
342
+ extension (REST) routes.
343
+ """
344
+ return None
345
+
266
346
 
267
347
  _SCALAR_TYPES = {str: "string", int: "integer", float: "number", bool: "boolean", list: "array", dict: "object"}
268
348
  _STRING_FORMATS = {datetime.datetime: "date-time", datetime.date: "date", datetime.time: "time", uuid.UUID: "uuid"}
@@ -307,19 +387,189 @@ def _schema_for_annotation(annotation: Any) -> dict:
307
387
  return {"type": json_type} if json_type is not None else {}
308
388
 
309
389
 
310
- def _schema_from_signature(fn: Callable) -> dict:
311
- """Derive a JSON-Schema object for a handler's call params from its signature (Annotated[Field] descriptions included)."""
312
- sig = inspect.signature(fn)
390
+ def _unwrap_annotation(annotation: Any) -> list:
391
+ """The concrete types an annotation names: Annotated metadata and None peeled off Optional/Union."""
392
+ origin = get_origin(annotation)
393
+ if origin is Annotated:
394
+ return _unwrap_annotation(get_args(annotation)[0])
395
+ if origin is Union or origin is getattr(types, "UnionType", None):
396
+ return [t for arg in get_args(annotation) if arg is not type(None) for t in _unwrap_annotation(arg)]
397
+ return [annotation]
398
+
399
+
400
+ def _split_top_level(text: str, sep: str) -> list:
401
+ parts: list = []
402
+ depth = 0
403
+ start = 0
404
+ for i, ch in enumerate(text):
405
+ if ch in "[(":
406
+ depth += 1
407
+ elif ch in "])":
408
+ depth -= 1
409
+ elif ch == sep and depth == 0:
410
+ parts.append(text[start:i])
411
+ start = i + 1
412
+ parts.append(text[start:])
413
+ return [p.strip() for p in parts]
414
+
415
+
416
+ def _string_annotation_names(text: str) -> list:
417
+ """The type names a string annotation spells out, unwrapped like ``_unwrap_annotation``: the fallback
418
+ under ``from __future__ import annotations`` when the function's hints cannot be resolved."""
419
+ text = text.strip().strip("'\"")
420
+ parts = _split_top_level(text, "|")
421
+ if len(parts) > 1:
422
+ return [n for part in parts for n in _string_annotation_names(part)]
423
+ head, bracket, rest = text.partition("[")
424
+ if bracket and text.endswith("]"):
425
+ args = _split_top_level(rest[:-1], ",")
426
+ wrapper = head.rsplit(".", 1)[-1]
427
+ if wrapper == "Annotated":
428
+ return _string_annotation_names(args[0])
429
+ if wrapper in ("Optional", "Union"):
430
+ return [n for arg in args for n in _string_annotation_names(arg)]
431
+ return [text]
432
+ return [] if text == "None" else [text.rsplit(".", 1)[-1]]
433
+
434
+
435
+ def _is_tool_context_annotation(annotation: Any) -> bool:
436
+ if isinstance(annotation, str):
437
+ return ToolContext.__name__ in _string_annotation_names(annotation)
438
+ return any(t is ToolContext for t in _unwrap_annotation(annotation))
439
+
440
+
441
+ def _fastmcp_context_type() -> type | None:
442
+ """FastMCP's Context class when mcp is loaded, else None. mcp is an optional dependency, so it is
443
+ looked up rather than imported; a Context-annotated handler has necessarily imported it already."""
444
+ return getattr(sys.modules.get("mcp.server.fastmcp.server"), "Context", None)
445
+
446
+
447
+ def _is_fastmcp_context_annotation(annotation: Any) -> bool:
448
+ context_type = _fastmcp_context_type()
449
+ if context_type is None:
450
+ return False
451
+ if isinstance(annotation, str):
452
+ return context_type.__name__ in _string_annotation_names(annotation)
453
+ return any(isinstance(t, type) and issubclass(t, context_type) for t in _unwrap_annotation(annotation))
454
+
455
+
456
+ def _resolved_hints(fn: Callable) -> dict:
313
457
  try:
314
- hints = get_type_hints(fn, include_extras=True)
458
+ return get_type_hints(fn, include_extras=True)
315
459
  except Exception:
316
- hints = {}
460
+ return {}
461
+
462
+
463
+ def _callable_name(fn: Callable) -> str:
464
+ return getattr(fn, "__qualname__", None) or getattr(fn, "__name__", None) or repr(fn)
465
+
466
+
467
+ def _tool_context_slot(fn: Callable) -> str | None:
468
+ """Name of the parameter annotated ``ToolContext`` / ``Optional[ToolContext]`` (any name), or None.
469
+ The slot is filled by name, so a positional-only one is refused here rather than on every call."""
470
+ hints = _resolved_hints(fn)
471
+ slots = []
472
+ for name, param in inspect.signature(fn).parameters.items():
473
+ if name in ("self", "cls") or param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
474
+ continue
475
+ if not _is_tool_context_annotation(hints.get(name, param.annotation)):
476
+ continue
477
+ if param.kind is inspect.Parameter.POSITIONAL_ONLY:
478
+ raise TypeError(f"{_callable_name(fn)}: ToolContext parameter {name!r} must not be positional-only")
479
+ slots.append(name)
480
+ if len(slots) > 1:
481
+ raise TypeError(f"{_callable_name(fn)} declares more than one ToolContext parameter: {slots}")
482
+ return slots[0] if slots else None
483
+
484
+
485
+ def _tool_call_hook(handler: Any) -> Callable | None:
486
+ """The handler's ``on_tool_call`` when it has one of its own (the base class's no-op does not count),
487
+ checked at mount to take the ToolContext as its one argument so a mismatch fails here, not per call."""
488
+ hook = getattr(handler, "on_tool_call", None)
489
+ if hook is None or getattr(type(handler), "on_tool_call", None) is AgentEnvEnvironment.on_tool_call:
490
+ return None
491
+ try:
492
+ inspect.signature(hook).bind(EMPTY)
493
+ except TypeError as e:
494
+ raise TypeError(f"{_callable_name(hook)} must take the ToolContext as its only argument") from e
495
+ return hook
496
+
497
+
498
+ def _reduced_signature(fn: Callable, slot: str) -> tuple:
499
+ """``fn``'s signature and annotations without ``slot``, every annotation resolved: the registry
500
+ reads ``__signature__`` verbatim and cannot evaluate a string left in it."""
501
+ sig = inspect.signature(fn)
502
+ hints = _resolved_hints(fn)
503
+ params = [p.replace(annotation=hints.get(p.name, p.annotation)) for p in sig.parameters.values() if p.name != slot]
504
+ returns = hints.get("return", sig.return_annotation)
505
+ unresolved = [p.name for p in params if isinstance(p.annotation, str)] + (["return"] if isinstance(returns, str) else [])
506
+ if unresolved:
507
+ raise TypeError(
508
+ f"{_callable_name(fn)}: cannot resolve the annotations of {unresolved}; "
509
+ "a function that declares a ToolContext parameter must have resolvable annotations"
510
+ )
511
+ annotations = {p.name: p.annotation for p in params if p.annotation is not inspect.Parameter.empty}
512
+ if returns is not inspect.Signature.empty:
513
+ annotations["return"] = returns
514
+ return sig.replace(parameters=params, return_annotation=returns), annotations
515
+
516
+
517
+ def _inject_tool_context(fn: Callable, slot: str) -> Callable:
518
+ signature, annotations = _reduced_signature(fn, slot)
519
+ full = inspect.signature(fn)
520
+
521
+ def filled(args: tuple, kwargs: dict) -> inspect.BoundArguments:
522
+ # Positional arguments mean what the twin advertises, then land where ``fn`` declares them.
523
+ call = full.bind_partial()
524
+ call.arguments.update(signature.bind(*args, **kwargs).arguments)
525
+ call.arguments[slot] = ToolContext.current()
526
+ return call
527
+
528
+ # Two bodies so the registry's sync/async detection sees the same kind as ``fn``.
529
+ if inspect.iscoroutinefunction(fn):
530
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
531
+ call = filled(args, kwargs)
532
+ return await fn(*call.args, **call.kwargs)
533
+ else:
534
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
535
+ call = filled(args, kwargs)
536
+ return fn(*call.args, **call.kwargs)
537
+ functools.update_wrapper(wrapper, fn)
538
+ wrapper.__signature__, wrapper.__annotations__ = signature, annotations
539
+ return wrapper
540
+
541
+
542
+ def injecting(fn: Callable) -> Callable:
543
+ """``fn`` as an MCP tool registry should see it: unchanged when it declares no ``ToolContext``
544
+ parameter, else a twin of the same name, doc and sync/async nature whose signature and annotations
545
+ omit that parameter and which fills it with ``ToolContext.current()`` on every call; positional
546
+ arguments follow the twin's signature, not ``fn``'s. ``@tool`` methods get this at mount; use it for a slotted function registered any other way
547
+ (``app.tool()(fn)``, a separate ``ToolManager``), which otherwise fails at registration."""
548
+ slot = _tool_context_slot(fn)
549
+ return _inject_tool_context(fn, slot) if slot else fn
550
+
551
+
552
+ def _check_call_tool_result(value: Any) -> None:
553
+ # The value goes to the wire as-is, so anything else would be emitted as a successful result.
554
+ result_type = getattr(sys.modules.get("mcp.types"), "CallToolResult", None)
555
+ if result_type is None or not isinstance(value, result_type):
556
+ raise TypeError(f"on_tool_call must return None or mcp.types.CallToolResult, got {type(value).__name__}")
557
+
558
+
559
+ def _schema_from_signature(fn: Callable) -> dict:
560
+ """Derive a JSON-Schema object for a handler's call params from its signature (Annotated[Field] descriptions
561
+ included). Injected parameters (``ToolContext``, FastMCP ``Context``) are not wire parameters and are left out."""
562
+ sig = inspect.signature(fn)
563
+ hints = _resolved_hints(fn)
317
564
  properties: dict = {}
318
565
  required: list = []
319
566
  for name, param in sig.parameters.items():
320
567
  if name in ("self", "cls") or param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
321
568
  continue
322
- prop = _schema_for_annotation(hints.get(name, param.annotation))
569
+ annotation = hints.get(name, param.annotation)
570
+ if _is_tool_context_annotation(annotation) or _is_fastmcp_context_annotation(annotation):
571
+ continue
572
+ prop = _schema_for_annotation(annotation)
323
573
  if param.default is inspect.Parameter.empty:
324
574
  required.append(name)
325
575
  else:
@@ -601,7 +851,15 @@ def _coerce_params(fn: Callable, params: dict) -> dict:
601
851
 
602
852
 
603
853
  def _extension_handler(fn: Callable, http_method: str) -> Callable:
604
- """REST handler for an @extension route: bind request params to the handler and return JSON."""
854
+ """REST handler for an @extension route: bind request params to the handler and return JSON. A
855
+ ``ToolContext`` parameter is filled from the request headers and never bound
856
+ from the params."""
857
+ slot = _tool_context_slot(fn)
858
+ signature = inspect.signature(fn)
859
+ if slot:
860
+ signature = signature.replace(parameters=[p for p in signature.parameters.values() if p.name != slot])
861
+ op = fn.__name__ or "invoke"
862
+
605
863
  async def handler(request: Request) -> Response:
606
864
  try:
607
865
  if http_method == "GET":
@@ -615,14 +873,16 @@ def _extension_handler(fn: Callable, http_method: str) -> Callable:
615
873
  except Exception as e:
616
874
  return _json_response(error_body("invalid_params", str(e)), 400)
617
875
  try:
618
- inspect.signature(fn).bind(**params)
876
+ signature.bind(**params)
619
877
  except TypeError as e:
620
878
  return _json_response(error_body("invalid_params", str(e)), 400)
621
- try:
622
- result = await _maybe_await(fn(**params))
623
- except Exception as e:
624
- logger.exception("extension invocation failed")
625
- return _json_response(error_body("extension_failed", str(e)), 500)
879
+ with bound(context_from_http(request, op, params)) as tool_context:
880
+ call_kwargs = {**params, slot: tool_context} if slot else params
881
+ try:
882
+ result = await _maybe_await(fn(**call_kwargs))
883
+ except Exception as e:
884
+ logger.exception("extension invocation failed")
885
+ return _json_response(error_body("extension_failed", str(e)), 500)
626
886
  return _json_response(result if result is not None else {})
627
887
  return handler
628
888