mrpd 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.
Files changed (51) hide show
  1. mrpd/__init__.py +1 -0
  2. mrpd/adapters/__init__.py +1 -0
  3. mrpd/api/app.py +8 -0
  4. mrpd/api/routes.py +200 -0
  5. mrpd/cli.py +182 -0
  6. mrpd/commands/__init__.py +1 -0
  7. mrpd/commands/bridge_mcp.py +189 -0
  8. mrpd/commands/bridge_openapi.py +279 -0
  9. mrpd/commands/init_provider.py +176 -0
  10. mrpd/commands/mrpify_mcp.py +80 -0
  11. mrpd/commands/mrpify_openapi.py +80 -0
  12. mrpd/commands/publish.py +102 -0
  13. mrpd/commands/route.py +126 -0
  14. mrpd/commands/run.py +169 -0
  15. mrpd/commands/serve.py +13 -0
  16. mrpd/commands/validate.py +71 -0
  17. mrpd/core/__init__.py +1 -0
  18. mrpd/core/artifacts.py +39 -0
  19. mrpd/core/config.py +26 -0
  20. mrpd/core/defaults.py +7 -0
  21. mrpd/core/envelopes.py +29 -0
  22. mrpd/core/errors.py +37 -0
  23. mrpd/core/evidence.py +17 -0
  24. mrpd/core/models.py +37 -0
  25. mrpd/core/provider.py +100 -0
  26. mrpd/core/registry.py +115 -0
  27. mrpd/core/schema.py +64 -0
  28. mrpd/core/scoring.py +92 -0
  29. mrpd/core/util.py +27 -0
  30. mrpd/spec/__init__.py +1 -0
  31. mrpd/spec/fixtures/README.md +8 -0
  32. mrpd/spec/fixtures/invalid/bad_msg_type.json +8 -0
  33. mrpd/spec/fixtures/invalid/missing_required_fields.json +7 -0
  34. mrpd/spec/fixtures/valid/discover.json +13 -0
  35. mrpd/spec/fixtures/valid/error.json +13 -0
  36. mrpd/spec/fixtures/valid/offer.json +20 -0
  37. mrpd/spec/schemas/envelope.schema.json +102 -0
  38. mrpd/spec/schemas/manifest.schema.json +52 -0
  39. mrpd/spec/schemas/payloads/discover.schema.json +23 -0
  40. mrpd/spec/schemas/payloads/error.schema.json +15 -0
  41. mrpd/spec/schemas/payloads/evidence.schema.json +17 -0
  42. mrpd/spec/schemas/payloads/execute.schema.json +14 -0
  43. mrpd/spec/schemas/payloads/negotiate.schema.json +15 -0
  44. mrpd/spec/schemas/payloads/offer.schema.json +30 -0
  45. mrpd/spec/schemas/types/artifact.schema.json +15 -0
  46. mrpd/spec/schemas/types/input.schema.json +20 -0
  47. mrpd-0.1.0.dist-info/METADATA +104 -0
  48. mrpd-0.1.0.dist-info/RECORD +51 -0
  49. mrpd-0.1.0.dist-info/WHEEL +5 -0
  50. mrpd-0.1.0.dist-info/entry_points.txt +2 -0
  51. mrpd-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import time
