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 +7 -0
- macp_mcp/__main__.py +31 -0
- macp_mcp/_contract.py +118 -0
- macp_mcp/bridge.py +399 -0
- macp_mcp/config.py +124 -0
- macp_mcp/results.py +144 -0
- macp_mcp/server.py +250 -0
- macp_mcp-0.1.0.dist-info/METADATA +225 -0
- macp_mcp-0.1.0.dist-info/RECORD +13 -0
- macp_mcp-0.1.0.dist-info/WHEEL +5 -0
- macp_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- macp_mcp-0.1.0.dist-info/licenses/LICENSE +201 -0
- macp_mcp-0.1.0.dist-info/top_level.txt +1 -0
macp_mcp/__init__.py
ADDED
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
|
+
)
|