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.
- mrpd/__init__.py +1 -0
- mrpd/adapters/__init__.py +1 -0
- mrpd/api/app.py +8 -0
- mrpd/api/routes.py +200 -0
- mrpd/cli.py +182 -0
- mrpd/commands/__init__.py +1 -0
- mrpd/commands/bridge_mcp.py +189 -0
- mrpd/commands/bridge_openapi.py +279 -0
- mrpd/commands/init_provider.py +176 -0
- mrpd/commands/mrpify_mcp.py +80 -0
- mrpd/commands/mrpify_openapi.py +80 -0
- mrpd/commands/publish.py +102 -0
- mrpd/commands/route.py +126 -0
- mrpd/commands/run.py +169 -0
- mrpd/commands/serve.py +13 -0
- mrpd/commands/validate.py +71 -0
- mrpd/core/__init__.py +1 -0
- mrpd/core/artifacts.py +39 -0
- mrpd/core/config.py +26 -0
- mrpd/core/defaults.py +7 -0
- mrpd/core/envelopes.py +29 -0
- mrpd/core/errors.py +37 -0
- mrpd/core/evidence.py +17 -0
- mrpd/core/models.py +37 -0
- mrpd/core/provider.py +100 -0
- mrpd/core/registry.py +115 -0
- mrpd/core/schema.py +64 -0
- mrpd/core/scoring.py +92 -0
- mrpd/core/util.py +27 -0
- mrpd/spec/__init__.py +1 -0
- mrpd/spec/fixtures/README.md +8 -0
- mrpd/spec/fixtures/invalid/bad_msg_type.json +8 -0
- mrpd/spec/fixtures/invalid/missing_required_fields.json +7 -0
- mrpd/spec/fixtures/valid/discover.json +13 -0
- mrpd/spec/fixtures/valid/error.json +13 -0
- mrpd/spec/fixtures/valid/offer.json +20 -0
- mrpd/spec/schemas/envelope.schema.json +102 -0
- mrpd/spec/schemas/manifest.schema.json +52 -0
- mrpd/spec/schemas/payloads/discover.schema.json +23 -0
- mrpd/spec/schemas/payloads/error.schema.json +15 -0
- mrpd/spec/schemas/payloads/evidence.schema.json +17 -0
- mrpd/spec/schemas/payloads/execute.schema.json +14 -0
- mrpd/spec/schemas/payloads/negotiate.schema.json +15 -0
- mrpd/spec/schemas/payloads/offer.schema.json +30 -0
- mrpd/spec/schemas/types/artifact.schema.json +15 -0
- mrpd/spec/schemas/types/input.schema.json +20 -0
- mrpd-0.1.0.dist-info/METADATA +104 -0
- mrpd-0.1.0.dist-info/RECORD +51 -0
- mrpd-0.1.0.dist-info/WHEEL +5 -0
- mrpd-0.1.0.dist-info/entry_points.txt +2 -0
- mrpd-0.1.0.dist-info/top_level.txt +1 -0
mrpd/commands/publish.py
ADDED
|
@@ -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,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)
|