6
+ from urllib.parse import urlparse
7
+
8
+ import httpx
9
+ import typer
10
+
11
+ from mrpd.core.defaults import MRP_DEFAULT_REGISTRY_BASE
12
+
13
+
14
+ def publish(
15
+ manifest_url: str,
16
+ registry: str | None,
17
+ poll_seconds: float,
18
+ ) -> None:
19
+ """Self-register a provider in the public registry using HTTP-01 domain control.
20
+
21
+ Flow:
22
+ 1) POST /mrp/registry/submit {manifest_url}
23
+ 2) Operator publishes challenge at /.well-known/mrp-registry-challenge/<token>
24
+ 3) POST /mrp/registry/verify {token}
25
+
26
+ This is intentionally zero-trust: registry verifies domain control + manifest reachability.
27
+ """
28
+
29
+ async def _run() -> int:
30
+ base = (registry or MRP_DEFAULT_REGISTRY_BASE).rstrip("/")
31
+ submit_url = f"{base}/mrp/registry/submit"
32
+ verify_url = f"{base}/mrp/registry/verify"
33
+
34
+ # sanity check
35
+ try:
36
+ u = urlparse(manifest_url)
37
+ if u.scheme not in ("http", "https") or not u.netloc:
38
+ raise ValueError("manifest_url must be an http(s) URL")
39
+ except Exception as e:
40
+ typer.echo(f"Invalid manifest_url: {e}")
41
+ return 2
42
+
43
+ async with httpx.AsyncClient(timeout=20.0, follow_redirects=False) as client:
44
+ r = await client.post(
45
+ submit_url,
46
+ json={"manifest_url": manifest_url},
47
+ headers={"Content-Type": "application/json", "Accept": "application/mrp+json, application/json"},
48
+ )
49
+ if r.status_code >= 400:
50
+ typer.echo(f"Submit failed ({r.status_code}): {r.text}")
51
+ return 1
52
+
53
+ data = r.json()
54
+ challenge = (data.get("challenge") or {})
55
+ token = data.get("token")
56
+ expected = challenge.get("expected")
57
+ path = challenge.get("path")
58
+ url = challenge.get("url")
59
+
60
+ if not token or not expected or not path or not url:
61
+ typer.echo("Registry returned malformed challenge:")
62
+ typer.echo(json.dumps(data, indent=2))
63
+ return 1
64
+
65
+ typer.echo("\n=== MRP Registry HTTP-01 Challenge ===")
66
+ typer.echo(f"Registry: {base}")
67
+ typer.echo(f"Manifest: {manifest_url}")
68
+ typer.echo("")
69
+ typer.echo("Create a public file at:")
70
+ typer.echo(f" {path}")
71
+ typer.echo("So that this URL returns EXACTLY this string:")
72
+ typer.echo(f" {url}")
73
+ typer.echo("")
74
+ typer.echo(expected)
75
+ typer.echo("")
76
+ typer.echo("When ready, I will verify and publish the entry.")
77
+
78
+ # Poll verify
79
+ while True:
80
+ vr = await client.post(
81
+ verify_url,
82
+ json={"token": token},
83
+ headers={"Content-Type": "application/json", "Accept": "application/mrp+json, application/json"},
84
+ )
85
+
86
+ if vr.status_code == 200:
87
+ out = vr.json()
88
+ typer.echo("\n✅ Verified and published:")
89
+ typer.echo(json.dumps(out.get("entry") or out, indent=2, ensure_ascii=False))
90
+ return 0
91
+
92
+ try:
93
+ out = vr.json()
94
+ except Exception:
95
+ out = {"error": vr.text}
96
+
97
+ err = out.get("error") or out.get("message") or vr.text
98
+ typer.echo(f"Waiting… ({err})")
99
+
100
+ await asyncio.sleep(poll_seconds)
101
+
102
+ raise typer.Exit(code=asyncio.run(_run()))
mrpd/commands/route.py ADDED
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ import typer
6
+
7
+ from mrpd.core.registry import RegistryClient, fetch_manifest
8
+ from mrpd.core.scoring import ScoreResult, rank_entries
9
+
10
+
11
+ def route(
12
+ intent: str,
13
+ capability: str | None,
14
+ policy: str | None,
15
+ registry: str | None,
16
+ limit: int,
17
+ bootstrap_raw: str | None,
18
+ ) -> None:
19
+ """Discover candidates for an intent from the registry and print ranked results.
20
+
21
+ v0: discovery + ranking + manifest fetch.
22
+ v1: negotiate/execute.
23
+ """
24
+
25
+ def loss_reason(winner: ScoreResult, candidate: ScoreResult) -> str:
26
+ if candidate.missing:
27
+ return f"missing requirements: {', '.join(candidate.missing)}"
28
+ if candidate.score != winner.score:
29
+ return f"lower score ({candidate.score:.2f} vs {winner.score:.2f})"
30
+ if candidate.required_matches != winner.required_matches:
31
+ return "fewer required matches"
32
+ if candidate.trust_score != winner.trust_score:
33
+ return "lower trust score"
34
+ if candidate.proofs_count != winner.proofs_count:
35
+ return "fewer proofs"
36
+ if (candidate.entry.name or "").lower() != (winner.entry.name or "").lower():
37
+ return "tiebreaker: name order"
38
+ return "tiebreaker: id order"
39
+
40
+ async def _run() -> int:
41
+ if bootstrap_raw:
42
+ import os
43
+
44
+ os.environ["MRP_BOOTSTRAP_REGISTRY_RAW"] = bootstrap_raw
45
+ client = RegistryClient(base_url=registry) if registry else RegistryClient()
46
+ try:
47
+ res = await client.query(capability=capability, policy=policy, limit=limit)
48
+ except Exception as ex:
49
+ typer.echo(f"Registry query failed: {ex}")
50
+ return 1
51
+
52
+ entries = list(res.results)
53
+ if not entries and (capability or policy):
54
+ try:
55
+ res = await client.query(limit=limit)
56
+ entries = list(res.results)
57
+ except Exception as ex:
58
+ typer.echo(f"Registry query failed: {ex}")
59
+ return 1
60
+
61
+ if not entries:
62
+ typer.echo("No registry entries matched (and entries must include manifest_url).")
63
+ return 1
64
+
65
+ ranked = rank_entries(entries, capability=capability, policy=policy)
66
+ satisfying = [r for r in ranked if r.satisfied]
67
+
68
+ typer.echo(f"Intent: {intent}")
69
+ if capability:
70
+ typer.echo(f"Filter capability: {capability}")
71
+ if policy:
72
+ typer.echo(f"Filter policy: {policy}")
73
+ typer.echo("")
74
+
75
+ if satisfying:
76
+ winner = satisfying[0]
77
+ typer.echo(
78
+ f"Winner: score={winner.score:.2f} id={winner.entry.id} name={winner.entry.name}"
79
+ )
80
+ winner_reason = ", ".join(winner.reasons) if winner.reasons else "best tiebreaker"
81
+ typer.echo(f"Why winner won: {winner_reason}")
82
+ typer.echo(f"Manifest: {winner.entry.manifest_url}")
83
+ if winner.entry.repo:
84
+ typer.echo(f"Repo: {winner.entry.repo}")
85
+ try:
86
+ manifest = await fetch_manifest(winner.entry.manifest_url)
87
+ caps = manifest.get("capability") or manifest.get("capability_id")
88
+ typer.echo(f"Manifest capability: {caps}")
89
+ endpoints = manifest.get("endpoints") or {}
90
+ if endpoints:
91
+ typer.echo(f"Endpoints: {endpoints}")
92
+ except Exception as ex:
93
+ typer.echo(f"Manifest fetch FAILED: {ex}")
94
+ typer.echo("")
95
+
96
+ for r in satisfying[1:limit]:
97
+ typer.echo(f"- score={r.score:.2f} id={r.entry.id} name={r.entry.name}")
98
+ typer.echo(f" why lost: {loss_reason(winner, r)}")
99
+ typer.echo(f" manifest: {r.entry.manifest_url}")
100
+ if r.entry.repo:
101
+ typer.echo(f" repo: {r.entry.repo}")
102
+ try:
103
+ manifest = await fetch_manifest(r.entry.manifest_url)
104
+ caps = manifest.get("capability") or manifest.get("capability_id")
105
+ typer.echo(f" manifest.capability: {caps}")
106
+ endpoints = manifest.get("endpoints") or {}
107
+ if endpoints:
108
+ typer.echo(f" endpoints: {endpoints}")
109
+ except Exception as ex:
110
+ typer.echo(f" manifest fetch FAILED: {ex}")
111
+ typer.echo("")
112
+ else:
113
+ typer.echo("No candidates satisfied the requested requirements. Near-miss list:")
114
+ typer.echo("")
115
+ for r in ranked[:limit]:
116
+ missing = ", ".join(r.missing) if r.missing else "none"
117
+ typer.echo(f"- score={r.score:.2f} id={r.entry.id} name={r.entry.name}")
118
+ typer.echo(f" missing: {missing}")
119
+ typer.echo(f" manifest: {r.entry.manifest_url}")
120
+ if r.entry.repo:
121
+ typer.echo(f" repo: {r.entry.repo}")
122
+ typer.echo("")
123
+
124
+ return 0
125
+
126
+ raise typer.Exit(code=asyncio.run(_run()))
mrpd/commands/run.py ADDED
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import uuid
6
+
7
+ import httpx
8
+ import typer
9
+
10
+ from mrpd.core.defaults import MRP_DEFAULT_REGISTRY_BASE
11
+ from mrpd.core.envelopes import mk_envelope
12
+ from mrpd.core.evidence import write_evidence_bundle
13
+ from mrpd.core.registry import RegistryClient, fetch_manifest, normalize_manifest_endpoints
14
+ from mrpd.core.scoring import rank_entries
15
+ from mrpd.core.util import utc_now_rfc3339
16
+
17
+
18
+ def run(
19
+ intent: str,
20
+ url: str,
21
+ capability: str,
22
+ policy: str | None,
23
+ registry: str | None,
24
+ manifest_url: str | None,
25
+ max_tokens: int | None,
26
+ max_cost: float | None,
27
+ ) -> None:
28
+ """End-to-end demo: query registry -> discover -> execute -> print evidence.
29
+
30
+ v0: expects provider implements /mrp/discover and /mrp/execute per manifest endpoints.
31
+ """
32
+
33
+ async def _run() -> int:
34
+ manifest: dict
35
+ receiver_id: str | None = None
36
+ if manifest_url:
37
+ typer.echo("Fetching manifest...")
38
+ manifest = await fetch_manifest(manifest_url)
39
+ manifest = normalize_manifest_endpoints(manifest, manifest_url)
40
+ # For the built-in demo provider, use a stable receiver id.
41
+ receiver_id = "service:mrpd"
42
+ else:
43
+ client = RegistryClient(base_url=registry) if registry else RegistryClient(base_url=MRP_DEFAULT_REGISTRY_BASE)
44
+ typer.echo("Querying registry...")
45
+ res = await client.query(capability=capability, policy=policy, limit=25)
46
+
47
+ ranked = rank_entries(res.results, capability=capability, policy=policy)
48
+ satisfying = [r for r in ranked if r.satisfied]
49
+ if not ranked:
50
+ typer.echo("No registry entries matched (requires manifest_url entries).")
51
+ typer.echo("Tip: use --manifest-url http://host/mrp/manifest for local testing.")
52
+ return 1
53
+ if not satisfying:
54
+ typer.echo("No registry entries satisfied required capability/policy.")
55
+ for r in ranked[:5]:
56
+ missing = ", ".join(r.missing) if r.missing else "none"
57
+ typer.echo(f"- score={r.score:.2f} id={r.entry.id} missing: {missing}")
58
+ return 1
59
+
60
+ # Pick top
61
+ entry = satisfying[0].entry
62
+ receiver_id = entry.id
63
+ typer.echo(f"Selected entry: {entry.id} ({entry.name})")
64
+ typer.echo("Fetching manifest...")
65
+ manifest = await fetch_manifest(entry.manifest_url)
66
+ manifest = normalize_manifest_endpoints(manifest, entry.manifest_url)
67
+
68
+ endpoints = manifest.get("endpoints") or {}
69
+ discover_url = endpoints.get("discover")
70
+ execute_url = endpoints.get("execute")
71
+ if not discover_url or not execute_url:
72
+ typer.echo("Selected manifest is missing endpoints.discover/execute")
73
+ return 1
74
+
75
+ # DISCOVER
76
+ typer.echo("Sending DISCOVER...")
77
+ discover_payload: dict = {
78
+ "intent": intent,
79
+ "inputs": [{"type": "url", "value": url}],
80
+ "constraints": {},
81
+ }
82
+ if max_cost is not None:
83
+ discover_payload["constraints"]["max_cost"] = max_cost
84
+ if policy:
85
+ discover_payload["constraints"]["policy"] = [policy]
86
+ # token budget is not in the core schema yet; put it in constraints extension
87
+ if max_tokens is not None:
88
+ discover_payload["constraints"]["max_context_tokens"] = max_tokens
89
+
90
+ discover_env = mk_envelope("DISCOVER", discover_payload, receiver_id=receiver_id)
91
+
92
+ async with httpx.AsyncClient(timeout=20.0, follow_redirects=False) as http:
93
+ r = await http.post(discover_url, json=discover_env, headers={"Content-Type": "application/mrp+json"})
94
+ r.raise_for_status()
95
+ offer_env = r.json()
96
+
97
+ offers = (offer_env.get("payload") or {}).get("offers") or []
98
+ if not offers:
99
+ typer.echo("No offers returned.")
100
+ typer.echo(json.dumps(offer_env, indent=2))
101
+ return 1
102
+
103
+ offer = offers[0]
104
+ route_id = offer.get("route_id")
105
+ if not route_id:
106
+ typer.echo("Offer missing route_id")
107
+ return 1
108
+
109
+ # EXECUTE
110
+ typer.echo("Sending EXECUTE...")
111
+ job_id = str(uuid.uuid4())
112
+ exec_payload = {
113
+ "route_id": route_id,
114
+ "inputs": [{"type": "url", "value": url}],
115
+ "output_format": "markdown",
116
+ "job": {"id": job_id, "intent": intent},
117
+ }
118
+ exec_env = mk_envelope("EXECUTE", exec_payload, receiver_id=receiver_id)
119
+
120
+ async with httpx.AsyncClient(timeout=60.0, follow_redirects=False) as http:
121
+ r = await http.post(execute_url, json=exec_env, headers={"Content-Type": "application/mrp+json"})
122
+ r.raise_for_status()
123
+ out = r.json()
124
+
125
+ typer.echo("Received evidence.")
126
+
127
+ payload = out.get("payload") or {}
128
+ response_job_id = payload.get("job_id") or job_id
129
+ outputs = payload.get("outputs") or []
130
+ artifact_refs = [o for o in outputs if isinstance(o, dict) and o.get("type") == "artifact"]
131
+
132
+ def envelope_meta(env: dict) -> dict:
133
+ return {
134
+ "msg_id": env.get("msg_id"),
135
+ "msg_type": env.get("msg_type"),
136
+ "timestamp": env.get("timestamp"),
137
+ "sender": env.get("sender"),
138
+ "receiver": env.get("receiver"),
139
+ "in_reply_to": env.get("in_reply_to"),
140
+ }
141
+
142
+ bundle = {
143
+ "job_id": response_job_id,
144
+ "created_at": utc_now_rfc3339(),
145
+ "transcript": {
146
+ "intent": intent,
147
+ "capability": capability,
148
+ "policy": policy,
149
+ "discover": {
150
+ "endpoint": discover_url,
151
+ "request": envelope_meta(discover_env),
152
+ "response": envelope_meta(offer_env),
153
+ },
154
+ "execute": {
155
+ "endpoint": execute_url,
156
+ "request": envelope_meta(exec_env),
157
+ "response": envelope_meta(out),
158
+ },
159
+ },
160
+ "artifact_refs": artifact_refs,
161
+ "evidence_envelope": out,
162
+ }
163
+ evidence_path = write_evidence_bundle(response_job_id, bundle)
164
+ typer.echo(f"Evidence bundle written: {evidence_path}")
165
+
166
+ typer.echo(json.dumps(out, indent=2, ensure_ascii=False))
167
+ return 0
168
+
169
+ raise typer.Exit(code=asyncio.run(_run()))
mrpd/commands/serve.py ADDED
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ import uvicorn
4
+
5
+
6
+ def serve(host: str, port: int, reload: bool) -> None:
7
+ uvicorn.run(
8
+ "mrpd.api.app:app",
9
+ host=host,
10
+ port=port,
11
+ reload=reload,
12
+ log_level="info",
13
+ )
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from importlib import resources
6
+
7
+ import typer
8
+
9
+ from mrpd.core.schema import validate_envelope
10
+
11
+
12
+ def _validate_one(raw: str, label: str, *, quiet: bool = False) -> bool:
13
+ try:
14
+ envelope = json.loads(raw)
15
+ except json.JSONDecodeError as e:
16
+ if not quiet:
17
+ typer.echo(f"INVALID ({label}): invalid JSON: {e}")
18
+ return False
19
+
20
+ try:
21
+ validate_envelope(envelope)
22
+ except Exception as e:
23
+ if not quiet:
24
+ typer.echo(f"INVALID ({label}): {e}")
25
+ return False
26
+
27
+ return True
28
+
29
+
30
+ def validate(path: str, fixtures: bool = False) -> None:
31
+ """Validate an envelope file or run bundled fixture validation."""
32
+
33
+ if fixtures:
34
+ base = resources.files("mrpd.spec").joinpath("fixtures")
35
+ valid_dir = base.joinpath("valid")
36
+ invalid_dir = base.joinpath("invalid")
37
+
38
+ ok = True
39
+
40
+ # Valid fixtures must pass
41
+ for p in sorted(valid_dir.glob("*.json")):
42
+ raw = p.read_text(encoding="utf-8")
43
+ if not _validate_one(raw, f"valid/{p.name}"):
44
+ ok = False
45
+
46
+ # Invalid fixtures must fail
47
+ for p in sorted(invalid_dir.glob("*.json")):
48
+ raw = p.read_text(encoding="utf-8")
49
+ if _validate_one(raw, f"invalid/{p.name}", quiet=True):
50
+ typer.echo(f"INVALID FIXTURE PASSED ({p.name})")
51
+ ok = False
52
+ else:
53
+ typer.echo(f"EXPECTED FAIL ({p.name})")
54
+
55
+ if not ok:
56
+ raise typer.Exit(code=1)
57
+
58
+ typer.echo("OK (fixtures)")
59
+ return
60
+
61
+ if path == "-":
62
+ raw = sys.stdin.read()
63
+ label = "stdin"
64
+ else:
65
+ raw = open(path, "r", encoding="utf-8").read()
66
+ label = path
67
+
68
+ if not _validate_one(raw, label):
69
+ raise typer.Exit(code=1)
70
+
71
+ typer.echo("OK")
mrpd/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __all__ = []
mrpd/core/artifacts.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from mrpd.core.util import sha256_hex
9
+
10
+
11
+ def default_artifact_dir() -> Path:
12
+ root = os.getenv("MRPD_ARTIFACT_DIR")
13
+ if root:
14
+ return Path(root)
15
+ # default under user home
16
+ return Path.home() / ".mrpd" / "artifacts"
17
+
18
+
19
+ def store_bytes(data: bytes, *, mime: str, suffix: str = "") -> dict[str, Any]:
20
+ d = default_artifact_dir()
21
+ d.mkdir(parents=True, exist_ok=True)
22
+
23
+ h = sha256_hex(data)
24
+ name = h + (suffix if suffix else "")
25
+ path = d / name
26
+ path.write_bytes(data)
27
+
28
+ return {
29
+ "type": "artifact",
30
+ "uri": f"file://{path.as_posix()}",
31
+ "hash": f"sha256:{h}",
32
+ "size": len(data),
33
+ "mime": mime,
34
+ }
35
+
36
+
37
+ def store_json(obj: Any, *, suffix: str = ".json") -> dict[str, Any]:
38
+ data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
39
+ return store_bytes(data, mime="application/json", suffix=suffix)
mrpd/core/config.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class RegistrySource(BaseModel):
11
+ name: str
12
+ base_url: str
13
+
14
+
15
+ class Config(BaseModel):
16
+ registries: list[RegistrySource] = Field(default_factory=list)
17
+ cache_dir: str | None = None
18
+ # TODO: adapters, local tools, auth keys
19
+
20
+
21
+ def load_config(path: str | Path) -> Config:
22
+ p = Path(path)
23
+ data: Any = {}
24
+ if p.exists():
25
+ data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
26
+ return Config.model_validate(data)
mrpd/core/defaults.py ADDED
@@ -0,0 +1,7 @@
1
+ # Canonical public MRP registry host (once deployed)
2
+ MRP_DEFAULT_REGISTRY_BASE = "https://www.moltrouter.dev"
3
+
4
+ # Fallback registry source (raw JSON list). Use ONLY if explicitly configured.
5
+ # We default to the hosted registry API on https://www.moltrouter.dev.
6
+ # Env var: MRP_BOOTSTRAP_REGISTRY_RAW (supports file:// for local testing)
7
+ MRP_BOOTSTRAP_REGISTRY_RAW = None
mrpd/core/envelopes.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from typing import Any
5
+
6
+ from mrpd.core.util import utc_now_rfc3339
7
+
8
+
9
+ def mk_envelope(
10
+ msg_type: str,
11
+ payload: dict[str, Any],
12
+ *,
13
+ sender_id: str = "agent:mrpd/client",
14
+ receiver_id: str | None = None,
15
+ in_reply_to: str | None = None,
16
+ ) -> dict[str, Any]:
17
+ env = {
18
+ "mrp_version": "0.1",
19
+ "msg_id": str(uuid.uuid4()),
20
+ "msg_type": msg_type,
21
+ "timestamp": utc_now_rfc3339(),
22
+ "sender": {"id": sender_id},
23
+ "payload": payload,
24
+ }
25
+ if receiver_id:
26
+ env["receiver"] = {"id": receiver_id}
27
+ if in_reply_to:
28
+ env["in_reply_to"] = in_reply_to
29
+ return env
mrpd/core/errors.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+
7
+ def mrp_error(
8
+ *,
9
+ msg_id: str | None,
10
+ timestamp: str | None,
11
+ receiver_id: str | None = None,
12
+ in_reply_to: str | None = None,
13
+ code: str,
14
+ message: str,
15
+ retryable: bool = False,
16
+ retry_after_ms: int | None = None,
17
+ details: dict[str, Any] | None = None,
18
+ ) -> dict[str, Any]:
19
+ payload: dict[str, Any] = {"code": code, "message": message, "retryable": retryable}
20
+ if retry_after_ms is not None:
21
+ payload["retry_after_ms"] = retry_after_ms
22
+ if details is not None:
23
+ payload["details"] = details
24
+
25
+ env = {
26
+ "mrp_version": "0.1",
27
+ "msg_id": msg_id,
28
+ "msg_type": "ERROR",
29
+ "timestamp": timestamp or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
30
+ "sender": {"id": "service:mrpd"},
31
+ "payload": payload,
32
+ }
33
+ if receiver_id:
34
+ env["receiver"] = {"id": receiver_id}
35
+ if in_reply_to:
36
+ env["in_reply_to"] = in_reply_to
37
+ return env
mrpd/core/evidence.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ def evidence_dir() -> Path:
9
+ return Path.home() / ".mrpd" / "evidence"
10
+
11
+
12
+ def write_evidence_bundle(job_id: str, bundle: dict[str, Any]) -> Path:
13
+ directory = evidence_dir()
14
+ directory.mkdir(parents=True, exist_ok=True)
15
+ path = directory / f"{job_id}.json"
16
+ path.write_text(json.dumps(bundle, indent=2), encoding="utf-8")
17
+ return path
mrpd/core/models.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Literal, Optional
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ VerificationLevel = Literal["self_asserted", "registry_attested", "third_party_audited"]
9
+
10
+
11
+ class TrustInfo(BaseModel):
12
+ score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
13
+ proofs: List[str] = Field(default_factory=list)
14
+ level: Optional[VerificationLevel] = None
15
+
16
+
17
+ class RegistryEntry(BaseModel):
18
+ id: str
19
+ name: str
20
+ description: Optional[str] = None
21
+
22
+ repo: Optional[str] = None
23
+ manifest_url: str
24
+
25
+ capabilities: List[str] = Field(default_factory=list)
26
+ policies: List[str] = Field(default_factory=list)
27
+ proofs: List[str] = Field(default_factory=list)
28
+
29
+ trust: Optional[TrustInfo] = None
30
+
31
+ metadata: dict[str, Any] = Field(default_factory=dict)
32
+
33
+
34
+ class RegistryQueryResponse(BaseModel):
35
+ mrp_version: str = "0.1"
36
+ next_page: Optional[str] = None
37
+ results: List[RegistryEntry] = Field(default_factory=list)