macp-sdk-python 0.4.1__tar.gz → 0.6.0__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. {macp_sdk_python-0.4.1/src/macp_sdk_python.egg-info → macp_sdk_python-0.6.0}/PKG-INFO +14 -8
  2. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/README.md +9 -2
  3. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/pyproject.toml +56 -16
  4. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/__init__.py +2 -0
  5. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/agent/participant.py +11 -1
  6. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/agent/runner.py +5 -0
  7. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/base_session.py +19 -2
  8. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/client.py +262 -50
  9. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/envelope.py +35 -1
  10. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/errors.py +13 -1
  11. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/handoff.py +29 -0
  12. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/policy.py +23 -2
  13. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/proto_registry.py +27 -1
  14. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/validation.py +34 -6
  15. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/watchers.py +9 -5
  16. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0/src/macp_sdk_python.egg-info}/PKG-INFO +14 -8
  17. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk_python.egg-info/requires.txt +4 -5
  18. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/LICENSE +0 -0
  19. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/setup.cfg +0 -0
  20. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/_logging.py +0 -0
  21. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/agent/__init__.py +0 -0
  22. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/agent/cancel_callback.py +0 -0
  23. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/agent/dispatcher.py +0 -0
  24. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/agent/strategies.py +0 -0
  25. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/agent/transports.py +0 -0
  26. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/agent/types.py +0 -0
  27. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/auth.py +0 -0
  28. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/base_projection.py +0 -0
  29. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/constants.py +0 -0
  30. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/decision.py +0 -0
  31. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/projections.py +0 -0
  32. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/proposal.py +0 -0
  33. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/py.typed +0 -0
  34. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/quorum.py +0 -0
  35. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/retry.py +0 -0
  36. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk/task.py +0 -0
  37. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk_python.egg-info/SOURCES.txt +0 -0
  38. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
  39. {macp_sdk_python-0.4.1 → macp_sdk_python-0.6.0}/src/macp_sdk_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: macp-sdk-python
3
- Version: 0.4.1
3
+ Version: 0.6.0
4
4
  Summary: Python SDK for the MACP Rust runtime
5
5
  Author-email: Multi-Agent Coordination Protocol <macp@multiagentcoordinationprotocol.org>
6
6
  License: Apache-2.0
@@ -20,18 +20,17 @@ Classifier: Topic :: Software Development :: Libraries
20
20
  Requires-Python: >=3.11
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: grpcio>=1.81.1
24
- Requires-Dist: protobuf>=5.27.0
25
- Requires-Dist: macp-proto<0.2.0,>=0.1.3
23
+ Requires-Dist: grpcio>=1.82.0rc2
24
+ Requires-Dist: protobuf>=7.35.0
25
+ Requires-Dist: macp-proto<0.1.9,>=0.1.6
26
26
  Provides-Extra: dev
27
- Requires-Dist: grpcio-tools>=1.74.0; extra == "dev"
28
27
  Requires-Dist: pytest>=8.0; extra == "dev"
29
- Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
30
28
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
31
29
  Requires-Dist: mypy>=1.10; extra == "dev"
32
30
  Requires-Dist: ruff>=0.5; extra == "dev"
33
31
  Requires-Dist: grpc-stubs>=1.53; extra == "dev"
34
32
  Requires-Dist: build>=1.0; extra == "dev"
33
+ Requires-Dist: jsonschema>=4.0; extra == "dev"
35
34
  Provides-Extra: docs
36
35
  Requires-Dist: mkdocs-material>=9.5; extra == "docs"
37
36
  Requires-Dist: mkdocstrings[python]>=0.25; extra == "docs"
@@ -131,9 +130,10 @@ make setup # pip install -e ".[dev,docs]"
131
130
  make lint # ruff check
132
131
  make fmt # ruff format
133
132
  make typecheck # mypy strict
134
- make test # unit tests
133
+ make test # unit tests + coverage gate (85% branch, from pyproject)
134
+ make test-integration # integration tests (auto-skip without a running runtime)
135
135
  make test-all # lint + typecheck + all tests
136
- make coverage # coverage report
136
+ make coverage # HTML + terminal coverage report
137
137
 
138
138
  # Build
139
139
  make build # sdist + wheel
@@ -149,6 +149,12 @@ export MACP_ALLOW_INSECURE=1
149
149
  cargo run # in the runtime repo
150
150
  ```
151
151
 
152
+ Since runtime **v0.5.0**, a runtime with no authentication configured
153
+ (`MACP_AUTH_TOKENS_*` / `MACP_AUTH_ISSUER` unset) **refuses to start** unless
154
+ `MACP_ALLOW_INSECURE=1` is set, and the published Docker image **no longer
155
+ bakes that env in** — pass it explicitly for local dev (as above and in the
156
+ `docker run` command in `CLAUDE.md`).
157
+
152
158
  Dev-agent auth rides the standard `Authorization: Bearer` header — the
153
159
  runtime's `dev_authenticate` fallback binds the token value verbatim
154
160
  as the sender.
@@ -92,9 +92,10 @@ make setup # pip install -e ".[dev,docs]"
92
92
  make lint # ruff check
93
93
  make fmt # ruff format
94
94
  make typecheck # mypy strict
95
- make test # unit tests
95
+ make test # unit tests + coverage gate (85% branch, from pyproject)
96
+ make test-integration # integration tests (auto-skip without a running runtime)
96
97
  make test-all # lint + typecheck + all tests
97
- make coverage # coverage report
98
+ make coverage # HTML + terminal coverage report
98
99
 
99
100
  # Build
100
101
  make build # sdist + wheel
@@ -110,6 +111,12 @@ export MACP_ALLOW_INSECURE=1
110
111
  cargo run # in the runtime repo
111
112
  ```
112
113
 
114
+ Since runtime **v0.5.0**, a runtime with no authentication configured
115
+ (`MACP_AUTH_TOKENS_*` / `MACP_AUTH_ISSUER` unset) **refuses to start** unless
116
+ `MACP_ALLOW_INSECURE=1` is set, and the published Docker image **no longer
117
+ bakes that env in** — pass it explicitly for local dev (as above and in the
118
+ `docker run` command in `CLAUDE.md`).
119
+
113
120
  Dev-agent auth rides the standard `Authorization: Bearer` header — the
114
121
  runtime's `dev_authenticate` fallback binds the token value verbatim
115
122
  as the sender.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "macp-sdk-python"
7
- version = "0.4.1"
7
+ version = "0.6.0"
8
8
  description = "Python SDK for the MACP Rust runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -23,18 +23,36 @@ classifiers = [
23
23
  "Topic :: Software Development :: Libraries",
24
24
  ]
