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.
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/PKG-INFO +37 -1
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/README.md +36 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/pyproject.toml +1 -1
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/__init__.py +20 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/agent_env_environment.py +275 -15
- agentenv_framework_protocol-0.1.325/src/agentenv_protocol/tool_context.py +200 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_agent_env_environment.py +6 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_data_objects.py +13 -0
- agentenv_framework_protocol-0.1.325/tests/test_tool_context.py +394 -0
- agentenv_framework_protocol-0.1.325/tests/test_tool_context_injection.py +479 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/.gitignore +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/LICENSE +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/NOTICE +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/THIRD_PARTY_NOTICES.md +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/advanced_agent.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/basic_agent.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/custom_extensions_agent.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/multimodal_agent.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/reference_model.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/examples/streaming_agent.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/client.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/manifest.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/preflight.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/transfers.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/src/agentenv_protocol/types.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_a2a_agent.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_a2a_agent_examples.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_a2a_object_transfers.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_a2a_staging.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_a2a_trajectory_log.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_client.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_intake.py +0 -0
- {agentenv_framework_protocol-0.1.323 → agentenv_framework_protocol-0.1.325}/tests/test_manifest.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
311
|
-
"""
|
|
312
|
-
|
|
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
|
-
|
|
458
|
+
return get_type_hints(fn, include_extras=True)
|
|
315
459
|
except Exception:
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
876
|
+
signature.bind(**params)
|
|
619
877
|
except TypeError as e:
|
|
620
878
|
return _json_response(error_body("invalid_params", str(e)), 400)
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
|