agentenv-framework-protocol 0.1.324__tar.gz → 0.1.326__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 (45) hide show
  1. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/PKG-INFO +4 -1
  2. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/README.md +2 -0
  3. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/pyproject.toml +2 -1
  4. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/agent_env_environment.py +91 -15
  5. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/tool_context.py +12 -2
  6. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_tool_context.py +1 -1
  7. agentenv_framework_protocol-0.1.326/tests/test_tool_context_fastmcp.py +224 -0
  8. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_tool_context_injection.py +9 -1
  9. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/.gitignore +0 -0
  10. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/LICENSE +0 -0
  11. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/NOTICE +0 -0
  12. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/THIRD_PARTY_NOTICES.md +0 -0
  13. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/examples/__init__.py +0 -0
  14. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/examples/advanced_agent.py +0 -0
  15. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/examples/basic_agent.py +0 -0
  16. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/examples/custom_extensions_agent.py +0 -0
  17. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/examples/multimodal_agent.py +0 -0
  18. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/examples/reference_model.py +0 -0
  19. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/examples/streaming_agent.py +0 -0
  20. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/__init__.py +0 -0
  21. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  22. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
  23. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  24. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
  25. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
  26. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  27. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  28. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  29. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  30. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/client.py +0 -0
  31. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/manifest.py +0 -0
  32. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/preflight.py +0 -0
  33. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/transfers.py +0 -0
  34. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/types.py +0 -0
  35. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_a2a_agent.py +0 -0
  36. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_a2a_agent_examples.py +0 -0
  37. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_a2a_object_transfers.py +0 -0
  38. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_a2a_staging.py +0 -0
  39. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_a2a_trajectory_log.py +0 -0
  40. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_agent_env_environment.py +0 -0
  41. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_client.py +0 -0
  42. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_data_objects.py +0 -0
  43. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_intake.py +0 -0
  44. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/tests/test_manifest.py +0 -0
  45. {agentenv_framework_protocol-0.1.324 → agentenv_framework_protocol-0.1.326}/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.324
3
+ Version: 0.1.326
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
@@ -35,6 +35,7 @@ Requires-Dist: regex>=2023.12.25; extra == 'agent'
35
35
  Requires-Dist: uvicorn>=0.30; extra == 'agent'
36
36
  Provides-Extra: dev
37
37
  Requires-Dist: a2a-sdk[http-server]<0.4,>=0.3.26; extra == 'dev'
38
+ Requires-Dist: fastmcp<4,>=3.4; extra == 'dev'
38
39
  Requires-Dist: mcp<2,>=1.25; extra == 'dev'
39
40
  Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
40
41
  Requires-Dist: pytest>=9.0; extra == 'dev'
@@ -213,6 +214,8 @@ extension's, and `client.invoke_extension(base_url, card, uri, params, method=..
213
214
  its HTTP verb. Without `method`, `invoke_extension` calls the first method listed; an extension
214
215
  with several methods, such as a gateway's `urn:agentenv:clock/v1`, should always be called by name.
215
216
 
217
+ Where the binding lives depends on the app. On mcp's own FastMCP it wraps the tool manager's dispatch; on the standalone `fastmcp` package it joins the app's middleware chain as the outermost layer, so a handler's own middleware runs under it too. An app with neither seam mounts unbound, with a warning, unless a tool declares a `ToolContext` parameter or the handler defines `on_tool_call`: then the mount is refused rather than serving an empty caller.
218
+
216
219
  ### Uploading an export through a grant
217
220
 
218
221
  An export too large to return from `data/get` can go straight to the caller's object store. A
@@ -169,6 +169,8 @@ extension's, and `client.invoke_extension(base_url, card, uri, params, method=..
169
169
  its HTTP verb. Without `method`, `invoke_extension` calls the first method listed; an extension
170
170
  with several methods, such as a gateway's `urn:agentenv:clock/v1`, should always be called by name.
171
171
 