25
25
  dependencies = [
26
- # macp-proto 0.1.3's generated gRPC stubs require grpcio>=1.81.1.
27
- "grpcio>=1.81.1",
28
- "protobuf>=5.27.0",
29
- # Requires 0.1.3+ for the suspend/cancel/supersede surface: SuspendSession
30
- # /ResumeSession RPCs, SESSION_STATE_SUSPENDED/CANCELLED, the SUSPENDED/
31
- # RESUMED/CANCELLED lifecycle events, and CommitmentPayload.supersedes
32
- # (CommitmentRef). 0.1.2 added the RFC-MACP-0006-A1 subscribe_session_id /
33
- # after_sequence fields on StreamSessionRequest (used by MacpStream
34
- # .send_subscribe and the gRPC agent transport adapter); 0.1.1 is the
35
- # earlier baseline for context_id / extensions on SessionStartPayload.
36
- # See docs/contributing.md "Bumping macp-proto".
37
- "macp-proto>=0.1.3,<0.2.0",
26
+ # macp-proto 0.1.6's gencode was produced by protobuf 7.35.0 / grpc 1.82.0.
27
+ # protobuf enforces this at import time: importing macp.v1.core_pb2 under an
28
+ # older protobuf raises google.protobuf.runtime_version.VersionError
29
+ # ("runtime version cannot be older than the linked gencode version"), so
30
+ # these floors are hard requirements derived from the gencode, not from
31
+ # macp-proto's own (looser) METADATA. This is the consumer-visible bump in
32
+ # SDK 0.5.0 deployments pinned to protobuf 6.x / grpcio < 1.82 cannot
33
+ # straddle it.
34
+ # grpcio 1.82.0 (stable) was YANKED from PyPI with no newer stable published
35
+ # yet, which made a ">=1.82.0" floor unsatisfiable in fresh environments
36
+ # (pip skips yanked versions when resolving ranges). The rc2 floor keeps the
37
+ # gencode's GRPC_GENERATED_VERSION=1.82.0 import check satisfied (verified:
38
+ # macp.v1.*_pb2_grpc imports cleanly under 1.82.0rc2) and, per PEP 440,
39
+ # a pre-release in the specifier lets pip resolve it; pip will prefer the
40
+ # next stable >= 1.82.1 as soon as one exists. Revisit then.
41
+ "grpcio>=1.82.0rc2",
42
+ "protobuf>=7.35.0",
43
+ # Requires 0.1.6+ for the runtime v0.5.0 wire surface referenced by the SDK:
44
+ # SessionStartPayload.max_suspend_ms (0.1.5), HandoffAcceptPayload.implicit
45
+ # and ListSessionsRequest.page_size/page_token (0.1.6), and the canonical
46
+ # macp.modes.multi_round.v1.ContributePayload proto encoding (0.1.4).
47
+ # Retains the 0.1.3 surface (suspend/cancel/supersede, subscribe_session_id/
48
+ # after_sequence, context_id/extensions). See docs/contributing.md
49
+ # "Bumping macp-proto".
50
+ # Capped below 0.1.8: that release's METADATA floor is "grpcio>=1.82.0",
51
+ # which is unsatisfiable while grpcio 1.82.0 stable is yanked (see the
52
+ # grpcio comment above) — PEP 440 sorts 1.82.0rc2 below 1.82.0, so no
53
+ # candidate exists. 0.1.6/0.1.7 declare "grpcio>=1.74.0" and resolve fine.
54
+ # Re-widen to <0.2.0 once grpcio publishes a stable >= 1.82.1.
55
+ "macp-proto>=0.1.6,<0.1.9",
38
56
  ]
39
57
 
40
58
  [project.urls]
@@ -45,15 +63,18 @@ Changelog = "https://github.com/multiagentcoordinationprotocol/macp-sdk-python/b
45
63
  "Bug Tracker" = "https://github.com/multiagentcoordinationprotocol/macp-sdk-python/issues"
46
64
 
47
65
  [project.optional-dependencies]
66
+ # grpcio-tools was dropped from dev: this repo has no proto-generation step
67
+ # (bindings come from the macp-proto package; generation lives in the spec
68
+ # repo), and every installable grpcio-tools stable caps protobuf<7.0.0,
69
+ # which conflicts with the protobuf>=7.35.0 floor above.
48
70
  dev = [
49
- "grpcio-tools>=1.74.0", # only needed for proto-python package publishing
50
71
  "pytest>=8.0",
51
- "pytest-asyncio>=0.24",
52
72
  "pytest-cov>=5.0",
53
73
  "mypy>=1.10",
54
74
  "ruff>=0.5",
55
75
  "grpc-stubs>=1.53",
56
76
  "build>=1.0",
77
+ "jsonschema>=4.0", # validate vendored conformance fixtures against schema.json
57
78
  ]
