agentenv-framework-protocol 0.1.286__tar.gz → 0.1.289__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 (39) hide show
  1. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/PKG-INFO +30 -1
  2. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/README.md +29 -0
  3. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/pyproject.toml +1 -1
  4. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/__init__.py +6 -0
  5. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/agent_env_environment.py +37 -2
  6. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/client.py +8 -2
  7. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/types.py +33 -1
  8. agentenv_framework_protocol-0.1.289/tests/test_data_objects.py +249 -0
  9. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/.gitignore +0 -0
  10. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/LICENSE +0 -0
  11. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/NOTICE +0 -0
  12. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/THIRD_PARTY_NOTICES.md +0 -0
  13. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/examples/__init__.py +0 -0
  14. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/examples/advanced_agent.py +0 -0
  15. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/examples/basic_agent.py +0 -0
  16. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/examples/custom_extensions_agent.py +0 -0
  17. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/examples/multimodal_agent.py +0 -0
  18. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/examples/reference_model.py +0 -0
  19. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/examples/streaming_agent.py +0 -0
  20. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  21. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  22. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
  23. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
  24. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  25. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  26. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  27. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  28. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/manifest.py +0 -0
  29. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/preflight.py +0 -0
  30. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/src/agentenv_protocol/transfers.py +0 -0
  31. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/tests/test_a2a_agent.py +0 -0
  32. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/tests/test_a2a_agent_examples.py +0 -0
  33. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/tests/test_a2a_object_transfers.py +0 -0
  34. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/tests/test_a2a_staging.py +0 -0
  35. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/tests/test_agent_env_environment.py +0 -0
  36. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/tests/test_client.py +0 -0
  37. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/tests/test_intake.py +0 -0
  38. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/tests/test_manifest.py +0 -0
  39. {agentenv_framework_protocol-0.1.286 → agentenv_framework_protocol-0.1.289}/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.286
3
+ Version: 0.1.289
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
@@ -177,6 +177,35 @@ extension's, and `client.invoke_extension(base_url, card, uri, params, method=..
177
177
  its HTTP verb. Without `method`, `invoke_extension` calls the first method listed; an extension
178
178
  with several methods, such as a gateway's `urn:agentenv:clock/v1`, should always be called by name.
179
179
 
180
+ ### Uploading an export through a grant
181
+
182
+ An export too large to return from `data/get` can go straight to the caller's object store. A
183
+ `@get_data` handler that takes a `write_namespace` parameter is advertised under
184
+ `urn:agentenv:data-objects/v1` (`DATA_OBJECTS_EXTENSION_URI`), and a caller may then send a
185
+ `transfers.WriteNamespaceGrant` with the call: `client.get_data(base_url,
186
+ write_namespace=grant)`. The handler is called with the grant, or with None when the caller
187
+ sent none. It uploads under the grant with `transfers.NamespaceUploader` and answers with
188
+ `uploaded_file_part(path, name=..., mime_type=...)`, where `path` is relative to the grant's
189
+ `root_path`; the caller reads it back with `uploaded_object_path(part)`, which refuses a path
190
+ outside the root. A handler that cannot use the grant (its export outgrows the grant's limits,
191
+ say) answers as it would without one.
192
+
193
+ ```python
194
+ from agentenv_protocol import AgentEnvEnvironment, DataPart, environment_card, get_data, uploaded_file_part
195
+ from agentenv_protocol.transfers import NamespaceUploader, WriteNamespaceGrant
196
+
197
+
198
+ @environment_card(name="slack")
199
+ class SlackEnv(AgentEnvEnvironment):
200
+ @get_data
201
+ async def _state(self, write_namespace: WriteNamespaceGrant | None = None):
202
+ bundle = self.write_bundle() # a Path
203
+ if write_namespace is not None and bundle.stat().st_size <= write_namespace.max_object_bytes:
204
+ await NamespaceUploader(write_namespace).upload("slack.zip", bundle)
205
+ return [uploaded_file_part("slack.zip", name="slack.zip", mime_type="application/zip")]
206
+ return [DataPart(data=self.state())]
207
+ ```
208
+
180
209
  Dependencies are intentionally light (`pydantic`, `starlette`) so the package can be added to environment server images without pulling a heavier framework — `mcp` is imported lazily inside `create_fastmcp_app()` and is deliberately not a dependency.
