macp-mcp 0.1.0__py3-none-any.whl

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.
macp_mcp/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """macp-mcp: an MCP server for running or joining MACP Decision sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
macp_mcp/__main__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Entry point: ``macp-mcp`` / ``python -m macp_mcp`` (stdio transport)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+ from .config import ConfigError, load_settings
9
+
10
+
11
+ def main() -> None:
12
+ # stdout carries the MCP protocol; every log line must go to stderr.
13
+ logging.basicConfig(stream=sys.stderr, level=logging.WARNING)
14
+ try:
15
+ settings = load_settings()
16
+ except ConfigError as exc:
17
+ print(f"macp-mcp: {exc}", file=sys.stderr)
18
+ raise SystemExit(2) from None
19
+
20
+ from .server import build_server
21
+
22
+ print(
23
+ f"macp-mcp: agent={settings.agent_id} runtime={settings.target} "
24
+ f"auth={settings.auth_mode} tls={'off' if settings.allow_insecure else 'on'}",
25
+ file=sys.stderr,
26
+ )
27
+ build_server(settings).run("stdio")
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
macp_mcp/_contract.py ADDED
@@ -0,0 +1,118 @@
1
+ """The cross-language tool contract (``contract/tools.json``).
2
+
3
+ macp-mcp has a TypeScript twin with the identical tool surface. Both packages
4
+ test against one manifest: the tool names, descriptions, annotations, input
5
+ schemas and output schemas, plus the server instructions. Schema generators
6
+ differ, so schemas are compared after :func:`normalize_schema`, which
7
+ inlines ``$ref``, drops ``title``, and writes nullable types as a type array.
8
+ What is left is the part a client actually validates against. The manifest
9
+ is fully inlined and uses plain JSON Schema keywords only. The TypeScript
10
+ twin serves it verbatim and validates with ajv.
11
+
12
+ Regenerate after an intentional surface change with ``make contract`` and
13
+ review the diff. ``make verify-contract`` (CI) fails on drift.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import sys
21
+ from typing import Any
22
+
23
+ from mcp import Client
24
+
25
+ from .config import Settings
26
+ from .server import INSTRUCTIONS, build_server
27
+
28
+ #: Bump on any breaking change to the manifest.
29
+ CONTRACT_VERSION = "1"
30
+
31
+ #: Dialect of every schema in the manifest (stated once, not per schema).
32
+ JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
33
+
34
+ _DROPPED_KEYS = {"title", "$defs"}
35
+
36
+
37
+ def _as_nullable(schema: dict[str, Any]) -> dict[str, Any] | None:
38
+ """``{"anyOf": [{"type": T, ...}, {"type": "null"}]}`` → ``{"type": [T, "null"], ...}``.
39
+
40
+ pydantic spells ``str | None`` with ``anyOf``. The manifest uses the type
41
+ array, which every validator (ajv included) handles the same way.
42
+ """
43
+ branches = schema.get("anyOf")
44
+ if not isinstance(branches, list) or len(branches) != 2 or {"type": "null"} not in branches:
45
+ return None
46
+ other = next(b for b in branches if b != {"type": "null"})
47
+ if not isinstance(other, dict) or not isinstance(other.get("type"), str):
48
+ return None
49
+ rest = {k: v for k, v in schema.items() if k != "anyOf"}
50
+ return {**other, **rest, "type": [other["type"], "null"]}
51
+
52
+
53
+ def normalize_schema(schema: Any, defs: dict[str, Any] | None = None) -> Any:
54
+ """Inline local ``$ref``s, drop ``title`` / ``$defs``, sort keys and ``required``."""
55
+ if isinstance(schema, dict):
56
+ if defs is None:
57
+ defs = schema.get("$defs", {})
58
+ ref = schema.get("$ref")
59
+ if isinstance(ref, str) and ref.startswith("#/$defs/"):
60
+ target = dict(defs[ref.removeprefix("#/$defs/")])
61
+ siblings = {k: v for k, v in schema.items() if k != "$ref"}
62
+ return normalize_schema({**target, **siblings}, defs)
63
+ nullable = _as_nullable(schema)
64
+ if nullable is not None:
65
+ return normalize_schema(nullable, defs)
66
+ out: dict[str, Any] = {}
67
+ for key, value in sorted(schema.items()):
68
+ if key in _DROPPED_KEYS:
69
+ continue
70
+ if key == "properties" and isinstance(value, dict):
71
+ # Property names are data, not keywords: a property called
72
+ # "title" must survive.
73
+ out[key] = {p: normalize_schema(v, defs) for p, v in sorted(value.items())}
74
+ elif key == "required" and isinstance(value, list):
75
+ out[key] = sorted(value)
76
+ else:
77
+ out[key] = normalize_schema(value, defs)
78
+ return out
79
+ if isinstance(schema, list):
80
+ return [normalize_schema(v, defs) for v in schema]
81
+ return schema
82
+
83
+
84
+ async def listed_contract() -> dict[str, Any]:
85
+ """The contract as this server actually advertises it over MCP."""
86
+ async with Client(build_server(Settings())) as client:
87
+ tools = (await client.list_tools()).tools
88
+ instructions = client.instructions
89
+ return {
90
+ "contract_version": CONTRACT_VERSION,
91
+ "json_schema_dialect": JSON_SCHEMA_DIALECT,
92
+ "server_name": "macp-mcp",
93
+ "instructions": instructions if instructions is not None else INSTRUCTIONS,
94
+ "tools": [
95
+ {
96
+ "name": tool.name,
97
+ "description": tool.description,
98
+ "annotations": tool.annotations.model_dump(by_alias=True, exclude_none=True)
99
+ if tool.annotations
100
+ else {},
101
+ "inputSchema": normalize_schema(tool.input_schema),
102
+ "outputSchema": normalize_schema(tool.output_schema),
103
+ }
104
+ for tool in sorted(tools, key=lambda t: t.name)
105
+ ],
106
+ }
107
+
108
+
109
+ def render(contract: dict[str, Any]) -> str:
110
+ return json.dumps(contract, indent=2, ensure_ascii=False) + "\n"
111
+
112
+
113
+ def main() -> None:
114
+ sys.stdout.write(render(asyncio.run(listed_contract())))
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
macp_mcp/bridge.py ADDED
@@ -0,0 +1,399 @@
1
+ """The MACP side of the server: Decision-mode actions over ``AsyncMacpClient``.
2
+
3
+ The bridge is deliberately stateless. It keeps no per-session objects
4
+ between tool calls; every action is built from the session id plus what the
5
+ runtime reports (``GetSession`` metadata and the replayed, append-only
6
+ session history). That has two consequences worth knowing:
7
+
8
+ - A server that restarts mid-session loses nothing, and a server that never
9
+ started a session can still join it: knowing the ``session_id`` is enough.
10
+ - ``get_session`` and ``get_commitment`` reflect every participant's
11
+ messages, not just this agent's, because they read the runtime's history
12
+ rather than a local projection.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import binascii
19
+ import time
20
+ from collections.abc import Callable
21
+ from typing import Protocol
22
+
23
+ from macp.v1 import core_pb2, envelope_pb2
24
+ from macp_sdk import (
25
+ AsyncDecisionSession,
26
+ AsyncMacpClient,
27
+ MacpTimeoutError,
28
+ commitment_hash,
29
+ verify_commitment_hash,
30
+ )
31
+ from macp_sdk.commitment_hash import LABEL as COMMITMENT_HASH_LABEL
32
+ from macp_sdk.projections import DecisionProjection
33
+
34
+ from .config import Settings
35
+ from .results import (
36
+ AckResult,
37
+ CommitAckResult,
38
+ CommitmentResult,
39
+ CommitmentView,
40
+ ProposalAckResult,
41
+ SessionResult,
42
+ StartSessionResult,
43
+ VerifyResult,
44
+ VerifySource,
45
+ VoteAckResult,
46
+ )
47
+
48
+ MODE_DECISION = "macp.mode.decision.v1"
49
+
50
+ #: Cap on a single replay, so a busy open session cannot pin a tool call.
51
+ REPLAY_MAX_SECONDS = 15.0
52
+
53
+
54
+ class _Stream(Protocol):
55
+ async def send_subscribe(self, session_id: str, after_sequence: int = 0) -> None: ...
56
+
57
+ async def read(self, timeout: float | None = None) -> envelope_pb2.Envelope | None: ...
58
+
59
+ async def __aenter__(self) -> _Stream: ...
60
+
61
+ async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None: ...
62
+
63
+
64
+ ClientFactory = Callable[[Settings], AsyncMacpClient]
65
+
66
+
67
+ def default_client_factory(settings: Settings) -> AsyncMacpClient:
68
+ """Build the SDK's asyncio client from settings (call inside the event loop)."""
69
+ return AsyncMacpClient(
70
+ target=settings.target,
71
+ allow_insecure=settings.allow_insecure,
72
+ auth=settings.auth,
73
+ root_certificates=settings.root_certificates,
74
+ client_name="macp-mcp",
75
+ )
76
+
77
+
78
+ def _state_name(state: int) -> str:
79
+ name = envelope_pb2.SessionState.Name(state)
80
+ return name.removeprefix("SESSION_STATE_")
81
+
82
+
83
+ def _ack_dict(ack: envelope_pb2.Ack) -> AckResult:
84
+ return {
85
+ "ok": ack.ok,
86
+ "duplicate": ack.duplicate,
87
+ "message_id": ack.message_id,
88
+ "session_id": ack.session_id,
89
+ "session_state": _state_name(ack.session_state),
90
+ }
91
+
92
+
93
+ def _commitment_dict(payload: core_pb2.CommitmentPayload) -> CommitmentView:
94
+ out: CommitmentView = {
95
+ "commitment_id": payload.commitment_id,
96
+ "action": payload.action,
97
+ "authority_scope": payload.authority_scope,
98
+ "reason": payload.reason,
99
+ "outcome_positive": payload.outcome_positive,
100
+ "mode_version": payload.mode_version,
101
+ "policy_version": payload.policy_version,
102
+ "configuration_version": payload.configuration_version,
103
+ }
104
+ if payload.HasField("supersedes"):
105
+ out["supersedes"] = {
106
+ "session_id": payload.supersedes.session_id,
107
+ "commitment_hash": payload.supersedes.commitment_hash,
108
+ }
109
+ return out
110
+
111
+
112
+ class MacpBridge:
113
+ """Decision-mode actions for one MACP agent identity."""
114
+
115
+ def __init__(
116
+ self,
117
+ settings: Settings,
118
+ client_factory: ClientFactory = default_client_factory,
119
+ clock: Callable[[], float] = time.monotonic,
120
+ ) -> None:
121
+ self.settings = settings
122
+ self._client_factory = client_factory
123
+ self._client: AsyncMacpClient | None = None
124
+ self._clock = clock
125
+
126
+ @property
127
+ def client(self) -> AsyncMacpClient:
128
+ # Created lazily so it binds to the server's running event loop
129
+ # (grpc.aio channels bind to the loop current at creation).
130
+ if self._client is None:
131
+ self._client = self._client_factory(self.settings)
132
+ return self._client
133
+
134
+ async def aclose(self) -> None:
135
+ if self._client is not None:
136
+ await self._client.close()
137
+ self._client = None
138
+
139
+ def _session(
140
+ self, session_id: str, metadata: core_pb2.SessionMetadata | None = None
141
+ ) -> AsyncDecisionSession:
142
+ if metadata is None:
143
+ return AsyncDecisionSession(self.client, session_id=session_id)
144
+ return AsyncDecisionSession(
145
+ self.client,
146
+ session_id=session_id,
147
+ mode_version=metadata.mode_version,
148
+ configuration_version=metadata.configuration_version,
149
+ policy_version=metadata.policy_version,
150
+ )
151
+
152
+ async def _metadata(self, session_id: str) -> core_pb2.SessionMetadata:
153
+ return (await self.client.get_session(session_id)).metadata
154
+
155
+ # -- history ----------------------------------------------------------
156
+
157
+ async def replay(self, session_id: str) -> list[envelope_pb2.Envelope]:
158
+ """Return the session's accepted history, oldest first.
159
+
160
+ Opens a ``StreamSession`` and sends a subscribe frame with
161
+ ``after_sequence=0`` (RFC-MACP-0006-A1), which replays every accepted
162
+ envelope and then switches to live delivery. The replay is treated
163
+ as complete once a ``Commitment`` arrives (nothing follows a terminal
164
+ message) or no envelope arrives for ``replay_idle_ms``.
165
+ """
166
+ idle = self.settings.replay_idle_ms / 1000.0
167
+ deadline = self._clock() + REPLAY_MAX_SECONDS
168
+ history: list[envelope_pb2.Envelope] = []
169
+ seen: set[str] = set()
170
+ stream: _Stream = self.client.open_stream()
171
+ async with stream:
172
+ await stream.send_subscribe(session_id, after_sequence=0)
173
+ while self._clock() < deadline:
174
+ try:
175
+ envelope = await stream.read(timeout=idle)
176
+ except MacpTimeoutError:
177
+ break
178
+ if envelope is None:
179
+ break
180
+ if envelope.session_id != session_id or envelope.message_id in seen:
181
+ continue
182
+ seen.add(envelope.message_id)
183
+ history.append(envelope)
184
+ if envelope.message_type == "Commitment":
185
+ break
186
+ return history
187
+
188
+ @staticmethod
189
+ def _find_commitment(history: list[envelope_pb2.Envelope]) -> envelope_pb2.Envelope | None:
190
+ for envelope in history:
191
+ if envelope.message_type == "Commitment":
192
+ return envelope
193
+ return None
194
+
195
+ # -- tools ------------------------------------------------------------
196
+
197
+ async def start_session(
198
+ self,
199
+ intent: str,
200
+ participants: list[str],
201
+ ttl_ms: int = 0,
202
+ context_id: str = "",
203
+ ) -> StartSessionResult:
204
+ members = list(dict.fromkeys(p.strip() for p in participants if p.strip()))
205
+ if self.settings.agent_id not in members:
206
+ members.insert(0, self.settings.agent_id)
207
+ session = self._session("")
208
+ ack = await session.start(
209
+ intent=intent,
210
+ participants=members,
211
+ ttl_ms=ttl_ms or self.settings.default_ttl_ms,
212
+ context_id=context_id,
213
+ )
214
+ return {
215
+ **_ack_dict(ack),
216
+ "session_id": session.session_id,
217
+ "mode": MODE_DECISION,
218
+ "initiator": self.settings.agent_id,
219
+ "participants": members,
220
+ "ttl_ms": ttl_ms or self.settings.default_ttl_ms,
221
+ }
222
+
223
+ async def propose(
224
+ self, session_id: str, proposal_id: str, option: str, rationale: str = ""
225
+ ) -> ProposalAckResult:
226
+ ack = await self._session(session_id).propose(proposal_id, option, rationale=rationale)
227
+ return {**_ack_dict(ack), "proposal_id": proposal_id, "sender": self.settings.agent_id}
228
+
229
+ async def evaluate(
230
+ self,
231
+ session_id: str,
232
+ proposal_id: str,
233
+ recommendation: str,
234
+ confidence: float,
235
+ reason: str = "",
236
+ ) -> ProposalAckResult:
237
+ ack = await self._session(session_id).evaluate(
238
+ proposal_id, recommendation, confidence=confidence, reason=reason
239
+ )
240
+ return {**_ack_dict(ack), "proposal_id": proposal_id, "sender": self.settings.agent_id}
241
+
242
+ async def vote(
243
+ self, session_id: str, proposal_id: str, vote: str, reason: str = ""
244
+ ) -> VoteAckResult:
245
+ ack = await self._session(session_id).vote(proposal_id, vote, reason=reason)
246
+ return {
247
+ **_ack_dict(ack),
248
+ "proposal_id": proposal_id,
249
+ "vote": vote.upper(),
250
+ "sender": self.settings.agent_id,
251
+ }
252
+
253
+ async def commit(
254
+ self,
255
+ session_id: str,
256
+ action: str,
257
+ authority_scope: str,
258
+ reason: str,
259
+ outcome_positive: bool = True,
260
+ ) -> CommitAckResult:
261
+ # Bind the commitment to the versions the session was started with,
262
+ # so a joining committer cannot drift from the initiator's binding.
263
+ metadata = await self._metadata(session_id)
264
+ ack = await self._session(session_id, metadata).commit(
265
+ action=action,
266
+ authority_scope=authority_scope,
267
+ reason=reason,
268
+ outcome_positive=outcome_positive,
269
+ )
270
+ return {**_ack_dict(ack), "sender": self.settings.agent_id}
271
+
272
+ async def get_session(self, session_id: str) -> SessionResult:
273
+ metadata = await self._metadata(session_id)
274
+ history = await self.replay(session_id)
275
+ projection = DecisionProjection()
276
+ for envelope in history:
277
+ projection.apply_envelope(envelope)
278
+ winner = projection.majority_winner()
279
+ return {
280
+ "session_id": metadata.session_id,
281
+ "mode": metadata.mode,
282
+ "state": _state_name(metadata.state),
283
+ "initiator": metadata.initiator,
284
+ "participants": list(metadata.participants),
285
+ "mode_version": metadata.mode_version,
286
+ "configuration_version": metadata.configuration_version,
287
+ "policy_version": metadata.policy_version,
288
+ "started_at_unix_ms": metadata.started_at_unix_ms,
289
+ "expires_at_unix_ms": metadata.expires_at_unix_ms,
290
+ "you": self.settings.agent_id,
291
+ "phase": projection.phase,
292
+ "proposals": [
293
+ {
294
+ "proposal_id": p.proposal_id,
295
+ "option": p.option,
296
+ "rationale": p.rationale,
297
+ "sender": p.sender,
298
+ }
299
+ for p in projection.proposals.values()
300
+ ],
301
+ "evaluations": [
302
+ {
303
+ "proposal_id": e.proposal_id,
304
+ "recommendation": e.recommendation,
305
+ "confidence": e.confidence,
306
+ "reason": e.reason,
307
+ "sender": e.sender,
308
+ }
309
+ for e in projection.evaluations
310
+ ],
311
+ "objections": [
312
+ {
313
+ "proposal_id": o.proposal_id,
314
+ "reason": o.reason,
315
+ "severity": o.severity,
316
+ "sender": o.sender,
317
+ }
318
+ for o in projection.objections
319
+ ],
320
+ "votes": [
321
+ {"proposal_id": pid, "vote": v.vote, "reason": v.reason, "sender": v.sender}
322
+ for pid, by_sender in projection.votes.items()
323
+ for v in by_sender.values()
324
+ ],
325
+ "approve_totals": projection.vote_totals(),
326
+ "majority_winner": winner,
327
+ "blocking_objection": projection.has_blocking_objection(winner),
328
+ "committed": projection.is_committed,
329
+ "history": [
330
+ {"message_type": e.message_type, "sender": e.sender, "message_id": e.message_id}
331
+ for e in history
332
+ ],
333
+ }
334
+
335
+ async def get_commitment(self, session_id: str) -> CommitmentResult:
336
+ metadata = await self._metadata(session_id)
337
+ envelope = self._find_commitment(await self.replay(session_id))
338
+ if envelope is None:
339
+ return {
340
+ "session_id": session_id,
341
+ "state": _state_name(metadata.state),
342
+ "found": False,
343
+ }
344
+ payload = core_pb2.CommitmentPayload()
345
+ payload.ParseFromString(envelope.payload)
346
+ return {
347
+ "session_id": session_id,
348
+ "state": _state_name(metadata.state),
349
+ "found": True,
350
+ "sender": envelope.sender,
351
+ "message_id": envelope.message_id,
352
+ "commitment": _commitment_dict(payload),
353
+ "payload_b64": base64.b64encode(envelope.payload).decode("ascii"),
354
+ "commitment_hash": commitment_hash(payload),
355
+ "hash_label": COMMITMENT_HASH_LABEL,
356
+ }
357
+
358
+ async def verify(
359
+ self,
360
+ commitment_hash_claim: str,
361
+ session_id: str = "",
362
+ payload_b64: str = "",
363
+ ) -> VerifyResult:
364
+ """Recompute the RFC-MACP-0013 hash and compare it with a claim.
365
+
366
+ Exactly one source of payload bytes is used: ``payload_b64`` (bytes
367
+ the caller already holds) or ``session_id`` (bytes re-fetched from
368
+ the runtime's history, independent of any earlier tool result).
369
+ """
370
+ if bool(session_id) == bool(payload_b64):
371
+ raise ValueError("pass exactly one of session_id or payload_b64")
372
+ if payload_b64:
373
+ try:
374
+ wire = base64.b64decode(payload_b64, validate=True)
375
+ except (binascii.Error, ValueError):
376
+ raise ValueError("payload_b64 is not valid base64") from None
377
+ source: VerifySource = "payload_b64"
378
+ else:
379
+ envelope = self._find_commitment(await self.replay(session_id))
380
+ if envelope is None:
381
+ raise ValueError(f"session {session_id} has no Commitment in its history")
382
+ wire = envelope.payload
383
+ source = "runtime_history"
384
+ outcome = verify_commitment_hash(wire, commitment_hash_claim)
385
+ recomputed: str | None = None
386
+ if outcome != "cannot_verify":
387
+ payload = core_pb2.CommitmentPayload()
388
+ payload.ParseFromString(wire)
389
+ recomputed = commitment_hash(payload)
390
+ result: VerifyResult = {
391
+ "outcome": outcome,
392
+ "claimed_hash": commitment_hash_claim,
393
+ "recomputed_hash": recomputed,
394
+ "hash_label": COMMITMENT_HASH_LABEL,
395
+ "source": source,
396
+ }
397
+ if session_id:
398
+ result["session_id"] = session_id
399
+ return result
macp_mcp/config.py ADDED
@@ -0,0 +1,124 @@
1
+ """Environment-driven configuration for the macp-mcp server.
2
+
3
+ One server process is one MACP agent. Every setting comes from the
4
+ environment, because MCP clients (Claude Desktop, Claude Code, Cursor)
5
+ launch the server as a subprocess and pass configuration through an ``env``
6
+ block, not flags.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+
15
+ from macp_sdk import AuthConfig
16
+
17
+ DEFAULT_TARGET = "127.0.0.1:50051"
18
+ DEFAULT_AGENT_ID = "mcp-agent"
19
+ DEFAULT_TTL_MS = 300_000
20
+ DEFAULT_REPLAY_IDLE_MS = 1_000
21
+
22
+ _TRUE = {"1", "true", "yes", "on"}
23
+
24
+
25
+ class ConfigError(ValueError):
26
+ """Raised when the environment holds an unusable configuration."""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Settings:
31
+ """Resolved server settings.
32
+
33
+ :param target: runtime gRPC address (``host:port``).
34
+ :param agent_id: the MACP identity this server acts as. With dev-agent
35
+ auth it is also the bearer token; with a real token it is the
36
+ identity the runtime binds that token to.
37
+ :param token: bearer token issued by the runtime, or ``None`` for
38
+ dev-agent auth (local runtimes started with ``MACP_ALLOW_INSECURE=1``).
39
+ :param allow_insecure: use a plaintext channel instead of TLS.
40
+ :param root_certificates: PEM bundle for a private CA, or ``None`` for the
41
+ system trust store.
42
+ :param default_ttl_ms: TTL used by ``start_session`` when the caller
43
+ does not pass one.
44
+ :param replay_idle_ms: how long a history replay waits for the next
45
+ envelope before treating the replay as complete.
46
+ """
47
+
48
+ target: str = DEFAULT_TARGET
49
+ agent_id: str = DEFAULT_AGENT_ID
50
+ token: str | None = None
51
+ allow_insecure: bool = False
52
+ root_certificates: bytes | None = None
53
+ default_ttl_ms: int = DEFAULT_TTL_MS
54
+ replay_idle_ms: int = DEFAULT_REPLAY_IDLE_MS
55
+
56
+ @property
57
+ def auth(self) -> AuthConfig:
58
+ """The SDK auth config for this agent.
59
+
60
+ A token selects ``AuthConfig.for_bearer`` bound to ``agent_id``;
61
+ no token selects ``AuthConfig.for_dev_agent``, whose bearer value is
62
+ the agent id itself (the runtime's dev fallback binds it verbatim).
63
+ """
64
+ if self.token:
65
+ return AuthConfig.for_bearer(self.token, expected_sender=self.agent_id)
66
+ return AuthConfig.for_dev_agent(self.agent_id)
67
+
68
+ @property
69
+ def auth_mode(self) -> str:
70
+ return "bearer" if self.token else "dev-agent"
71
+
72
+
73
+ def _int(env: Mapping[str, str], name: str, default: int) -> int:
74
+ raw = env.get(name, "").strip()
75
+ if not raw:
76
+ return default
77
+ try:
78
+ value = int(raw)
79
+ except ValueError:
80
+ raise ConfigError(f"{name} must be an integer, got {raw!r}") from None
81
+ if value <= 0:
82
+ raise ConfigError(f"{name} must be positive, got {value}")
83
+ return value
84
+
85
+
86
+ def load_settings(env: Mapping[str, str] | None = None) -> Settings:
87
+ """Build :class:`Settings` from ``env`` (defaults to ``os.environ``).
88
+
89
+ Recognised variables:
90
+
91
+ - ``MACP_RUNTIME_TARGET``: runtime address (default ``127.0.0.1:50051``).
92
+ - ``MACP_AGENT_ID``: this agent's identity (default ``mcp-agent``).
93
+ - ``MACP_AUTH_TOKEN``: bearer token; unset means dev-agent auth.
94
+ - ``MACP_ALLOW_INSECURE``: ``1`` for a plaintext channel (local dev only).
95
+ - ``MACP_TLS_CA_FILE``: PEM file for a private CA.
96
+ - ``MACP_DEFAULT_TTL_MS``: default session TTL (default 300000).
97
+ - ``MACP_REPLAY_IDLE_MS``: replay idle cutoff (default 1000).
98
+ """
99
+ env = os.environ if env is None else env
100
+ target = env.get("MACP_RUNTIME_TARGET", "").strip() or DEFAULT_TARGET
101
+ agent_id = env.get("MACP_AGENT_ID", "").strip() or DEFAULT_AGENT_ID
102
+ token = env.get("MACP_AUTH_TOKEN", "").strip() or None
103
+ allow_insecure = env.get("MACP_ALLOW_INSECURE", "").strip().lower() in _TRUE
104
+
105
+ root_certificates: bytes | None = None
106
+ ca_file = env.get("MACP_TLS_CA_FILE", "").strip()
107
+ if ca_file:
108
+ if allow_insecure:
109
+ raise ConfigError("MACP_TLS_CA_FILE and MACP_ALLOW_INSECURE are mutually exclusive")
110
+ try:
111
+ with open(ca_file, "rb") as fh:
112
+ root_certificates = fh.read()
113
+ except OSError as exc:
114
+ raise ConfigError(f"cannot read MACP_TLS_CA_FILE {ca_file!r}: {exc}") from None
115
+
116
+ return Settings(
117
+ target=target,
118
+ agent_id=agent_id,
119
+ token=token,
120
+ allow_insecure=allow_insecure,
121
+ root_certificates=root_certificates,
122
+ default_ttl_ms=_int(env, "MACP_DEFAULT_TTL_MS", DEFAULT_TTL_MS),
123
+ replay_idle_ms=_int(env, "MACP_REPLAY_IDLE_MS", DEFAULT_REPLAY_IDLE_MS),
124
+ )