58
79
  docs = [
59
80
  "mkdocs-material>=9.5",
@@ -126,8 +147,27 @@ ignore_missing_imports = true
126
147
 
127
148
  [tool.pytest.ini_options]
128
149
  testpaths = ["tests"]
129
- asyncio_mode = "auto"
150
+ addopts = "--strict-markers"
130
151
  markers = [
131
152
  "integration: requires running MACP runtime",
132
153
  "conformance: replays conformance fixtures against runtime",
133
154
  ]
155
+ filterwarnings = [
156
+ "error",
157
+ # Unit tests build real MacpClient objects against localhost:0 with mocked
158
+ # stubs and rely on GC to reap the never-connected socket; the resulting
159
+ # "unclosed socket" ResourceWarning fires inside socket.__del__ and would
160
+ # otherwise escalate to PytestUnraisableExceptionWarning under -W error.
161
+ "ignore::ResourceWarning",
162
+ ]
163
+
164
+ # Coverage gate: single source of truth for `make test`, `make coverage`, and
165
+ # CI (all invoke pytest with a bare --cov and inherit source/branch/fail_under
166
+ # from here).
167
+ [tool.coverage.run]
168
+ source = ["macp_sdk"]
169
+ branch = true
170
+
171
+ [tool.coverage.report]
172
+ fail_under = 85
173
+ show_missing = true
@@ -20,6 +20,7 @@ from .decision import DecisionSession
20
20
  from .envelope import (
21
21
  build_commitment_payload,
22
22
  build_commitment_ref,
23
+ build_contribute_payload,
23
24
  build_envelope,
24
25
  build_progress_payload,
25
26
  build_root,
@@ -183,6 +184,7 @@ __all__ = [
183
184
  "VotingRules",
184
185
  "build_commitment_payload",
185
186
  "build_commitment_ref",
187
+ "build_contribute_payload",
186
188
  "build_decision_policy",
187
189
  "build_envelope",
188
190
  "build_handoff_policy",
@@ -69,6 +69,9 @@ class InitiatorConfig:
69
69
  mode_version: str | None = None
70
70
  configuration_version: str | None = None
71
71
  policy_version: str | None = None
72
+ # Runtime v0.5.0 per-session maximum-suspension cap (ms); 0 = runtime
73
+ # default (currently 7 days). Negative values are rejected at build time.
74
+ max_suspend_ms: int = 0
72
75
  kickoff_message_type: str | None = None
73
76
  kickoff_payload: dict[str, Any] = field(default_factory=dict)
74
77
 
@@ -113,8 +116,13 @@ class ParticipantActions:
113
116
  mode_version: str | None = None,
114
117
  configuration_version: str | None = None,
115
118
  policy_version: str | None = None,
119
+ max_suspend_ms: int = 0,
116
120
  ) -> Any:
117
- """Send a SessionStart envelope to open the session."""
121
+ """Send a SessionStart envelope to open the session.
122
+
123
+ ``max_suspend_ms`` (runtime v0.5.0) binds a per-session maximum
124
+ suspension cap; ``0`` selects the runtime default.
125
+ """
118
126
  from ..constants import (
119
127
  DEFAULT_CONFIGURATION_VERSION,
120
128
  DEFAULT_MODE_VERSION,
@@ -135,6 +143,7 @@ class ParticipantActions:
135
143
  mode_version=mode_version or DEFAULT_MODE_VERSION,
136
144
  configuration_version=configuration_version or DEFAULT_CONFIGURATION_VERSION,
137
145
  policy_version=policy_version or DEFAULT_POLICY_VERSION,
146
+ max_suspend_ms=max_suspend_ms,
138
147
  )
139
148
  envelope = build_envelope(
140
149
  mode=self._mode,
@@ -522,6 +531,7 @@ class Participant:
522
531
  mode_version=cfg.mode_version,
523
532
  configuration_version=cfg.configuration_version,
524
533
  policy_version=cfg.policy_version,
534
+ max_suspend_ms=cfg.max_suspend_ms,
525
535
  )
526
536
  logger.info("SessionStart emitted (session=%s)", self._session_id)
527
537
 
@@ -123,6 +123,11 @@ def from_bootstrap(bootstrap_path: str | None = None) -> Participant:
123
123
  intent=str(ss.get("intent", "")),
124
124
  participants=[str(p) for p in ss.get("participants", participants)],
125
125
  ttl_ms=int(ss.get("ttl_ms", 300000)),
126
+ # Runtime v0.5.0 per-session suspension cap; absent → 0 (runtime
127
+ # default). The TypeScript bootstrap schema does not yet map this
128
+ # key, so it is Python-only for now; a bootstrap that omits it
129
+ # behaves identically in both SDKs.
130
+ max_suspend_ms=int(ss.get("max_suspend_ms", 0)),
126
131
  context_id=str(ss.get("context_id", "")),
127
132
  extensions=_decode_extensions(ss.get("extensions")),
128
133
  roots=ss.get("roots"),
@@ -102,10 +102,17 @@ class BaseSession(ABC):
102
102
  context_id: str = "",
103
103
  extensions: Mapping[str, bytes] | None = None,
104
104
  roots: Iterable[Any] | None = None,
105
+ max_suspend_ms: int = 0,
105
106
  sender: str | None = None,
106
107
  auth: AuthConfig | None = None,
107
108
  ) -> envelope_pb2.Ack:
108
- """Send SessionStart and begin tracking via the projection."""
109
+ """Send SessionStart and begin tracking via the projection.
110
+
111
+ ``max_suspend_ms`` (runtime v0.5.0) binds a per-session maximum
112
+ suspension cap; ``0`` selects the runtime default (currently 7 days).
113
+ A suspension outlasting this cap expires the session
114
+ (``SUSPENDED`` → ``EXPIRED``). Negative values are rejected.
115
+ """
109
116
  validate_participant_count(len(participants))
110
117
  payload = build_session_start_payload(
111
118
  intent=intent,
@@ -117,6 +124,7 @@ class BaseSession(ABC):
117
124
  context_id=context_id,
118
125
  extensions=extensions,
119
126
  roots=roots,
127
+ max_suspend_ms=max_suspend_ms,
120
128
  )
121
129
  envelope = build_envelope(
122
130
  mode=self.MODE,
@@ -138,7 +146,16 @@ class BaseSession(ABC):
138
146
  sender: str | None = None,
139
147
  auth: AuthConfig | None = None,
140
148
  ) -> envelope_pb2.Ack:
141
- """Send Commitment to resolve the session."""
149
+ """Send Commitment to resolve the session.
150
+
151
+ The commitment echoes ``self.policy_version``. Runtime v0.5.0 relaxed
152
+ the match rule (RFC-MACP-0012, change review A3): an **empty**
153
+ ``policy_version`` matches whatever policy the session is bound to, so
154
+ a session started with an empty policy_version no longer has to echo
155
+ ``policy.default``. A **non-empty** value must still equal the
156
+ resolved policy id (e.g. ``policy.default``) or the commitment is
157
+ rejected with ``UNKNOWN_POLICY_VERSION``.
158
+ """
142
159
  payload = build_commitment_payload(
143
160
  action=action,
144
161
  authority_scope=authority_scope,
@@ -21,6 +21,7 @@ from .errors import (
21
21
  MacpAckError,
22
22
  MacpIdentityMismatchError,
23
23
  MacpSdkError,
24
+ MacpSessionError,
24
25
  MacpTransportError,
25
26
  )
26
27
 
@@ -69,6 +70,33 @@ def _parse_grpc_metadata_reasons(rpc_error: grpc.RpcError) -> list[str]:
69
70
  return []
70
71
 
71
72
 
73
+ def _rpc_status_name(rpc_err: grpc.RpcError) -> str | None:
74
+ """Return the gRPC status code name for *rpc_err*, or None.
75
+
76
+ Defensive against error objects that don't implement ``code()`` (the bare
77
+ ``grpc.RpcError`` base class doesn't; only concrete call errors do).
78
+ """
79
+ code_fn = getattr(rpc_err, "code", None)
80
+ if not callable(code_fn):
81
+ return None
82
+ try:
83
+ status = code_fn()
84
+ except Exception:
85
+ return None
86
+ return status.name if status is not None else None
87
+
88
+
89
+ def _rpc_details(rpc_err: grpc.RpcError) -> str | None:
90
+ """Return ``rpc_err.details()`` if available, else None."""
91
+ details_fn = getattr(rpc_err, "details", None)
92
+ if not callable(details_fn):
93
+ return None
94
+ try:
95
+ return details_fn()
96
+ except Exception:
97
+ return None
98
+
99
+
72
100
  def _default_capabilities() -> core_pb2.Capabilities:
73
101
  return core_pb2.Capabilities(
74
102
  sessions=core_pb2.SessionsCapability(stream=True, list_sessions=True, watch_sessions=True),
@@ -159,8 +187,18 @@ class MacpStream:
159
187
 
160
188
  def send_subscribe(self, session_id: str, after_sequence: int = 0) -> None:
161
189
  """RFC-MACP-0006-A1: Send a subscribe-only frame to receive session
162
- history + live broadcast. The runtime replays accepted envelopes from
163
- ``after_sequence`` onwards, then continues with live broadcast.
190
+ history + live broadcast.
191
+
192
+ ``after_sequence`` (RFC-MACP-0006 §3.2, runtime v0.5.0) is the 1-based
193
+ ordinal over *accepted envelopes*, interpreted **exclusively**: ``0``
194
+ (default) replays from the start; ``N`` replays from envelope ``N+1``
195
+ onward, so envelope ``N`` is never re-delivered. Ordinals are
196
+ contiguous and stable across log compaction and runtime restart. To
197
+ resume, track how many envelopes you have consumed and pass that count
198
+ as ``after_sequence`` on reconnect. Resuming below a compacted range
199
+ fails the stream with gRPC ``FAILED_PRECONDITION`` (surfaced as
200
+ ``MacpTransportError(code="FAILED_PRECONDITION")`` from :meth:`read`) —
201
+ restart from ``0`` and reconcile.
164
202
  """
165
203
  if self._closed:
166
204
  raise MacpSdkError("stream is already closed")
@@ -175,7 +213,10 @@ class MacpStream:
175
213
  if item is self._END:
176
214
  return None
177
215
  if isinstance(item, grpc.RpcError):
178
- raise MacpTransportError(item.details() or str(item))
216
+ raise MacpTransportError(
217
+ _rpc_details(item) or str(item),
218
+ code=_rpc_status_name(item),
219
+ )
179
220
  assert isinstance(item, envelope_pb2.Envelope)
180
221
  return item
181
222
 
@@ -213,7 +254,7 @@ class MacpClient:
213
254
  root_certificates: bytes | None = None,
214
255
  default_timeout: float | None = None,
215
256
  client_name: str = "macp-sdk-python",
216
- client_version: str = "0.4.0",
257
+ client_version: str = "0.5.0",
217
258
  ) -> None:
218
259
  if secure is None:
219
260
  secure = not allow_insecure
@@ -256,6 +297,43 @@ class MacpClient:
256
297
  raise MacpSdkError("this operation requires auth; pass auth= or configure client.auth")
257
298
  return selected
258
299
 
300
+ @staticmethod
301
+ def _transport_error_from_rpc(rpc_err: grpc.RpcError) -> MacpTransportError:
302
+ """Build a :class:`MacpTransportError` preserving the gRPC status code.
303
+
304
+ Watch/stream RPCs surface consumer lag as ``RESOURCE_EXHAUSTED`` and a
305
+ missing-auth ``WatchSignals`` as ``UNAUTHENTICATED``; attaching the
306
+ code lets callers decide to reconnect (lag) vs. fix auth vs. give up.
307
+ """
308
+ return MacpTransportError(
309
+ _rpc_details(rpc_err) or str(rpc_err),
310
+ code=_rpc_status_name(rpc_err),
311
+ )
312
+
313
+ @staticmethod
314
+ def _map_registry_mutation_error(rpc_err: grpc.RpcError, *, read_only_hint: str) -> Exception:
315
+ """Translate a registry-mutation ``RpcError`` into a typed SDK error.
316
+
317
+ A runtime configured with a read-only registry (e.g.
318
+ ``MACP_POLICIES_DIR`` for policies) advertises the corresponding
319
+ capability as ``false`` in ``Initialize`` and rejects every mutating
320
+ RPC with gRPC ``FAILED_PRECONDITION``. Surface that as a
321
+ :class:`MacpAckError` carrying the code so callers can branch on it,
322
+ mirroring how ``send``/``cancel_session`` surface NACKs. Any other
323
+ status stays a :class:`MacpTransportError` with the code attached.
324
+ """
325
+ name = _rpc_status_name(rpc_err)
326
+ if name == "FAILED_PRECONDITION":
327
+ failure = AckFailure(
328
+ code="FAILED_PRECONDITION",
329
+ message=_rpc_details(rpc_err) or read_only_hint,
330
+ )
331
+ return MacpAckError(failure)
332
+ return MacpTransportError(
333
+ _rpc_details(rpc_err) or str(rpc_err),
334
+ code=name,
335
+ )
336
+
259
337
  @staticmethod
260
338
  def _resolve_sender(auth_cfg: AuthConfig, sender: str) -> str:
261
339
  """Resolve and validate the envelope sender against auth.expected_sender.
@@ -418,6 +496,12 @@ class MacpClient:
418
496
  suspended the runtime rejects messages sent to the session with a
419
497
  non-OPEN error; call :meth:`resume_session` to return it to OPEN.
420
498
  The returned ``Ack.session_state`` reflects ``SUSPENDED``.
499
+
500
+ A suspension that outlasts the session-bound ``max_suspend_ms`` cap
501
+ (set at ``SessionStart``; runtime v0.5.0) expires the session
502
+ (``SUSPENDED`` → ``EXPIRED``), observed as an ``EVENT_TYPE_EXPIRED``
503
+ lifecycle event. ``max_suspend_ms=0`` uses the runtime default
504
+ (currently 7 days).
421
505
  """
422
506
  auth_cfg = self._require_auth(auth)
423
507
  try:
@@ -491,23 +575,55 @@ class MacpClient:
491
575
  def list_sessions(
492
576
  self,
493
577
  *,
578
+ page_size: int = 0,
494
579
  auth: AuthConfig | None = None,
495
580
  timeout: float | None = None,
496
581
  ) -> list[core_pb2.SessionMetadata]:
497
582
  """List all active sessions known to the runtime.
498
583
 
499
- Returns the populated ``sessions`` repeated field of
500
- ``ListSessionsResponse`` as a plain ``list`` so callers don't have
501
- to reach through the proto wrapper. Per runtime semantics each
502
- entry includes ``context_id`` and ``extension_keys``.
584
+ Auto-paginates (macp-proto >= 0.1.6 / runtime v0.5.0): repeatedly
585
+ requests ``ListSessions`` with the runtime's ``next_page_token`` until
586
+ it is empty, so the returned ``list`` is always the *complete* set
587
+ regardless of the runtime's page size. Each entry includes
588
+ ``context_id`` and ``extension_keys``. ``page_size`` (0 = server
589
+ default) tunes the per-request batch. Use :meth:`list_sessions_page`
590
+ for manual, single-page control.
591
+ """
592
+ auth_cfg = self._require_auth(auth)
593
+ sessions: list[core_pb2.SessionMetadata] = []
594
+ page_token = ""
595
+ while True:
596
+ batch, page_token = self.list_sessions_page(
597
+ page_size=page_size,
598
+ page_token=page_token,
599
+ auth=auth_cfg,
600
+ timeout=timeout,
601
+ )
602
+ sessions.extend(batch)
603
+ if not page_token:
604
+ return sessions
605
+
606
+ def list_sessions_page(
607
+ self,
608
+ *,
609
+ page_size: int = 0,
610
+ page_token: str = "",
611
+ auth: AuthConfig | None = None,
612
+ timeout: float | None = None,
613
+ ) -> tuple[list[core_pb2.SessionMetadata], str]:
614
+ """Fetch a single page of sessions (macp-proto >= 0.1.6).
615
+
616
+ Returns ``(sessions, next_page_token)``. An empty ``next_page_token``
617
+ means the last page. Callers that want to drain all pages should use
618
+ :meth:`list_sessions`, which loops this for them.
503
619
  """
504
620
  auth_cfg = self._require_auth(auth)
505
621
  resp = self.stub.ListSessions(
506
- core_pb2.ListSessionsRequest(),
622
+ core_pb2.ListSessionsRequest(page_size=page_size, page_token=page_token),
507
623
  metadata=self._metadata(auth_cfg),
508
624
  timeout=timeout or self.default_timeout,
509
625
  )
510
- return list(resp.sessions)
626
+ return list(resp.sessions), resp.next_page_token
511
627
 
512
628
  def watch_sessions(
513
629
  self,
@@ -535,7 +651,7 @@ class MacpClient:
535
651
  try:
536
652
  yield from call
537
653
  except grpc.RpcError as exc:
538
- raise MacpTransportError(str(exc)) from exc
654
+ raise self._transport_error_from_rpc(exc) from exc
539
655
 
540
656
  def register_ext_mode(
541
657
  self,
@@ -544,12 +660,37 @@ class MacpClient:
544
660
  auth: AuthConfig | None = None,
545
661
  timeout: float | None = None,
546
662
  ) -> core_pb2.RegisterExtModeResponse:
663
+ """Register an extension-mode descriptor with the runtime.
664
+
665
+ Descriptors must declare ``Commitment`` among their
666
+ ``terminal_message_types`` (runtime v0.5.0 rejects those that don't),
667
+ and an ext session started without ``mode_version`` binds the
668
+ registered descriptor's version. Against a read-only mode registry the
669
+ runtime rejects this with gRPC ``FAILED_PRECONDITION``, surfaced here
670
+ as :class:`MacpAckError`.
671
+
672
+ Raises :class:`MacpSessionError` client-side if the descriptor omits
673
+ ``Commitment`` from ``terminal_message_types`` (runtime v0.5.0 requires
674
+ it), giving a clearer error than the runtime's rejection.
675
+ """
676
+ if "Commitment" not in descriptor.terminal_message_types:
677
+ raise MacpSessionError(
678
+ "ext-mode descriptor must declare 'Commitment' in "
679
+ "terminal_message_types (runtime v0.5.0 rejects descriptors "
680
+ "without a Commitment terminal type)"
681
+ )
547
682
  auth_cfg = self._require_auth(auth)
548
- return self.stub.RegisterExtMode(
549
- core_pb2.RegisterExtModeRequest(mode_descriptor=descriptor),
550
- metadata=self._metadata(auth_cfg),
551
- timeout=timeout or self.default_timeout,
552
- )
683
+ try:
684
+ return self.stub.RegisterExtMode(
685
+ core_pb2.RegisterExtModeRequest(mode_descriptor=descriptor),
686
+ metadata=self._metadata(auth_cfg),
687
+ timeout=timeout or self.default_timeout,
688
+ )
689
+ except grpc.RpcError as rpc_err:
690
+ raise self._map_registry_mutation_error(
691
+ rpc_err,
692
+ read_only_hint="RegisterExtMode refused: the mode registry is read-only",
693
+ ) from rpc_err
553
694
 
554
695
  def unregister_ext_mode(
555
696
  self,
@@ -559,11 +700,17 @@ class MacpClient:
559
700
  timeout: float | None = None,
560
701
  ) -> core_pb2.UnregisterExtModeResponse:
561
702
  auth_cfg = self._require_auth(auth)
562
- return self.stub.UnregisterExtMode(
563
- core_pb2.UnregisterExtModeRequest(mode=mode),
564
- metadata=self._metadata(auth_cfg),
565
- timeout=timeout or self.default_timeout,
566
- )
703
+ try:
704
+ return self.stub.UnregisterExtMode(
705
+ core_pb2.UnregisterExtModeRequest(mode=mode),
706
+ metadata=self._metadata(auth_cfg),
707
+ timeout=timeout or self.default_timeout,
708
+ )
709
+ except grpc.RpcError as rpc_err:
710
+ raise self._map_registry_mutation_error(
711
+ rpc_err,
712
+ read_only_hint="UnregisterExtMode refused: the mode registry is read-only",
713
+ ) from rpc_err
567
714
 
568
715
  def promote_mode(
569
716
  self,
@@ -573,12 +720,25 @@ class MacpClient:
573
720
  auth: AuthConfig | None = None,
574
721
  timeout: float | None = None,
575
722
  ) -> core_pb2.PromoteModeResponse:
723
+ """Promote a registered extension mode to a first-class mode.
724
+
725
+ Runtime v0.5.0 rejects promotion into the reserved ``macp.mode.*``
726
+ namespace. Against a read-only mode registry the runtime rejects this
727
+ with gRPC ``FAILED_PRECONDITION``, surfaced here as
728
+ :class:`MacpAckError`.
729
+ """
576
730
  auth_cfg = self._require_auth(auth)
577
- return self.stub.PromoteMode(
578
- core_pb2.PromoteModeRequest(mode=mode, promoted_mode_name=promoted_mode_name),
579
- metadata=self._metadata(auth_cfg),
580
- timeout=timeout or self.default_timeout,
581
- )
731
+ try:
732
+ return self.stub.PromoteMode(
733
+ core_pb2.PromoteModeRequest(mode=mode, promoted_mode_name=promoted_mode_name),
734
+ metadata=self._metadata(auth_cfg),
735
+ timeout=timeout or self.default_timeout,
736
+ )
737
+ except grpc.RpcError as rpc_err:
738
+ raise self._map_registry_mutation_error(
739
+ rpc_err,
740
+ read_only_hint="PromoteMode refused: the mode registry is read-only",
741
+ ) from rpc_err
582
742
 
583
743
  # ── Governance policy lifecycle ───────────────────────────────────
584
744
 
@@ -589,13 +749,28 @@ class MacpClient:
589
749
  auth: AuthConfig | None = None,
590
750
  timeout: float | None = None,
591
751
  ) -> policy_pb2.RegisterPolicyResponse:
592
- """Register a governance policy with the runtime."""
752
+ """Register a governance policy with the runtime.
753
+
754
+ Raises :class:`MacpAckError` with ``code="FAILED_PRECONDITION"`` when
755
+ the runtime's policy registry is read-only — a runtime started with
756
+ ``MACP_POLICIES_DIR`` advertises ``policy_registry.register_policy:
757
+ false`` in :meth:`initialize` and refuses all mutating policy RPCs.
758
+ Check that capability before registering to avoid the round-trip.
759
+ """
593
760
  auth_cfg = self._require_auth(auth)
594
- return self.stub.RegisterPolicy(
595
- policy_pb2.RegisterPolicyRequest(policy_descriptor=descriptor),
596
- metadata=self._metadata(auth_cfg),
597
- timeout=timeout or self.default_timeout,
598
- )
761
+ try:
762
+ return self.stub.RegisterPolicy(
763
+ policy_pb2.RegisterPolicyRequest(policy_descriptor=descriptor),
764
+ metadata=self._metadata(auth_cfg),
765
+ timeout=timeout or self.default_timeout,
766
+ )
767
+ except grpc.RpcError as rpc_err:
768
+ raise self._map_registry_mutation_error(
769
+ rpc_err,
770
+ read_only_hint="RegisterPolicy refused: the policy registry is read-only "
771
+ "(runtime configured with MACP_POLICIES_DIR; "
772
+ "Initialize advertises policy_registry.register_policy=false)",
773
+ ) from rpc_err
599
774
 
600
775
  def unregister_policy(
601
776
  self,
@@ -604,13 +779,24 @@ class MacpClient:
604
779
  auth: AuthConfig | None = None,
605
780
  timeout: float | None = None,
606
781
  ) -> policy_pb2.UnregisterPolicyResponse:
607
- """Unregister a governance policy from the runtime."""
782
+ """Unregister a governance policy from the runtime.
783
+
784
+ Like :meth:`register_policy`, raises :class:`MacpAckError` with
785
+ ``code="FAILED_PRECONDITION"`` against a read-only registry.
786
+ """
608
787
  auth_cfg = self._require_auth(auth)
609
- return self.stub.UnregisterPolicy(
610
- policy_pb2.UnregisterPolicyRequest(policy_id=policy_id),
611
- metadata=self._metadata(auth_cfg),
612
- timeout=timeout or self.default_timeout,
613
- )
788
+ try:
789
+ return self.stub.UnregisterPolicy(
790
+ policy_pb2.UnregisterPolicyRequest(policy_id=policy_id),
791
+ metadata=self._metadata(auth_cfg),
792
+ timeout=timeout or self.default_timeout,
793
+ )
794
+ except grpc.RpcError as rpc_err:
795
+ raise self._map_registry_mutation_error(
796
+ rpc_err,
797
+ read_only_hint="UnregisterPolicy refused: the policy registry is read-only "
798
+ "(runtime configured with MACP_POLICIES_DIR)",
799
+ ) from rpc_err
614
800
 
615
801
  def get_policy(
616
802
  self,
@@ -643,18 +829,24 @@ class MacpClient:
643
829
  )
644
830
 
645
831
  def watch_policies(
646
- self, *, timeout: float | None = None
832
+ self, *, auth: AuthConfig | None = None, timeout: float | None = None
647
833
  ) -> Iterator[policy_pb2.WatchPoliciesResponse]:
648
- """Server-streaming RPC: yields governance policy change events."""
834
+ """Server-streaming RPC: yields governance policy change events.
835
+
836
+ Auth is forwarded when available (``auth`` arg or ``client.auth``) but
837
+ not required. A lagging consumer is terminated with
838
+ ``RESOURCE_EXHAUSTED``; reconnect to resume.
839
+ """
649
840
  logger.debug("watch_policies starting")
650
841
  call = self.stub.WatchPolicies(
651
842
  policy_pb2.WatchPoliciesRequest(),
843
+ metadata=self._metadata(auth),
652
844
  timeout=timeout or self.default_timeout,
653
845
  )
654
846
  try:
655
847
  yield from call
656
848
  except grpc.RpcError as exc:
657
- raise MacpTransportError(str(exc)) from exc
849
+ raise self._transport_error_from_rpc(exc) from exc
658
850
 
659
851
  def open_stream(
660
852
  self, *, auth: AuthConfig | None = None, timeout: float | None = None
@@ -667,44 +859,64 @@ class MacpClient:
667
859
  )
668
860
 
669
861
  def watch_mode_registry(
670
- self, *, timeout: float | None = None
862
+ self, *, auth: AuthConfig | None = None, timeout: float | None = None
671
863
  ) -> Iterator[core_pb2.WatchModeRegistryResponse]:
672
- """Server-streaming RPC: yields mode registry change events."""
864
+ """Server-streaming RPC: yields mode registry change events.
865
+
866
+ Auth is forwarded when available but not required.
867
+ """
673
868
  logger.debug("watch_mode_registry starting")
674
869
  call = self.stub.WatchModeRegistry(
675
870
  core_pb2.WatchModeRegistryRequest(),
871
+ metadata=self._metadata(auth),
676
872
  timeout=timeout or self.default_timeout,
677
873
  )
678
874
  try:
679
875
  yield from call
680
876
  except grpc.RpcError as exc:
681
- raise MacpTransportError(str(exc)) from exc
877
+ raise self._transport_error_from_rpc(exc) from exc
682
878
 
683
- def watch_roots(self, *, timeout: float | None = None) -> Iterator[core_pb2.WatchRootsResponse]:
684
- """Server-streaming RPC: yields root change events."""
879
+ def watch_roots(
880
+ self, *, auth: AuthConfig | None = None, timeout: float | None = None
881
+ ) -> Iterator[core_pb2.WatchRootsResponse]:
882
+ """Server-streaming RPC: yields root change events.
883
+
884
+ The runtime advertises ``roots.list_changed: false`` and does not yet
885
+ populate roots, so this stream idles. Auth is forwarded when available
886
+ but not required.
887
+ """
685
888
  logger.debug("watch_roots starting")
686
889
  call = self.stub.WatchRoots(
687
890
  core_pb2.WatchRootsRequest(),
891
+ metadata=self._metadata(auth),
688
892
  timeout=timeout or self.default_timeout,
689
893
  )
690
894
  try:
691
895
  yield from call
692
896
  except grpc.RpcError as exc:
693
- raise MacpTransportError(str(exc)) from exc
897
+ raise self._transport_error_from_rpc(exc) from exc
694
898
 
695
899
  def watch_signals(
696
- self, *, timeout: float | None = None
900
+ self, *, auth: AuthConfig | None = None, timeout: float | None = None
697
901
  ) -> Iterator[core_pb2.WatchSignalsResponse]:
698
- """Server-streaming RPC: yields ambient signal envelopes."""
902
+ """Server-streaming RPC: yields ambient signal envelopes.
903
+
904
+ Requires authentication since runtime v0.5.0 — an unauthenticated
905
+ ``WatchSignals`` is rejected with gRPC ``UNAUTHENTICATED`` (surfaced
906
+ as ``MacpTransportError(code="UNAUTHENTICATED")``). A lagging consumer
907
+ is terminated with ``RESOURCE_EXHAUSTED``; reconnect to resume.
908
+ """
699
909
  logger.debug("watch_signals starting")
910
+ auth_cfg = self._require_auth(auth)
700
911
  call = self.stub.WatchSignals(
701
912
  core_pb2.WatchSignalsRequest(),
913
+ metadata=self._metadata(auth_cfg),
702
914
  timeout=timeout or self.default_timeout,
703
915
  )
704
916
  try:
705
917
  yield from call
706
918
  except grpc.RpcError as exc:
707
- raise MacpTransportError(str(exc)) from exc
919
+ raise self._transport_error_from_rpc(exc) from exc
708
920
 
709
921
  def send_signal(
710
922
  self,
@@ -4,6 +4,7 @@ import time
4
4
  import uuid
5
5
  from collections.abc import Iterable, Mapping, Sequence
6
6
 
7
+ from macp.modes.multi_round.v1 import multi_round_pb2
7
8
  from macp.v1 import core_pb2, envelope_pb2
8
9
 
9
10
  from .constants import (
@@ -50,6 +51,11 @@ def build_root(uri: str, name: str = "") -> core_pb2.Root:
50
51
  return core_pb2.Root(uri=uri, name=name)
51
52
 
52
53
 
54
+ def _has_max_suspend_ms_field() -> bool:
55
+ """Check if the proto schema supports SessionStartPayload.max_suspend_ms (>=0.1.5)."""
56
+ return any(f.name == "max_suspend_ms" for f in core_pb2.SessionStartPayload.DESCRIPTOR.fields)
57
+
58
+
53
59
  def build_session_start_payload(
54
60
  *,
55
61
  intent: str,
@@ -61,8 +67,22 @@ def build_session_start_payload(
61
67
  context_id: str = "",
62
68
  extensions: Mapping[str, bytes] | None = None,
63
69
  roots: Iterable[core_pb2.Root] | None = None,
70
+ max_suspend_ms: int = 0,
64
71
  ) -> core_pb2.SessionStartPayload:
65
- return core_pb2.SessionStartPayload(
72
+ """Build a ``SessionStartPayload``.
73
+
74
+ ``max_suspend_ms`` (macp-proto >= 0.1.5, runtime v0.5.0) binds a
75
+ per-session maximum-suspension cap at start. ``0`` (the default) or an
76
+ absent field selects the runtime default (currently 7 days). The runtime
77
+ rejects negative values at ``SessionStart``, so they are rejected
78
+ client-side here with a clear message. proto3 does not serialize a scalar
79
+ ``0``, so the default keeps byte-compatibility with pre-0.1.5 payloads.
80
+ """
81
+ if max_suspend_ms < 0:
82
+ raise MacpSessionError(
83
+ f"max_suspend_ms must be >= 0 (0 selects the runtime default), got {max_suspend_ms}"
84
+ )
85
+ kwargs: dict[str, object] = dict(
66
86
  intent=intent,
67
87
  participants=list(participants),
68
88
  mode_version=mode_version,
@@ -73,6 +93,9 @@ def build_session_start_payload(
73
93
  extensions=dict(extensions) if extensions else {},
74
94
  roots=list(roots or []),
75
95
  )
96
+ if max_suspend_ms and _has_max_suspend_ms_field():
97
+ kwargs["max_suspend_ms"] = max_suspend_ms
98
+ return core_pb2.SessionStartPayload(**kwargs)
76
99
 
77
100
 
78
101
  def _has_outcome_positive_field() -> bool:
@@ -164,6 +187,17 @@ def build_progress_payload(
164
187
  )
165
188
 
166
189
 
190
+ def build_contribute_payload(value: str) -> multi_round_pb2.ContributePayload:
191
+ """Build a ``ContributePayload`` for the ``ext.multi_round.v1`` mode.
192
+
193
+ Runtime v0.5.0 / macp-proto >= 0.1.4 moved Contribute onto the canonical
194
+ protobuf wire format. The runtime still permanently accepts legacy JSON
195
+ (``{"value": "..."}``) — see ``ProtoRegistry.decode_known_payload`` — but
196
+ new clients should emit proto via this builder.
197
+ """
198
+ return multi_round_pb2.ContributePayload(value=value)
199
+
200
+
167
201
  def serialize_message(message: object) -> bytes:
168
202
  serializer = getattr(message, "SerializeToString", None)
169
203
  if serializer is None:
@@ -69,7 +69,19 @@ class MacpAckError(MacpSdkError):
69
69
 
70
70
 
71
71
  class MacpTransportError(MacpSdkError):
72
- """gRPC communication failure."""
72
+ """gRPC communication failure.
73
+
74
+ ``code`` carries the gRPC status code name (e.g. ``"RESOURCE_EXHAUSTED"``,
75
+ ``"UNAUTHENTICATED"``, ``"FAILED_PRECONDITION"``) when the failure
76
+ originated from an ``RpcError``, or ``None`` otherwise. It lets callers
77
+ distinguish a lagging watch/stream consumer (``RESOURCE_EXHAUSTED`` →
78
+ reconnect) or a missing-auth watch (``UNAUTHENTICATED``) from a generic
79
+ transport failure without parsing the message string.
80
+ """
81
+
82
+ def __init__(self, message: str, *, code: str | None = None) -> None:
83
+ self.code = code
84
+ super().__init__(message)
73
85
 
74
86
 
75
87
  class MacpSessionError(MacpSdkError):
@@ -27,6 +27,12 @@ class HandoffRecord:
27
27
  context_content_type: str | None
28
28
  accepted_by: str | None
29
29
  declined_by: str | None
30
+ # True when the acceptance was an implicit accept synthesized by the
31
+ # runtime (RFC-MACP-0010 §5.1) rather than an explicit client HandoffAccept.
32
+ # Runtime v0.5.0 defines but does not yet emit these; the SDK surfaces the
33
+ # field so histories that contain them replay correctly. Client-submitted
34
+ # accepts are always ``implicit=False`` (the runtime rejects a forged True).
35
+ implicit: bool = False
30
36
 
31
37
 
32
38
  # ---------------------------------------------------------------------------
@@ -83,6 +89,12 @@ class HandoffProjection(BaseProjection):
83
89
  if handoff is not None:
84
90
  handoff.status = "accepted"
85
91
  handoff.accepted_by = p.accepted_by
92
+ # macp-proto >= 0.1.6: capture whether this was a runtime
93
+ # implicit accept (sender = target, message_id
94
+ # ``implicit-accept:<handoff_id>``). Absent field decodes to
95
+ # False. If ``handoff`` is None the offer was never observed
96
+ # (e.g. mid-session subscribe) — we still advance the phase.
97
+ handoff.implicit = getattr(p, "implicit", False)
86
98
  self.phase = "Accepted"
87
99
  return
88
100
 
@@ -119,6 +131,18 @@ class HandoffProjection(BaseProjection):
119
131
  handoff = self.handoffs.get(handoff_id)
120
132
  return handoff is not None and handoff.status == "declined"
121
133
 
134
+ def is_implicitly_accepted(self, handoff_id: str) -> bool:
135
+ """True if *handoff_id* was accepted by a runtime implicit accept.
136
+
137
+ Distinguishes a timeout-driven implicit accept (RFC-MACP-0010 §5.1)
138
+ from an explicit client ``HandoffAccept``. Runtime v0.5.0 does not yet
139
+ emit implicit accepts, so this returns False for all live sessions
140
+ today; it exists so histories/replays that carry them surface the
141
+ distinction.
142
+ """
143
+ handoff = self.handoffs.get(handoff_id)
144
+ return handoff is not None and handoff.status == "accepted" and handoff.implicit
145
+
122
146
  def get_handoff(self, handoff_id: str) -> HandoffRecord | None:
123
147
  """Return the handoff record for *handoff_id*, or None."""
124
148
  return self.handoffs.get(handoff_id)
@@ -203,6 +227,11 @@ class HandoffSession(BaseSession):
203
227
  sender: str | None = None,
204
228
  auth: AuthConfig | None = None,
205
229
  ) -> envelope_pb2.Ack:
230
+ # Deliberately no ``implicit`` parameter: RFC-MACP-0010 §5.1 reserves
231
+ # ``implicit=true`` for runtime-synthesized accepts and the runtime
232
+ # rejects a client-submitted True. Client accepts always leave the
233
+ # field at its proto3 default (False). See the regression test in
234
+ # tests/unit/test_handoff.py.
206
235
  payload = handoff_pb2.HandoffAcceptPayload(
207
236
  handoff_id=handoff_id,
208
237
  accepted_by=accepted_by or self._sender_for(sender, auth=auth),
@@ -12,6 +12,8 @@ from dataclasses import dataclass, field
12
12
 
13
13
  from macp.v1 import policy_pb2
14
14
 
15
+ from .errors import MacpSessionError
16
+
15
17
  # ── Shared commitment rules (all modes) ─────────────────────────────
16
18
 
17
19
 
@@ -145,10 +147,19 @@ def build_decision_policy(
145
147
 
146
148
  @dataclass(frozen=True, slots=True)
147
149
  class QuorumThreshold:
148
- """Quorum threshold configuration (RFC: ``threshold`` object)."""
150
+ """Quorum threshold configuration (RFC-MACP-0012 §4.2 ``threshold`` object).
151
+
152
+ ``threshold`` is strictly the **approval bar** — the number/percentage of
153
+ approvals required to commit. There is no separate participation quorum in
154
+ schema_version <= 2. ``value`` is an **integer** in the canonical
155
+ ``quorum-rules.schema.json``: an approval count for ``n_of_m`` /
156
+ ``weighted``, and an integer percentage 0-100 for ``percentage``. A
157
+ fractional value (e.g. ``0.75``) is rejected by the runtime's schema
158
+ validation, so this is typed ``int`` and range-checked at build time.
159
+ """
149
160
 
150
161
  type: str = "n_of_m"
151
- value: float = 0
162
+ value: int = 0
152
163
 
153
164
 
154
165
  @dataclass(frozen=True, slots=True)
@@ -172,6 +183,16 @@ def build_quorum_policy(
172
183
  a = abstention or AbstentionRules()
173
184
  c = commitment or CommitmentRules()
174
185
 
186
+ # Match the canonical schema's constraints before the runtime does, so a
187
+ # bad descriptor fails immediately client-side instead of round-tripping
188
+ # to an INVALID_POLICY_DEFINITION from RegisterPolicy.
189
+ if t.value < 0:
190
+ raise MacpSessionError(f"quorum threshold value must be >= 0, got {t.value}")
191
+ if t.type == "percentage" and t.value > 100:
192
+ raise MacpSessionError(
193
+ f"quorum threshold value must be 0-100 for type 'percentage', got {t.value}"
194
+ )
195
+
175
196
  rules: dict[str, object] = {
176
197
  "threshold": {"type": t.type, "value": t.value},
177
198
  "abstention": {
@@ -66,7 +66,11 @@ MODE_MAP: dict[str, dict[str, str]] = {
66
66
  "Abstain": "macp.modes.quorum.v1.AbstainPayload",
67
67
  },
68
68
  MODE_MULTI_ROUND: {
69
- "Contribute": "__json__",
69
+ # Runtime v0.5.0 / macp-proto >= 0.1.4: Contribute is the last
70
+ # advertised mode moved onto the canonical protobuf wire format.
71
+ # Legacy JSON (``{"value": "..."}``) is still decoded (tried first,
72
+ # permanently) — see ``decode_known_payload``.
73
+ "Contribute": "macp.modes.multi_round.v1.ContributePayload",
70
74
  },
71
75
  }
72
76
 
@@ -88,6 +92,7 @@ def _ensure_pb2_imports() -> None:
88
92
  "macp.modes.task.v1.task_pb2",
89
93
  "macp.modes.handoff.v1.handoff_pb2",
90
94
  "macp.modes.quorum.v1.quorum_pb2",
95
+ "macp.modes.multi_round.v1.multi_round_pb2",
91
96
  ):
92
97
  importlib.import_module(_mod)
93
98
  _PB2_MODULES_LOADED = True
@@ -133,8 +138,29 @@ class ProtoRegistry:
133
138
  type_name = self.get_known_type_name(mode, message_type)
134
139
  if type_name is None or type_name == "__json__":
135
140
  return self._try_decode_utf8(payload)
141
+ if mode == MODE_MULTI_ROUND and message_type == "Contribute":
142
+ # RFC-MACP contract: multi_round Contribute accepts legacy JSON
143
+ # (``{"value": "..."}``) *permanently* and tries it first, so
144
+ # pre-proto histories/replays decode byte-identically. Proto
145
+ # (``ContributePayload``) is the new canonical encoding; its
146
+ # serialized bytes are not valid JSON, so the JSON attempt fails
147
+ # loudly and we fall through to proto.
148
+ return self._decode_json_first_then_proto(type_name, payload)
136
149
  return self.decode_message(type_name, payload)
137
150
 
151
+ def _decode_json_first_then_proto(
152
+ self, type_name: str, payload: bytes
153
+ ) -> dict[str, Any] | None:
154
+ if not payload:
155
+ return None
156
+ try:
157
+ parsed = json.loads(payload.decode("utf-8"))
158
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
159
+ return self.decode_message(type_name, payload)
160
+ # Legacy JSON payload — keep the ``{"encoding": "json", "json": ...}``
161
+ # shape that existing consumers of decoded dicts already handle.
162
+ return {"encoding": "json", "json": parsed}
163
+
138
164
  @staticmethod
139
165
  def _try_decode_utf8(payload: bytes) -> dict[str, Any] | None:
140
166
  if not payload:
@@ -10,7 +10,15 @@ from collections.abc import Sequence
10
10
 
11
11
  from .errors import MacpSessionError
12
12
 
13
- _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
13
+ # A string with the structural shape of a UUID (36 chars, hyphens at
14
+ # 8-13-18-23, hex-only otherwise) — case-insensitive, any version/variant.
15
+ _UUID_SHAPE_RE = re.compile(
16
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
17
+ )
18
+ # Strict lowercase UUID v4/v7 (version nibble 4 or 7, RFC 9562 variant 8/9/a/b).
19
+ _UUID_V4V7_RE = re.compile(
20
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
21
+ )
14
22
  _BASE64URL_RE = re.compile(r"^[A-Za-z0-9_-]{22,}$")
15
23
 
16
24
  _VALID_VOTES = frozenset({"APPROVE", "REJECT", "ABSTAIN"})
@@ -22,11 +30,31 @@ _MAX_TTL_MS = 86_400_000 # 24 hours
22
30
 
23
31
 
24
32
  def validate_session_id(sid: str) -> None:
25
- """Validate that *sid* matches UUID v4/v7 or base64url (22+ chars)."""
26
- if not (_UUID_RE.match(sid) or _BASE64URL_RE.match(sid)):
27
- raise MacpSessionError(
28
- f"session_id must be UUID v4/v7 or base64url (22+ chars), got: {sid!r}"
29
- )
33
+ """Validate that *sid* is a lowercase UUID v4/v7 or base64url (22+ chars).
34
+
35
+ Mirrors the runtime's **no-fall-through** rule (runtime v0.5.0 change
36
+ review A4): a string that has the structural shape of a UUID is validated
37
+ strictly as a lowercase v4/v7 UUID — it is *not* reinterpreted as
38
+ base64url (which would otherwise accept an uppercase or wrong-version
39
+ UUID). A 36-char base64url ID containing ``-`` that is not UUID-shaped
40
+ (e.g. non-hex characters) still validates via the base64url branch, as the
41
+ runtime accepts.
42
+
43
+ This validation is advisory — it runs only when an explicit ``session_id``
44
+ is passed; auto-generated IDs are always valid v4 UUIDs.
45
+ """
46
+ if _UUID_SHAPE_RE.match(sid):
47
+ if not _UUID_V4V7_RE.match(sid):
48
+ raise MacpSessionError(
49
+ "session_id is UUID-shaped but not a lowercase v4/v7 UUID "
50
+ f"(no fall-through to base64url), got: {sid!r}"
51
+ )
52
+ return
53
+ if _BASE64URL_RE.match(sid):
54
+ return
55
+ raise MacpSessionError(
56
+ f"session_id must be a lowercase UUID v4/v7 or base64url (22+ chars), got: {sid!r}"
57
+ )
30
58
 
31
59
 
32
60
  def validate_vote(value: str) -> str:
@@ -88,7 +88,7 @@ class ModeRegistryWatcher:
88
88
 
89
89
  def changes(self) -> Iterator[Any]:
90
90
  """Yield ``WatchModeRegistryResponse`` items from the runtime stream."""
91
- yield from self._client.watch_mode_registry()
91
+ yield from self._client.watch_mode_registry(auth=self._auth)
92
92
 
93
93
  def watch(self, handler: Callable[[Any], None]) -> None:
94
94
  """Block and invoke *handler* for each registry change."""
@@ -111,7 +111,7 @@ class RootsWatcher:
111
111
 
112
112
  def changes(self) -> Iterator[Any]:
113
113
  """Yield ``WatchRootsResponse`` items from the runtime stream."""
114
- yield from self._client.watch_roots()
114
+ yield from self._client.watch_roots(auth=self._auth)
115
115
 
116
116
  def watch(self, handler: Callable[[Any], None]) -> None:
117
117
  """Block and invoke *handler* for each root change."""
@@ -133,8 +133,12 @@ class SignalWatcher:
133
133
  self._auth = auth
134
134
 
135
135
  def signals(self) -> Iterator[Any]:
136
- """Yield envelope objects extracted from ``WatchSignalsResponse``."""
137
- for response in self._client.watch_signals():
136
+ """Yield envelope objects extracted from ``WatchSignalsResponse``.
137
+
138
+ Forwards the watcher's stored ``auth`` — runtime v0.5.0 requires
139
+ authentication for ``WatchSignals``.
140
+ """
141
+ for response in self._client.watch_signals(auth=self._auth):
138
142
  if hasattr(response, "envelope") and response.envelope.ByteSize() > 0:
139
143
  yield response.envelope
140
144
 
@@ -217,7 +221,7 @@ class PolicyWatcher:
217
221
 
218
222
  def changes(self) -> Iterator[PolicyChange]:
219
223
  """Yield ``PolicyChange`` items from the runtime stream."""
220
- for response in self._client.watch_policies():
224
+ for response in self._client.watch_policies(auth=self._auth):
221
225
  descriptors = list(response.descriptors) if hasattr(response, "descriptors") else []
222
226
  observed = getattr(response, "observed_at_unix_ms", 0)
223
227
  yield PolicyChange(descriptors=descriptors, observed_at_unix_ms=observed)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: macp-sdk-python
3
- Version: 0.4.1
3
+ Version: 0.6.0
4
4
  Summary: Python SDK for the MACP Rust runtime
5
5
  Author-email: Multi-Agent Coordination Protocol <macp@multiagentcoordinationprotocol.org>
6
6
  License: Apache-2.0
@@ -20,18 +20,17 @@ Classifier: Topic :: Software Development :: Libraries
20
20
  Requires-Python: >=3.11
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: grpcio>=1.81.1
24
- Requires-Dist: protobuf>=5.27.0
25
- Requires-Dist: macp-proto<0.2.0,>=0.1.3
23
+ Requires-Dist: grpcio>=1.82.0rc2
24
+ Requires-Dist: protobuf>=7.35.0
25
+ Requires-Dist: macp-proto<0.1.9,>=0.1.6
26
26
  Provides-Extra: dev
27
- Requires-Dist: grpcio-tools>=1.74.0; extra == "dev"
28
27
  Requires-Dist: pytest>=8.0; extra == "dev"
29
- Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
30
28
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
31
29
  Requires-Dist: mypy>=1.10; extra == "dev"
32
30
  Requires-Dist: ruff>=0.5; extra == "dev"
33
31
  Requires-Dist: grpc-stubs>=1.53; extra == "dev"
34
32
  Requires-Dist: build>=1.0; extra == "dev"
33
+ Requires-Dist: jsonschema>=4.0; extra == "dev"
35
34
  Provides-Extra: docs
36
35
  Requires-Dist: mkdocs-material>=9.5; extra == "docs"
37
36
  Requires-Dist: mkdocstrings[python]>=0.25; extra == "docs"
@@ -131,9 +130,10 @@ make setup # pip install -e ".[dev,docs]"
131
130
  make lint # ruff check
132
131
  make fmt # ruff format
133
132
  make typecheck # mypy strict
134
- make test # unit tests
133
+ make test # unit tests + coverage gate (85% branch, from pyproject)
134
+ make test-integration # integration tests (auto-skip without a running runtime)
135
135
  make test-all # lint + typecheck + all tests
136
- make coverage # coverage report
136
+ make coverage # HTML + terminal coverage report
137
137
 
138
138
  # Build
139
139
  make build # sdist + wheel
@@ -149,6 +149,12 @@ export MACP_ALLOW_INSECURE=1
149
149
  cargo run # in the runtime repo
150
150
  ```
151
151
 
152
+ Since runtime **v0.5.0**, a runtime with no authentication configured
153
+ (`MACP_AUTH_TOKENS_*` / `MACP_AUTH_ISSUER` unset) **refuses to start** unless
154
+ `MACP_ALLOW_INSECURE=1` is set, and the published Docker image **no longer
155
+ bakes that env in** — pass it explicitly for local dev (as above and in the
156
+ `docker run` command in `CLAUDE.md`).
157
+
152
158
  Dev-agent auth rides the standard `Authorization: Bearer` header — the
153
159
  runtime's `dev_authenticate` fallback binds the token value verbatim
154
160
  as the sender.
@@ -1,16 +1,15 @@
1
- grpcio>=1.81.1
2
- protobuf>=5.27.0
3
- macp-proto<0.2.0,>=0.1.3
1
+ grpcio>=1.82.0rc2
2
+ protobuf>=7.35.0
3
+ macp-proto<0.1.9,>=0.1.6
4
4
 
5
5
  [dev]
6
- grpcio-tools>=1.74.0
7
6
  pytest>=8.0
8
- pytest-asyncio>=0.24
9
7
  pytest-cov>=5.0
10
8
  mypy>=1.10
11
9
  ruff>=0.5
12
10
  grpc-stubs>=1.53
13
11
  build>=1.0
12
+ jsonschema>=4.0
14
13
 
15
14
  [docs]
16
15
  mkdocs-material>=9.5
File without changes