181
210
 
182
211
  ## A2A agent framework
@@ -133,6 +133,35 @@ extension's, and `client.invoke_extension(base_url, card, uri, params, method=..
133
133
  its HTTP verb. Without `method`, `invoke_extension` calls the first method listed; an extension
134
134
  with several methods, such as a gateway's `urn:agentenv:clock/v1`, should always be called by name.
135
135
 
136
+ ### Uploading an export through a grant
137
+
138
+ An export too large to return from `data/get` can go straight to the caller's object store. A
139
+ `@get_data` handler that takes a `write_namespace` parameter is advertised under
140
+ `urn:agentenv:data-objects/v1` (`DATA_OBJECTS_EXTENSION_URI`), and a caller may then send a
141
+ `transfers.WriteNamespaceGrant` with the call: `client.get_data(base_url,
142
+ write_namespace=grant)`. The handler is called with the grant, or with None when the caller
143
+ sent none. It uploads under the grant with `transfers.NamespaceUploader` and answers with
144
+ `uploaded_file_part(path, name=..., mime_type=...)`, where `path` is relative to the grant's
145
+ `root_path`; the caller reads it back with `uploaded_object_path(part)`, which refuses a path
146
+ outside the root. A handler that cannot use the grant (its export outgrows the grant's limits,
147
+ say) answers as it would without one.
148
+
149
+ ```python
150
+ from agentenv_protocol import AgentEnvEnvironment, DataPart, environment_card, get_data, uploaded_file_part
151
+ from agentenv_protocol.transfers import NamespaceUploader, WriteNamespaceGrant
152
+
153
+
154
+ @environment_card(name="slack")
155
+ class SlackEnv(AgentEnvEnvironment):
156
+ @get_data
157
+ async def _state(self, write_namespace: WriteNamespaceGrant | None = None):
158
+ bundle = self.write_bundle() # a Path
159
+ if write_namespace is not None and bundle.stat().st_size <= write_namespace.max_object_bytes:
160
+ await NamespaceUploader(write_namespace).upload("slack.zip", bundle)
161
+ return [uploaded_file_part("slack.zip", name="slack.zip", mime_type="application/zip")]
162
+ return [DataPart(data=self.state())]
163
+ ```
164
+
136
165
  Dependencies are intentionally light (`pydantic`, `starlette`) so the package can be added to environment server images without pulling a heavier framework — `mcp` is imported lazily inside `create_fastmcp_app()` and is deliberately not a dependency.
137
166
 
138
167
  ## A2A agent framework
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.286"
3
+ version = "0.1.289"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -34,6 +34,7 @@ from .manifest import (
34
34
  manifest_compatible,
35
35
  )