172
+ Where the binding lives depends on the app. On mcp's own FastMCP it wraps the tool manager's dispatch; on the standalone `fastmcp` package it joins the app's middleware chain as the outermost layer, so a handler's own middleware runs under it too. An app with neither seam mounts unbound, with a warning, unless a tool declares a `ToolContext` parameter or the handler defines `on_tool_call`: then the mount is refused rather than serving an empty caller.
173
+
172
174
  ### Uploading an export through a grant
173
175
 
174
176
  An export too large to return from `data/get` can go straight to the caller's object store. A
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.324"
3
+ version = "0.1.326"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -41,6 +41,7 @@ dev = [
41
41
  "pytest>=9.0",
42
42
  "pytest-asyncio>=0.23.0",
43
43
  "mcp>=1.25,<2",
44
+ "fastmcp>=3.4,<4", # the standalone-fastmcp mount path
44
45
  ]
45
46
  [project.urls]
46
47
  Homepage = "https://www.agentenvframework.com"
@@ -217,6 +217,10 @@ class AgentEnvApplication(ABC):
217
217
  class AgentEnvFastMCPApplication(AgentEnvApplication):
218
218
  supports_tools: ClassVar[bool] = True
219
219
  _hook: Callable | None = None
220
+ #: How this app's tool calls get their ToolContext, decided by ``_check_tool_binding``: ``"dispatch"`` wraps
221
+ #: mcp FastMCP's tool manager, ``"middleware"`` joins a fastmcp app's middleware chain, None leaves them unbound.
222
+ _binding: str | None = None
223
+ _middleware: Any = None
220
224
 
221
225
  def _add_route(self, app: Any, path: str, methods: list, handler: Callable) -> None:
222
226
  app.custom_route(path, methods=methods)(handler)
@@ -225,23 +229,53 @@ class AgentEnvFastMCPApplication(AgentEnvApplication):
225
229
  app.tool(name=descriptor.name, description=descriptor.description)(injecting(fn))
226
230
 
