agentenv-framework-protocol 0.1.325__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.
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/PKG-INFO +4 -1
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/README.md +2 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/pyproject.toml +2 -1
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/agent_env_environment.py +91 -15
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/tool_context.py +12 -2
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_tool_context.py +1 -1
- agentenv_framework_protocol-0.1.326/tests/test_tool_context_fastmcp.py +224 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_tool_context_injection.py +9 -1
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/.gitignore +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/LICENSE +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/NOTICE +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/THIRD_PARTY_NOTICES.md +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/examples/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/examples/advanced_agent.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/examples/basic_agent.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/examples/custom_extensions_agent.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/examples/multimodal_agent.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/examples/reference_model.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/examples/streaming_agent.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/client.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/preflight.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/transfers.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/src/agentenv_protocol/types.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_a2a_agent.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_a2a_agent_examples.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_a2a_object_transfers.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_a2a_staging.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_a2a_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_agent_env_environment.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_client.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_data_objects.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_intake.py +0 -0
- {agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_manifest.py +0 -0
- {agentenv_framework_protocol-0.1.325 → 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.
|
|
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.
|
|
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
|
-
"""
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
``
|
|
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
|
|
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
|
|
237
|
-
"
|
|
238
|
-
"
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
341
|
-
output validation
|
|
342
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/THIRD_PARTY_NOTICES.md
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/examples/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/examples/basic_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_a2a_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_client.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_intake.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_manifest.py
RENAMED
|
File without changes
|
{agentenv_framework_protocol-0.1.325 → agentenv_framework_protocol-0.1.326}/tests/test_preflight.py
RENAMED
|
File without changes
|