36
36
  from .types import (
37
+ DATA_OBJECTS_EXTENSION_URI,
37
38
  INTAKE_EXTENSION_URI,
38
39
  MCP_PATH,
39
40
  MCP_TRANSPORT,
@@ -60,6 +61,8 @@ from .types import (
60
61
  TextPart,
61
62
  error_body,
62
63
  intake_extension,
64
+ uploaded_file_part,
65
+ uploaded_object_path,
63
66
  )
64
67
 
65
68
  __all__ = [
@@ -88,6 +91,9 @@ __all__ = [
88
91
  "intake_extension",
89
92
  "intake_fit_check",
90
93
  "INTAKE_EXTENSION_URI",
94
+ "DATA_OBJECTS_EXTENSION_URI",
95
+ "uploaded_file_part",
96
+ "uploaded_object_path",
91
97
  "MCP_PATH",
92
98
  "MCP_TRANSPORT",
93
99
  "MANIFEST_VERSION",
@@ -13,12 +13,14 @@ import uuid
13
13
  from abc import ABC, abstractmethod
14
14
  from typing import Annotated, Any, Callable, ClassVar, Literal, NamedTuple, Union, get_args, get_origin, get_type_hints
15
15
 
16
+ from pydantic import ValidationError
16
17
  from pydantic.fields import FieldInfo
17
18
  from starlette.requests import Request
18
19
  from starlette.responses import Response
19
20
  from starlette.routing import Route
20
21
 
21
- from .types import MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataRequest, AddDataResponse, EnvironmentCapabilities, EnvironmentCard, EnvironmentExtension, EnvironmentInterface, EnvironmentTool, GetDataResponse, ResetDataResponse, error_body
22
+ from .transfers import WriteNamespaceGrant
23
+ 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
22
24
 
23
25
  logger = logging.getLogger(__name__)
24
26
 
@@ -50,6 +52,9 @@ def add_data(fn: Callable) -> Callable:
50
52
 
51
53
 
52
54
  def get_data(fn: Callable) -> Callable:
55
+ """Mark the ``data/get`` handler. One that takes a ``write_namespace`` parameter is advertised under
56
+ ``DATA_OBJECTS_EXTENSION_URI`` and called with the grant a caller sends, else None; it may upload its
57
+ export under the grant and return ``uploaded_file_part``."""
53
58
  setattr(fn, _OP_ATTR, OP_GET_DATA)
54
59
  return fn
55
60
 
@@ -157,6 +162,7 @@ class AgentEnvApplication(ABC):
157
162
  environment_card = _merge_tools(environment_card, tool_handlers)
158
163
  methods = _discover_methods(handler)
159
164
  environment_card = _merge_operations(environment_card, methods)
165
+ environment_card = _merge_data_objects(environment_card, methods)
160
166
  self.environment_card = environment_card
161
167
  self._dispatch = _jsonrpc_handler(methods)
162
168
  self._ext_routes = [(path, method, _extension_handler(fn, method)) for _descriptor, fn, path, method in ext_handlers]
@@ -426,6 +432,24 @@ def _merge_operations(card: EnvironmentCard, methods: dict) -> EnvironmentCard:
426
432
  return card.model_copy(update={"capabilities": caps.model_copy(update={"operations": list(methods)})})
427
433
 
428
434
 
435
+ _DATA_OBJECTS_EXTENSION = EnvironmentExtension(
436
+ uri=DATA_OBJECTS_EXTENSION_URI,
437
+ description="data/get takes a write_namespace grant and may upload its export under it.",
438
+ )
439
+
440
+
441
+ def _takes_write_namespace(fn: Callable) -> bool:
442
+ return "write_namespace" in inspect.signature(fn).parameters
443
+
444
+
445
+ def _merge_data_objects(card: EnvironmentCard, methods: dict) -> EnvironmentCard:
446
+ """Advertise the data-objects extension when the ``data/get`` handler takes a ``write_namespace``."""
447
+ entry = methods.get(METHOD_GET)
448
+ if entry is None or not _takes_write_namespace(entry[1]):
449
+ return card
450
+ return _merge_extensions(card, [(_DATA_OBJECTS_EXTENSION,)])
451
+
452
+
429
453
  def _merge_mcp_interface(card: EnvironmentCard, path: str) -> EnvironmentCard:
430
454
  """Declare the MCP endpoint at the path the app serves it on; a card-declared `MCP_TRANSPORT` interface wins.
431
455
 
@@ -471,8 +495,19 @@ async def _invoke_add(fn: Callable, params: dict) -> dict:
471
495
 
472
496
 
473
497
  async def _invoke_get(fn: Callable, params: dict) -> dict:
498
+ kwargs = {}
499
+ if _takes_write_namespace(fn):
500
+ if not isinstance(params, dict):
501
+ raise _InvalidParams("data/get takes its params by name")
502
+ raw = params.get("write_namespace")
503
+ try:
504
+ kwargs["write_namespace"] = None if raw is None else WriteNamespaceGrant.model_validate(raw)
505
+ except ValidationError as e:
506
+ # Name the fields only: the values carry the grant's signature.
507
+ fields = ", ".join(".".join(map(str, error["loc"])) or "write_namespace" for error in e.errors())
508
+ raise _InvalidParams(f"write_namespace is not a valid namespace grant ({fields})")
474
509
  try:
475
- parts = await _maybe_await(fn())
510
+ parts = await _maybe_await(fn(**kwargs))
476
511
  return GetDataResponse(parts=parts).model_dump(exclude_none=True)
477
512
  except Exception as e:
478
513
  logger.exception("data/get failed")
@@ -6,6 +6,7 @@ import logging
6
6
  import httpx
7
7
 
8
8
  from .manifest import INTERFACE_MANIFEST_PATH
9
+ from .transfers import WriteNamespaceGrant
9
10
  from .types import INTAKE_EXTENSION_URI, MCP_PATH, MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataResponse, GetDataResponse, Part, ResetDataResponse
10
11
 
11
12
  logger = logging.getLogger(__name__)
@@ -32,8 +33,13 @@ async def add_data(base_url: str, parts: list[Part], timeout: int = 120, verify:
32
33
  return AddDataResponse.model_validate(await _rpc(base_url, METHOD_ADD, params, timeout, verify))
33
34
 
34
35
 
35
- async def get_data(base_url: str, timeout: int = 30, verify: bool = True) -> GetDataResponse:
36
- return GetDataResponse.model_validate(await _rpc(base_url, METHOD_GET, {}, timeout, verify))
36
+ async def get_data(
37
+ base_url: str, timeout: int = 30, verify: bool = True, *, write_namespace: WriteNamespaceGrant | None = None
38
+ ) -> GetDataResponse:
39
+ """`data/get`. A server advertising `DATA_OBJECTS_EXTENSION_URI` may be handed `write_namespace` to upload
40
+ its export under; `uploaded_object_path` reads from the answer where it did."""
41
+ params = {} if write_namespace is None else {"write_namespace": write_namespace.model_dump(mode="json", exclude_none=True)}
42
+ return GetDataResponse.model_validate(await _rpc(base_url, METHOD_GET, params, timeout, verify))
37
43
 
38
44
 
39
45
  async def get_card(base_url: str, timeout: int = 10, verify: bool = True) -> dict:
@@ -3,7 +3,9 @@ from __future__ import annotations
3
3
 
4
4
  from typing import Annotated, Literal, Optional, Union
5
5
 
6
- from pydantic import BaseModel, Field
6
+ from pydantic import BaseModel, Field, TypeAdapter, ValidationError
7
+
8
+ from .transfers import RelativePath
7
9
 
8
10
 
9
11
  def error_body(code: str, message: str) -> dict:
@@ -30,6 +32,13 @@ METHOD_GET = "data/get"
30
32
  # extension means "no claim" (skip fit-checks), matching the `supports_v1` 404->legacy precedent.
31
33
  INTAKE_EXTENSION_URI = "urn:agentenv:intake/v1"
32
34
 
35
+ # A declaration-only extension: `data/get` takes a `write_namespace` param (a
36
+ # `transfers.WriteNamespaceGrant`) and may upload its export under the grant instead of returning
37
+ # it, answering with the part `uploaded_file_part` builds. A server that does not advertise it
38
+ # is never sent one.
39
+ DATA_OBJECTS_EXTENSION_URI = "urn:agentenv:data-objects/v1"
40
+ _RELATIVE_PATH = TypeAdapter(RelativePath)
41
+
33
42
 
34
43
  class FileWithBytes(BaseModel):
35
44
  bytes: str
@@ -64,6 +73,29 @@ class DataPart(BaseModel):
64
73
  Part = Annotated[Union[TextPart, FilePart, DataPart], Field(discriminator="kind")]
65
74
 
66
75
 
76
+ def uploaded_file_part(path: str, *, name: Optional[str] = None, mime_type: Optional[str] = None) -> FilePart:
77
+ """The `data/get` answer naming an export uploaded through its `write_namespace` grant, at
78
+ `path` relative to the grant's `root_path`."""
79
+ path = _RELATIVE_PATH.validate_python(path)
80
+ return FilePart(
81
+ file=FileWithUri(uri=path, name=name, mimeType=mime_type),
82
+ metadata={DATA_OBJECTS_EXTENSION_URI: {"path": path}},
83
+ )
84
+
85
+
86
+ def uploaded_object_path(part: Part) -> Optional[str]:
87
+ """Where, relative to the `write_namespace` grant's `root_path`, `part` says the export was
88
+ uploaded; None when it names no upload. Raises ValueError for a path outside the root."""
89
+ marker = (part.metadata or {}).get(DATA_OBJECTS_EXTENSION_URI) if isinstance(part, FilePart) else None
90
+ if marker is None:
91
+ return None
92
+ path = marker.get("path") if isinstance(marker, dict) else None
93
+ try:
94
+ return _RELATIVE_PATH.validate_python(path)
95
+ except ValidationError:
96
+ raise ValueError(f"the uploaded export's path {path!r} is not a normalized relative path") from None
97
+
98
+
67
99
  class AddDataRequest(BaseModel):
68
100
  parts: list[Part] = Field(min_length=1)
69
101
 
@@ -0,0 +1,249 @@
1
+ """data/get through a write_namespace grant: the card advertisement, the handler binding, the client
2
+ call, and the answer that names where the export was uploaded."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from datetime import datetime, timedelta, timezone
8
+ from types import SimpleNamespace
9
+
10
+ import httpx
11
+ import pytest
12
+ from starlette.applications import Starlette
13
+ from starlette.routing import Route
14
+
15
+ from agentenv_protocol import (
16
+ DATA_OBJECTS_EXTENSION_URI,
17
+ AgentEnvFastMCPApplication,
18
+ DataPart,
19
+ EnvironmentCapabilities,
20
+ EnvironmentCard,
21
+ EnvironmentExtension,
22
+ FilePart,
23
+ GetDataResponse,
24
+ get_data,
25
+ uploaded_file_part,
26
+ uploaded_object_path,
27
+ )
28
+ from agentenv_protocol import client as protocol_v1
29
+ from agentenv_protocol.transfers import HttpPostPolicyGrant, NamespaceUploader, WriteNamespaceGrant
30
+
31
+ _SIGNATURE = "policy-signature-secret"
32
+
33
+
34
+ def _grant(*, max_object_bytes: int = 1024) -> WriteNamespaceGrant:
35
+ return WriteNamespaceGrant(
36
+ root_path="snapshots/run-1",
37
+ expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
38
+ max_objects=1,
39
+ max_object_bytes=max_object_bytes,
40
+ max_total_bytes=max_object_bytes,
41
+ write=HttpPostPolicyGrant(
42
+ kind="http-post-policy",
43
+ url="https://objects.example.test/upload",
44
+ fields={"policy": _SIGNATURE},
45
+ path_field="key",
46
+ file_field="file",
47
+ ),
48
+ )
49
+
50
+
51
+ class _FakeMCP:
52
+ def __init__(self) -> None:
53
+ self.routes: list[Route] = []
54
+ self.settings = SimpleNamespace(streamable_http_path="/mcp")
55
+
56
+ def custom_route(self, path: str, methods: list[str]):
57
+ def deco(fn):
58
+ self.routes.append(Route(path, fn, methods=methods))
59
+ return fn
60
+ return deco
61
+
62
+
63
+ class _InlineHandler:
64
+ def __init__(self) -> None:
65
+ self.calls = 0
66
+
67
+ @get_data
68
+ async def _state(self) -> list:
69
+ self.calls += 1
70
+ return [DataPart(data={"rows": 1})]
71
+
72
+
73
+ class _UploadingHandler:
74
+ def __init__(self) -> None:
75
+ self.grants: list[WriteNamespaceGrant | None] = []
76
+
77
+ @get_data
78
+ async def _state(self, write_namespace: WriteNamespaceGrant | None = None) -> list:
79
+ self.grants.append(write_namespace)
80
+ if write_namespace is None:
81
+ return [DataPart(data={"rows": 1})]
82
+ await NamespaceUploader(write_namespace).upload("slack.zip", b"PK\x03\x04bundle")
83
+ return [uploaded_file_part("slack.zip", name="slack.zip", mime_type="application/zip")]
84
+
85
+
86
+ def _app(handler, card: EnvironmentCard | None = None) -> tuple[AgentEnvFastMCPApplication, Starlette]:
87
+ mcp = _FakeMCP()
88
+ application = AgentEnvFastMCPApplication(environment_card=card or EnvironmentCard(name="slack"), handler=handler)
89
+ application.add_routes_to_app(mcp)
90
+ return application, Starlette(routes=mcp.routes)
91
+
92
+
93
+ def _rpc(params: dict | None = None) -> dict:
94
+ return {"jsonrpc": "2.0", "id": 1, "method": "data/get", "params": params or {}}
95
+
96
+
97
+ async def _post(app: Starlette, body: dict) -> dict:
98
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
99
+ return (await client.post("/agentenv", json=body)).json()
100
+
101
+
102
+ def _uris(card: EnvironmentCard) -> list[str]:
103
+ return [e.uri for e in card.capabilities.extensions or []]
104
+
105
+
106
+ def test_a_handler_that_takes_a_write_namespace_is_advertised():
107
+ application, _ = _app(_UploadingHandler())
108
+ (extension,) = application.environment_card.capabilities.extensions
109
+ assert extension.uri == DATA_OBJECTS_EXTENSION_URI
110
+ assert extension.params is None
111
+
112
+
113
+ def test_a_handler_that_takes_none_is_not():
114
+ application, _ = _app(_InlineHandler())
115
+ assert _uris(application.environment_card) == []
116
+
117
+
118
+ def test_a_card_declared_data_objects_extension_wins():
119
+ declared = EnvironmentExtension(uri=DATA_OBJECTS_EXTENSION_URI, description="declared")
120
+ card = EnvironmentCard(name="slack", capabilities=EnvironmentCapabilities(extensions=[declared]))
121
+ application, _ = _app(_UploadingHandler(), card)
122
+ assert application.environment_card.capabilities.extensions == [declared]
123
+
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_the_handler_gets_the_grant_sent_or_none():
127
+ handler = _UploadingHandler()
128
+ _, app = _app(handler)
129
+ grant = _grant()
130
+
131
+ await _post(app, _rpc())
132
+ await _post(app, _rpc({"write_namespace": grant.model_dump(mode="json")}))
133
+
134
+ assert handler.grants == [None, grant]
135
+
136
+
137
+ @pytest.mark.asyncio
138
+ async def test_a_handler_that_takes_no_grant_ignores_one_sent():
139
+ handler = _InlineHandler()
140
+ _, app = _app(handler)
141
+
142
+ body = await _post(app, _rpc({"write_namespace": _grant().model_dump(mode="json")}))
143
+
144
+ assert body["result"]["parts"] == [{"kind": "data", "data": {"rows": 1}}]
145
+ assert handler.calls == 1
146
+
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_an_invalid_grant_is_refused_without_echoing_it():
150
+ handler = _UploadingHandler()
151
+ _, app = _app(handler)
152
+ sent = _grant().model_dump(mode="json")
153
+ sent["root_path"] = "../elsewhere"
154
+
155
+ body = await _post(app, _rpc({"write_namespace": sent}))
156
+
157
+ assert body["error"]["code"] == -32602
158
+ assert "root_path" in body["error"]["data"]["detail"]
159
+ assert _SIGNATURE not in json.dumps(body)
160
+ assert handler.grants == []
161
+
162
+
163
+ @pytest.mark.asyncio
164
+ async def test_params_given_by_position_are_refused():
165
+ handler = _UploadingHandler()
166
+ _, app = _app(handler)
167
+
168
+ body = await _post(app, {"jsonrpc": "2.0", "id": 1, "method": "data/get", "params": [_grant().model_dump(mode="json")]})
169
+
170
+ assert body["error"]["code"] == -32602
171
+ assert _SIGNATURE not in json.dumps(body)
172
+ assert handler.grants == []
173
+
174
+
175
+ @pytest.mark.asyncio
176
+ async def test_get_data_sends_no_params_without_a_grant_and_the_grant_with_one(monkeypatch):
177
+ sent: list[dict] = []
178
+ real = httpx.AsyncClient
179
+
180
+ def handle(request: httpx.Request) -> httpx.Response:
181
+ sent.append(json.loads(request.content))
182
+ return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": {"parts": []}})
183
+
184
+ monkeypatch.setattr(protocol_v1.httpx, "AsyncClient", lambda **kw: real(transport=httpx.MockTransport(handle)))
185
+
186
+ grant = _grant()
187
+ await protocol_v1.get_data("http://env")
188
+ await protocol_v1.get_data("http://env", write_namespace=grant)
189
+
190
+ assert sent[0]["params"] == {}
191
+ assert WriteNamespaceGrant.model_validate(sent[1]["params"]["write_namespace"]) == grant
192
+
193
+
194
+ @pytest.mark.asyncio
195
+ async def test_an_export_uploads_through_the_grant_and_the_answer_names_where(monkeypatch):
196
+ """The real handler, uploader, client and reader together: the bundle is POSTed under the grant's root, and
197
+ the answer names its path relative to it."""
198
+ handler = _UploadingHandler()
199
+ _, app = _app(handler)
200
+ uploads: list[httpx.Request] = []
201
+
202
+ def store(request: httpx.Request) -> httpx.Response:
203
+ uploads.append(request)
204
+ return httpx.Response(204)
205
+
206
+ real_sync, real_async = httpx.Client, httpx.AsyncClient
207
+ monkeypatch.setattr(httpx, "Client", lambda **kw: real_sync(**{**kw, "transport": httpx.MockTransport(store)}))
208
+ monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: real_async(transport=httpx.ASGITransport(app=app)))
209
+
210
+ response = await protocol_v1.get_data("http://t", write_namespace=_grant())
211
+
212
+ (part,) = response.parts
213
+ assert uploaded_object_path(part) == "slack.zip"
214
+ assert (part.file.name, part.file.mimeType) == ("slack.zip", "application/zip")
215
+ (upload,) = uploads
216
+ assert str(upload.url) == "https://objects.example.test/upload"
217
+ assert b'name="key"\r\n\r\nsnapshots/run-1/slack.zip' in upload.content
218
+ assert b"PK\x03\x04bundle" in upload.content
219
+
220
+
221
+ def test_the_uploaded_part_survives_the_wire():
222
+ wire = GetDataResponse(parts=[uploaded_file_part("dir/gdrive.zip", name="gdrive.zip")]).model_dump(mode="json", exclude_none=True)
223
+ (part,) = GetDataResponse.model_validate(json.loads(json.dumps(wire))).parts
224
+ assert uploaded_object_path(part) == "dir/gdrive.zip"
225
+
226
+
227
+ @pytest.mark.parametrize("part", [
228
+ FilePart(file={"uri": "export-snapshot", "name": "gdrive.zip"}),
229
+ FilePart(file={"uri": "export-snapshot"}, metadata={"other": {"path": "x"}}),
230
+ DataPart(data={"path": "x"}, metadata={DATA_OBJECTS_EXTENSION_URI: {"path": "x"}}),
231
+ ], ids=["no-metadata", "other-metadata", "data-part"])
232
+ def test_a_part_that_names_no_upload_reads_as_none(part):
233
+ assert uploaded_object_path(part) is None
234
+
235
+
236
+ @pytest.mark.parametrize("path", ["../escape.zip", "/abs.zip", "a//b.zip", "a/./b.zip", "a/", "", "a\\b.zip", None, 3])
237
+ def test_a_path_outside_the_root_is_refused(path):
238
+ part = FilePart(file={"uri": "x"}, metadata={DATA_OBJECTS_EXTENSION_URI: {"path": path}})
239
+ with pytest.raises(ValueError, match="not a normalized relative path"):
240
+ uploaded_object_path(part)
241
+ if isinstance(path, str):
242
+ with pytest.raises(ValueError):
243
+ uploaded_file_part(path)
244
+
245
+
246
+ def test_a_marker_that_is_not_an_object_is_refused():
247
+ part = FilePart(file={"uri": "x"}, metadata={DATA_OBJECTS_EXTENSION_URI: "x.zip"})
248
+ with pytest.raises(ValueError, match="not a normalized relative path"):
249
+ uploaded_object_path(part)