227
231
  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``."""
232
+ """Decide, before any route or tool is added, how every tools/call on ``app`` gets its ToolContext, and
233
+ refuse what cannot be honoured. The standalone ``fastmcp`` package (a ``middleware`` list with
234
+ ``add_middleware``) is joined through that public chain, checked first because its 2.x line also keeps a
235
+ tool manager whose dispatch has another shape; mcp's own FastMCP (``mcp>=1.25,<2``) has no public hook,
236
+ so its tool manager's ``call_tool`` is wrapped. An app with neither cannot bind: mounting it is refused
237
+ when the handler declared that it reads the context (a ``ToolContext`` parameter or an ``on_tool_call``),
238
+ since an empty caller would then be a lie nothing can detect, and goes ahead unbound, with a warning,
239
+ otherwise. A dispatch this protocol already wraps is refused either way (one handler per app, or the
240
+ second hook would answer the first handler's tools), an ``on_tool_call`` of the wrong shape fails here,
241
+ not per call, and so does a fastmcp that cannot be imported: nothing has been added to the app yet."""
242
+ self._hook = _tool_call_hook(self.handler)
243
+ middleware = getattr(app, "middleware", None)
244
+ if isinstance(middleware, list) and callable(getattr(app, "add_middleware", None)):
245
+ if any(getattr(entry, _TOOL_CONTEXT_BOUND_ATTR, False) for entry in middleware):
246
+ raise RuntimeError("this fastmcp app already carries an AgentEnv tool-context middleware; mount one handler per app")
247
+ self._middleware = _tool_context_middleware(self._hook)
248
+ self._binding = "middleware"
249
+ return
233
250
  manager = getattr(app, "_tool_manager", None)
234
- if not (callable(getattr(manager, "call_tool", None)) and callable(getattr(manager, "get_tool", None))):
251
+ if callable(getattr(manager, "call_tool", None)) and callable(getattr(manager, "get_tool", None)):
252
+ if getattr(manager.call_tool, _TOOL_CONTEXT_BOUND_ATTR, False):
253
+ raise RuntimeError("this FastMCP app already carries an AgentEnv tool dispatch; mount one handler per app")
254
+ self._binding = "dispatch"
255
+ return
256
+ readers = [descriptor.name for descriptor, fn in self._tools if _tool_context_slot(fn)]
257
+ if readers or self._hook is not None:
235
258
  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"
259
+ "this app has no tool dispatch to bind a ToolContext around (mcp>=1.25,<2 keeps one at "
260
+ f"FastMCP._tool_manager; fastmcp exposes a middleware list), and {', '.join(readers) or 'on_tool_call'} "
261
+ "would read an empty caller"
239
262
  )
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)
263
+ self._binding = None
264
+ logger.warning(
265
+ "%s.%s (environment %r): no tool dispatch to bind a ToolContext around, so its tool calls run with an "
266
+ "empty caller; build the app with create_fastmcp_app or on fastmcp to bind one",
267
+ type(app).__module__, type(app).__qualname__, self.environment_card.name,
268
+ )
243
269
 
244
270
  def _bind_tool_calls(self, app: Any) -> None:
271
+ """Bind as ``_check_tool_binding`` decided. First in fastmcp's middleware list runs outermost, so a
272
+ handler's own middleware and the tool both run under the binding, the same layering as the wrap."""
273
+ if self._binding == "dispatch":
274
+ self._bind_tool_dispatch(app)
275
+ elif self._binding == "middleware":
276
+ app.middleware.insert(0, self._middleware)
277
+
278
+ def _bind_tool_dispatch(self, app: Any) -> None:
245
279
  """Wrap the tool manager's ``call_tool``, which every tools/call resolves at call time, so each call
246
280
  of a registered tool runs under its ToolContext and the handler's ``on_tool_call`` may answer
247
281
  it first. An unknown tool name goes straight to the dispatch, whose error answers it. FastMCP
@@ -337,9 +371,10 @@ class AgentEnvEnvironment:
337
371
  """Runs before every tools/call on this environment's app, under the bound ``context``.
338
372
 
339
373
  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.
374
+ a refusal) to answer the call without running the tool. On mcp's FastMCP it reaches the wire
375
+ verbatim, before output validation; on a fastmcp app it passes through fastmcp's output validation,
376
+ so a non-error answer there must fit the tool's declared output, while a refusal passes unchanged.
377
+ Anything else raises TypeError. May be ``async``. Not invoked for extension (REST) routes.
343
378
  """
344
379
  return None
345
380
 
@@ -556,6 +591,47 @@ def _check_call_tool_result(value: Any) -> None:
556
591
  raise TypeError(f"on_tool_call must return None or mcp.types.CallToolResult, got {type(value).__name__}")
557
592
 
558
593
 
594
+ def _tool_context_middleware(hook: Callable | None) -> Any:
595
+ """A fastmcp ``Middleware`` that runs every tools/call under its ToolContext, the handler's ``on_tool_call``
596
+ first. A ``Middleware`` subclass rather than a bare ``(context, call_next)`` callable because fastmcp runs the
597
+ typed ``on_call_tool`` exactly once per call, where a bare callable runs once per dispatch phase. fastmcp is
598
+ not a dependency of this package: it is imported here, where an app built with it has just been handed over."""
599
+ try:
600
+ from fastmcp.server.middleware import Middleware
601
+ except ImportError as e:
602
+ raise RuntimeError("this app carries a fastmcp middleware list but fastmcp cannot be imported") from e
603
+
604
+ class ToolContextMiddleware(Middleware):
605
+ async def on_call_tool(self, context: Any, call_next: Callable) -> Any:
606
+ message = context.message
607
+ arguments = dict(getattr(message, "arguments", None) or {})
608
+ with bound(context_from_mcp(context.fastmcp_context, message.name, arguments)) as tool_context:
609
+ if hook is not None:
610
+ answer = await _maybe_await(hook(tool_context))
611
+ if answer is not None:
612
+ _check_call_tool_result(answer)
613
+ return _fastmcp_tool_result(answer)
614
+ return await call_next(context)
615
+
616
+ middleware = ToolContextMiddleware()
617
+ setattr(middleware, _TOOL_CONTEXT_BOUND_ATTR, True)
618
+ return middleware
619
+
620
+
621
+ def _fastmcp_tool_result(answer: Any) -> Any:
622
+ """A hook's ``mcp.types.CallToolResult`` as the ``ToolResult`` a fastmcp middleware chain returns, the error
623
+ flag kept, under the field spellings of mcp 1 and 2."""
624
+ from fastmcp.tools import ToolResult
625
+ # mcp 2's spellings first: on mcp 1 they are plain misses, on mcp 2 the camel-case ones are deprecation shims.
626
+ structured = getattr(answer, "structured_content", None)
627
+ if structured is None:
628
+ structured = getattr(answer, "structuredContent", None)
629
+ is_error = getattr(answer, "is_error", None)
630
+ if is_error is None:
631
+ is_error = getattr(answer, "isError", False)
632
+ return ToolResult(content=list(answer.content), structured_content=structured, is_error=bool(is_error))
633
+
634
+
559
635
  def _schema_from_signature(fn: Callable) -> dict:
560
636
  """Derive a JSON-Schema object for a handler's call params from its signature (Annotated[Field] descriptions
561
637
  included). Injected parameters (``ToolContext``, FastMCP ``Context``) are not wire parameters and are left out."""
@@ -185,14 +185,24 @@ def context_from_mcp(context: Any, tool: str, arguments: Mapping[str, Any]) -> T
185
185
  request_context = context.request_context
186
186
  except (ValueError, AttributeError):
187
187
  request_context = None
188
- meta = getattr(request_context, "meta", None)
189
- extra = getattr(meta, "model_extra", None) if meta is not None else None
188
+ extra = _meta_entries(getattr(request_context, "meta", None))
190
189
  request = getattr(request_context, "request", None)
191
190
  headers = getattr(request, "headers", None) if request is not None else None
192
191
  return ToolContext(caller=caller_from_request(extra, headers), tool=tool, arguments=_frozen(arguments),
193
192
  call_id=uuid.uuid4().hex)
194
193
 
195
194
 
195
+ def _meta_entries(meta: Any) -> Optional[Mapping[str, Any]]:
196
+ """A request's custom ``_meta`` entries: pydantic's ``model_extra`` where the SDK models them (mcp 1), the
197
+ mapping itself where it hands them over as a dict (mcp 2)."""
198
+ if meta is None:
199
+ return None
200
+ extra = getattr(meta, "model_extra", None)
201
+ if extra is not None:
202
+ return extra
203
+ return meta if isinstance(meta, Mapping) else None
204
+
205
+
196
206
  def context_from_http(request: Any, tool: str, arguments: Mapping[str, Any]) -> ToolContext:
197
207
  """The context for a REST or extension call: the caller from the request headers only."""
198
208
  headers = getattr(request, "headers", None) if request is not None else None
@@ -286,7 +286,7 @@ def test_injecting_raises_when_a_slotted_function_has_unresolvable_annotations()
286
286
  def test_target_without_a_tool_dispatch_is_refused_before_any_route_is_added(strip):
287
287
  mcp = _FakeMCP()
288
288
  strip(mcp)
289
- with pytest.raises(RuntimeError, match="no FastMCP tool dispatch"):
289
+ with pytest.raises(RuntimeError, match="no tool dispatch .* whoami, whoami_sync would read an empty caller"):
290
290
  AgentEnvFastMCPApplication(environment_card=EnvironmentCard(name="items"), handler=_SlotHandler()).add_routes_to_app(mcp)
291
291
  assert mcp.routes == [] and mcp.tools == {}
292
292
 
@@ -0,0 +1,224 @@
1
+ """ToolContext on the standalone ``fastmcp`` package, the way a plugin mounts it: no ``_tool_manager`` seam, so the
2
+ protocol joins the app's middleware chain, outermost. The hook, both role sources, the per-call reset and the
3
+ one-handler-per-app refusal hold as on mcp's FastMCP; an app with neither seam mounts unbound unless a reader is
4
+ declared."""
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import logging
9
+
10
+ import anyio
11
+ import httpx
12
+ import pytest
13
+
14
+ fastmcp = pytest.importorskip("fastmcp")
15
+ from fastmcp import Client, FastMCP # noqa: E402
16
+ from fastmcp.exceptions import ToolError # noqa: E402
17
+ from fastmcp.server.middleware import Middleware # noqa: E402
18
+ from mcp import ClientSession # noqa: E402
19
+ from mcp.client import streamable_http as _streamable_http # noqa: E402
20
+ from mcp.types import CallToolResult, TextContent # noqa: E402
21
+
22
+ from agentenv_protocol import ( # noqa: E402
23
+ ROLE_HEADER,
24
+ ROLE_META_KEY,
25
+ AgentEnvEnvironment,
26
+ AgentEnvFastMCPApplication,
27
+ Caller,
28
+ EnvironmentCard,
29
+ ToolContext,
30
+ environment_card,
31
+ tool,
32
+ )
33
+ from agentenv_protocol.agent_env_environment import _TOOL_CONTEXT_BOUND_ATTR # noqa: E402
34
+ from agentenv_protocol.tool_context import EMPTY # noqa: E402
35
+
36
+
37
+ class _Budget(Middleware):
38
+ """A handler's own middleware, as a plugin installs one: records the caller it sees, refuses past a budget."""
39
+
40
+ def __init__(self, budget: int = 100) -> None:
41
+ self.seen: list = []
42
+ self.budget = budget
43
+
44
+ async def on_call_tool(self, context, call_next):
45
+ self.seen.append(ToolContext.current().caller.role)
46
+ if len(self.seen) > self.budget:
47
+ raise ToolError("budget spent")
48
+ return await call_next(context)
49
+
50
+
51
+ @environment_card(name="fm")
52
+ class _Env(AgentEnvEnvironment):
53
+ def __init__(self) -> None:
54
+ self.hook_roles: list = []
55
+ self.seen: list = []
56
+
57
+ def on_tool_call(self, context: ToolContext):
58
+ self.hook_roles.append(context.caller.role)
59
+ if context.caller.role == "intruder":
60
+ return CallToolResult(isError=True, content=[TextContent(type="text", text="refused: intruder")])
61
+ return None
62
+
63
+ @tool()
64
+ def whoami_sync(self, ctx: ToolContext) -> str:
65
+ self.seen.append(ctx)
66
+ return f"{ctx.caller.role or '-'}|{ToolContext.current().caller.role or '-'}"
67
+
68
+ @tool()
69
+ async def whoami_async(self) -> str:
70
+ await asyncio.sleep(0)
71
+ self.seen.append(ToolContext.current())
72
+ return ToolContext.current().caller.role or "-"
73
+
74
+
75
+ @environment_card(name="plain")
76
+ class _PlainEnv(AgentEnvEnvironment):
77
+ @tool()
78
+ def echo(self, q: str) -> str:
79
+ return q
80
+
81
+
82
+ @environment_card(name="reader")
83
+ class _ReaderEnv(AgentEnvEnvironment):
84
+ @tool()
85
+ def whoami(self, ctx: ToolContext) -> str:
86
+ return ctx.caller.role or "-"
87
+
88
+
89
+ @environment_card(name="hooked")
90
+ class _HookedEnv(_PlainEnv):
91
+ def on_tool_call(self, context: ToolContext):
92
+ return None
93
+
94
+
95
+ class _NoSeamApp:
96
+ """An app with the generic surfaces only: no tool manager, no middleware list."""
97
+
98
+ def __init__(self) -> None:
99
+ self.routes: list = []
100
+ self.tools: dict = {}
101
+
102
+ def custom_route(self, path, methods):
103
+ def register(fn):
104
+ self.routes.append((path, tuple(methods), fn))
105
+ return fn
106
+ return register
107
+
108
+ def tool(self, name, description):
109
+ def register(fn):
110
+ self.tools[name] = fn
111
+ return fn
112
+ return register
113
+
114
+
115
+ class _ForeignListApp(_NoSeamApp):
116
+ """Not fastmcp: a ``middleware`` list without ``add_middleware`` is somebody else's list."""
117
+
118
+ def __init__(self) -> None:
119
+ super().__init__()
120
+ self.middleware: list = []
121
+
122
+
123
+ def _text(result) -> str:
124
+ return result.content[0].text
125
+
126
+
127
+ def _is_error(result) -> bool:
128
+ return bool(getattr(result, "is_error", getattr(result, "isError", False)))
129
+
130
+
131
+ @pytest.mark.asyncio
132
+ async def test_mount_joins_the_middleware_chain_outermost_and_binds_every_call():
133
+ budget = _Budget()
134
+ app = FastMCP("fm", middleware=[budget])
135
+ env = _Env()
136
+ env.mount(app)
137
+ assert getattr(app.middleware[0], _TOOL_CONTEXT_BOUND_ATTR, False) is True and app.middleware[1] is budget
138
+ with anyio.fail_after(30):
139
+ async with Client(app) as client:
140
+ alice = await client.call_tool("whoami_sync", {}, meta={ROLE_META_KEY: "alice"})
141
+ bob = await client.call_tool("whoami_async", {}, meta={ROLE_META_KEY: "bob"})
142
+ nobody = await client.call_tool("whoami_sync", {})
143
+ blank = await client.call_tool("whoami_async", {}, meta={ROLE_META_KEY: "default"})
144
+ assert (_text(alice), _text(bob), _text(nobody), _text(blank)) == ("alice|alice", "bob", "-|-", "-")
145
+ assert budget.seen == ["alice", "bob", None, None], "the handler's own middleware runs under the binding"
146
+ assert env.hook_roles == ["alice", "bob", None, None]
147
+ assert [c.tool for c in env.seen] == ["whoami_sync", "whoami_async", "whoami_sync", "whoami_async"]
148
+ assert env.seen[0].arguments == {} and env.seen[0].call_id
149
+ assert ToolContext.current() is EMPTY
150
+
151
+
152
+ @pytest.mark.asyncio
153
+ async def test_the_hook_answers_a_call_before_the_tool_runs():
154
+ app = FastMCP("fm")
155
+ env = _Env()
156
+ env.mount(app)
157
+ with anyio.fail_after(30):
158
+ async with Client(app) as client:
159
+ refused = await client.call_tool("whoami_sync", {}, meta={ROLE_META_KEY: "intruder"}, raise_on_error=False)
160
+ served = await client.call_tool("whoami_sync", {}, meta={ROLE_META_KEY: "carol"})
161
+ assert _is_error(refused) and _text(refused) == "refused: intruder"
162
+ assert [c.caller.role for c in env.seen] == ["carol"], "the refused call never reached the tool"
163
+ assert _text(served) == "carol|carol"
164
+ assert env.hook_roles == ["intruder", "carol"]
165
+
166
+
167
+ @pytest.mark.asyncio
168
+ @pytest.mark.skipif(not hasattr(_streamable_http, "streamable_http_client"), reason="mcp without streamable_http_client")
169
+ async def test_a_direct_caller_over_http_gets_the_header_role_and_its_own_session():
170
+ app = FastMCP("fm")
171
+ env = _Env()
172
+ env.mount(app)
173
+ asgi = app.http_app(path="/mcp", json_response=True)
174
+ base = "http://127.0.0.1:8000"
175
+ client = httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi), base_url=base, headers={ROLE_HEADER: "grader"})
176
+ with anyio.fail_after(30):
177
+ async with asgi.router.lifespan_context(asgi), client:
178
+ async with _streamable_http.streamable_http_client(f"{base}/mcp", http_client=client) as (read, write, session_id):
179
+ async with ClientSession(read, write) as session:
180
+ await session.initialize()
181
+ direct = await session.call_tool("whoami_sync", {})
182
+ proxied = await session.call_tool("whoami_sync", {}, meta={ROLE_META_KEY: "bob"})
183
+ sid = session_id()
184
+ assert sid and direct.content[0].text == "grader|grader" and proxied.content[0].text == "bob|bob"
185
+ assert env.seen[-2].caller == Caller("grader", sid)
186
+ assert env.seen[-1].caller == Caller("bob") # a _meta role entry: the connection's id is not the caller's
187
+
188
+
189
+ @pytest.mark.asyncio
190
+ async def test_concurrent_callers_keep_their_own_context():
191
+ app = FastMCP("fm")
192
+ env = _Env()
193
+ env.mount(app)
194
+ roles = [f"agent-{i}" for i in range(24)]
195
+ with anyio.fail_after(60):
196
+ async with Client(app) as client:
197
+ results = await asyncio.gather(
198
+ *(client.call_tool("whoami_async", {}, meta={ROLE_META_KEY: role}) for role in roles))
199
+ assert [_text(r) for r in results] == roles
200
+ assert sorted(env.hook_roles) == sorted(roles)
201
+ assert ToolContext.current() is EMPTY
202
+
203
+
204
+ def test_one_handler_per_fastmcp_app():
205
+ app = FastMCP("fm")
206
+ _Env().mount(app)
207
+ with pytest.raises(RuntimeError, match="already carries"):
208
+ AgentEnvFastMCPApplication(EnvironmentCard(name="second"), _PlainEnv()).add_routes_to_app(app)
209
+ assert sum(1 for entry in app.middleware if getattr(entry, _TOOL_CONTEXT_BOUND_ATTR, False)) == 1
210
+
211
+
212
+ def test_an_app_with_no_seam_mounts_unbound_unless_a_reader_is_declared(caplog):
213
+ app = _NoSeamApp()
214
+ with caplog.at_level(logging.WARNING, logger="agentenv_protocol.agent_env_environment"):
215
+ _PlainEnv().mount(app)
216
+ assert "echo" in app.tools and app.tools["echo"]("hi") == "hi"
217
+ assert "empty caller" in caplog.text
218
+ for env, reader in ((_ReaderEnv(), "whoami"), (_HookedEnv(), "on_tool_call")):
219
+ with pytest.raises(RuntimeError, match=reader):
220
+ env.mount(_NoSeamApp())
221
+ foreign = _ForeignListApp()
222
+ with pytest.raises(RuntimeError, match="whoami"):
223
+ _ReaderEnv().mount(foreign)
224
+ assert foreign.middleware == [] and foreign.tools == {}, "a foreign list is not a seam, and nothing was added"
@@ -38,7 +38,7 @@ from agentenv_protocol import ( # noqa: E402
38
38
  tool,
39
39
  )
40
40
  from agentenv_protocol.agent_env_environment import _is_fastmcp_context_annotation, _schema_from_signature # noqa: E402
41
- from agentenv_protocol.tool_context import EMPTY, bound # noqa: E402
41
+ from agentenv_protocol.tool_context import EMPTY, bound, context_from_mcp # noqa: E402
42
42
 
43
43
 
44
44
  def _text(result: Any) -> str:
@@ -174,6 +174,14 @@ async def test_concurrent_calls_with_different_meta_roles_never_cross():
174
174
  assert ToolContext.current() is EMPTY
175
175
 
176
176
 
177
+ def test_a_dict_meta_is_read_as_the_custom_entries():
178
+ """mcp 2 hands ``_meta`` over as a dict rather than a model with ``model_extra``."""
179
+ as_dict = SimpleNamespace(request_context=SimpleNamespace(meta={ROLE_META_KEY: "zed", SESSION_META_KEY: "s"}, request=None))
180
+ assert context_from_mcp(as_dict, "t", {}).caller == Caller("zed", "s")
181
+ assert context_from_mcp(_request_context({ROLE_META_KEY: "zed"}), "t", {}).caller == Caller("zed")
182
+ assert context_from_mcp(SimpleNamespace(request_context=SimpleNamespace(meta=42, request=None)), "t", {}).caller == Caller()
183
+
184
+
177
185
  @pytest.mark.asyncio
178
186
  async def test_header_fallback_and_default_blank_roles():
179
187
  env = _Env()