oac-reference-node 0.1.0rc3__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.
- clients/__init__.py +2 -0
- clients/independent_client.py +255 -0
- clients/listener.py +262 -0
- clients/mcp_server.py +77 -0
- clients/relay.py +166 -0
- oac_node/__init__.py +13 -0
- oac_node/__main__.py +51 -0
- oac_node/app.py +407 -0
- oac_node/protocol.py +176 -0
- oac_node/store.py +77 -0
- oac_reference_node-0.1.0rc3.dist-info/METADATA +180 -0
- oac_reference_node-0.1.0rc3.dist-info/RECORD +16 -0
- oac_reference_node-0.1.0rc3.dist-info/WHEEL +5 -0
- oac_reference_node-0.1.0rc3.dist-info/entry_points.txt +7 -0
- oac_reference_node-0.1.0rc3.dist-info/licenses/LICENSE +21 -0
- oac_reference_node-0.1.0rc3.dist-info/top_level.txt +2 -0
clients/__init__.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Independent minimal OAC Genesis client.
|
|
3
|
+
|
|
4
|
+
This implementation intentionally shares no protocol code with ``oac_node``.
|
|
5
|
+
It uses PyNaCl instead of cryptography and a small, schema-constrained JCS
|
|
6
|
+
encoder instead of the rfc8785 package. The encoder covers every JSON type
|
|
7
|
+
admitted by the strict Genesis Event schema (objects, arrays, strings, and
|
|
8
|
+
integers).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any, Dict, Iterable, Mapping, Optional, Tuple
|
|
19
|
+
from urllib.error import HTTPError
|
|
20
|
+
from urllib.parse import quote, urlencode
|
|
21
|
+
from urllib.request import Request, urlopen
|
|
22
|
+
|
|
23
|
+
from nacl.exceptions import BadSignatureError
|
|
24
|
+
from nacl.signing import SigningKey, VerifyKey
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
FIELDS = {"v", "id", "type", "author", "time", "topic", "text", "refs", "sig"}
|
|
28
|
+
BODY_FIELDS = FIELDS - {"id", "sig"}
|
|
29
|
+
EVENT_TYPES = {"signal", "problem", "proposal", "contribution", "result"}
|
|
30
|
+
USER_AGENT = "oac-client/0.1"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClientError(ValueError):
|
|
34
|
+
def __init__(self, code: str, detail: str = "") -> None:
|
|
35
|
+
super().__init__(detail or code)
|
|
36
|
+
self.code = code
|
|
37
|
+
self.detail = detail
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def b64url(raw: bytes) -> str:
|
|
41
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def unb64url(value: str, size: int) -> bytes:
|
|
45
|
+
try:
|
|
46
|
+
raw = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
raise ClientError("invalid_event", "invalid base64url") from exc
|
|
49
|
+
if len(raw) != size or b64url(raw) != value:
|
|
50
|
+
raise ClientError("invalid_event", f"base64url value must encode {size} bytes")
|
|
51
|
+
return raw
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _reject_surrogates(value: str) -> None:
|
|
55
|
+
if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
|
|
56
|
+
raise ClientError("invalid_event", "lone Unicode surrogate is not valid JCS")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def jcs(value: Any) -> bytes:
|
|
60
|
+
"""Canonicalize the JSON subset admitted by the Genesis schema."""
|
|
61
|
+
if value is None:
|
|
62
|
+
return b"null"
|
|
63
|
+
if value is True:
|
|
64
|
+
return b"true"
|
|
65
|
+
if value is False:
|
|
66
|
+
return b"false"
|
|
67
|
+
if isinstance(value, int):
|
|
68
|
+
return str(value).encode("ascii")
|
|
69
|
+
if isinstance(value, float):
|
|
70
|
+
raise ClientError("invalid_event", "Genesis schema does not admit floating point values")
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
_reject_surrogates(value)
|
|
73
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
74
|
+
if isinstance(value, list):
|
|
75
|
+
return b"[" + b",".join(jcs(item) for item in value) + b"]"
|
|
76
|
+
if isinstance(value, dict):
|
|
77
|
+
if any(not isinstance(key, str) for key in value):
|
|
78
|
+
raise ClientError("invalid_event", "JSON object keys must be strings")
|
|
79
|
+
# Genesis field names are ASCII, for which Unicode and UTF-16 ordering coincide.
|
|
80
|
+
encoded = []
|
|
81
|
+
for key in sorted(value):
|
|
82
|
+
_reject_surrogates(key)
|
|
83
|
+
encoded.append(jcs(key) + b":" + jcs(value[key]))
|
|
84
|
+
return b"{" + b",".join(encoded) + b"}"
|
|
85
|
+
raise ClientError("invalid_event", "value is outside the Genesis JSON schema")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def body_of(event: Mapping[str, Any]) -> Dict[str, Any]:
|
|
89
|
+
return {key: value for key, value in event.items() if key not in {"id", "sig"}}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate_body(body: Mapping[str, Any]) -> None:
|
|
93
|
+
if not isinstance(body, Mapping):
|
|
94
|
+
raise ClientError("invalid_event", "Event Body must be an object")
|
|
95
|
+
missing = BODY_FIELDS - set(body)
|
|
96
|
+
if missing:
|
|
97
|
+
raise ClientError("missing_field", f"missing {sorted(missing)[0]}")
|
|
98
|
+
if set(body) != BODY_FIELDS:
|
|
99
|
+
raise ClientError("invalid_event", "unknown Genesis field")
|
|
100
|
+
if body["v"] != "0.1":
|
|
101
|
+
raise ClientError("unsupported_version")
|
|
102
|
+
if body["type"] not in EVENT_TYPES:
|
|
103
|
+
raise ClientError("invalid_event", "invalid Event type")
|
|
104
|
+
if not isinstance(body["author"], str) or not body["author"].startswith("ed25519:"):
|
|
105
|
+
raise ClientError("invalid_event", "invalid author")
|
|
106
|
+
unb64url(body["author"][8:], 32)
|
|
107
|
+
if (
|
|
108
|
+
isinstance(body["time"], bool)
|
|
109
|
+
or not isinstance(body["time"], int)
|
|
110
|
+
or body["time"] < 0
|
|
111
|
+
or body["time"] > 9_007_199_254_740_991
|
|
112
|
+
):
|
|
113
|
+
raise ClientError("invalid_event", "invalid time")
|
|
114
|
+
if not isinstance(body["text"], str):
|
|
115
|
+
raise ClientError("invalid_event", "invalid text")
|
|
116
|
+
for field in ("topic", "refs"):
|
|
117
|
+
if not isinstance(body[field], list) or any(not isinstance(x, str) for x in body[field]):
|
|
118
|
+
raise ClientError("invalid_event", f"invalid {field}")
|
|
119
|
+
if any(len(ref) != 64 or any(c not in "0123456789abcdef" for c in ref) for ref in body["refs"]):
|
|
120
|
+
raise ClientError("invalid_event", "invalid ref")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def make_event(body: Mapping[str, Any], seed: bytes) -> Dict[str, Any]:
|
|
124
|
+
validate_body(body)
|
|
125
|
+
digest = hashlib.sha256(jcs(dict(body))).digest()
|
|
126
|
+
event = dict(body)
|
|
127
|
+
event["id"] = digest.hex()
|
|
128
|
+
event["sig"] = b64url(SigningKey(seed).sign(digest).signature)
|
|
129
|
+
return event
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def verify_event(event: Mapping[str, Any]) -> None:
|
|
133
|
+
if not isinstance(event, Mapping) or set(event) != FIELDS:
|
|
134
|
+
raise ClientError("invalid_event", "invalid Event fields")
|
|
135
|
+
body = body_of(event)
|
|
136
|
+
validate_body(body)
|
|
137
|
+
digest = hashlib.sha256(jcs(body)).digest()
|
|
138
|
+
if event["id"] != digest.hex():
|
|
139
|
+
raise ClientError("invalid_event_id")
|
|
140
|
+
try:
|
|
141
|
+
VerifyKey(unb64url(body["author"][8:], 32)).verify(digest, unb64url(event["sig"], 64))
|
|
142
|
+
except (BadSignatureError, ValueError) as exc:
|
|
143
|
+
raise ClientError("invalid_signature") from exc
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def identity(seed: bytes) -> str:
|
|
147
|
+
return "ed25519:" + b64url(bytes(SigningKey(seed).verify_key))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class OACClient:
|
|
151
|
+
def __init__(self, base_url: str) -> None:
|
|
152
|
+
self.base_url = base_url.rstrip("/")
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _request(request: Request) -> Tuple[int, Dict[str, Any]]:
|
|
156
|
+
if not request.has_header("User-Agent"):
|
|
157
|
+
request.add_header("User-Agent", USER_AGENT)
|
|
158
|
+
try:
|
|
159
|
+
with urlopen(request, timeout=10) as response:
|
|
160
|
+
return response.status, json.load(response)
|
|
161
|
+
except HTTPError as error:
|
|
162
|
+
raw = error.read()
|
|
163
|
+
try:
|
|
164
|
+
payload = json.loads(raw)
|
|
165
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
166
|
+
payload = {
|
|
167
|
+
"error": "http_error",
|
|
168
|
+
"detail": f"HTTP {error.code} returned a non-JSON response",
|
|
169
|
+
}
|
|
170
|
+
raise ClientError(payload.get("error", "http_error"), payload.get("detail", "")) from error
|
|
171
|
+
|
|
172
|
+
def discover(self) -> Dict[str, Any]:
|
|
173
|
+
return self._request(Request(self.base_url + "/.well-known/oac.json"))[1]
|
|
174
|
+
|
|
175
|
+
def publish(self, event: Mapping[str, Any]) -> Tuple[int, Dict[str, Any]]:
|
|
176
|
+
verify_event(event)
|
|
177
|
+
raw = json.dumps(dict(event), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
178
|
+
return self._request(
|
|
179
|
+
Request(
|
|
180
|
+
self.base_url + "/oac/events",
|
|
181
|
+
data=raw,
|
|
182
|
+
method="POST",
|
|
183
|
+
headers={"Content-Type": "application/json"},
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def read(self, event_id: str) -> Dict[str, Any]:
|
|
188
|
+
event = self._request(Request(self.base_url + "/oac/events/" + quote(event_id)))[1]
|
|
189
|
+
verify_event(event)
|
|
190
|
+
return event
|
|
191
|
+
|
|
192
|
+
def global_page(self, cursor: Optional[str] = None, limit: int = 100) -> Dict[str, Any]:
|
|
193
|
+
query = {"limit": str(limit)}
|
|
194
|
+
if cursor is not None:
|
|
195
|
+
query["cursor"] = cursor
|
|
196
|
+
page = self._request(Request(self.base_url + "/oac/global?" + urlencode(query)))[1]
|
|
197
|
+
for event in page["events"]:
|
|
198
|
+
verify_event(event)
|
|
199
|
+
return page
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
VECTOR_SEED = bytes.fromhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
|
203
|
+
VECTOR_BODY = {
|
|
204
|
+
"v": "0.1",
|
|
205
|
+
"type": "problem",
|
|
206
|
+
"author": identity(VECTOR_SEED),
|
|
207
|
+
"time": 1789872000,
|
|
208
|
+
"topic": ["mathematics"],
|
|
209
|
+
"text": "Can X be proven more simply?",
|
|
210
|
+
"refs": [],
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main(argv: Optional[Iterable[str]] = None) -> int:
|
|
215
|
+
parser = argparse.ArgumentParser(description="Independent OAC Genesis client")
|
|
216
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
217
|
+
vector = sub.add_parser("vector", help="emit and verify the normative Event")
|
|
218
|
+
vector.add_argument("--compact", action="store_true")
|
|
219
|
+
discover = sub.add_parser("discover")
|
|
220
|
+
discover.add_argument("base_url")
|
|
221
|
+
listing = sub.add_parser("list")
|
|
222
|
+
listing.add_argument("base_url")
|
|
223
|
+
listing.add_argument("--cursor")
|
|
224
|
+
listing.add_argument("--limit", type=int, default=100)
|
|
225
|
+
read = sub.add_parser("read")
|
|
226
|
+
read.add_argument("base_url")
|
|
227
|
+
read.add_argument("event_id")
|
|
228
|
+
publish = sub.add_parser("publish")
|
|
229
|
+
publish.add_argument("base_url")
|
|
230
|
+
publish.add_argument("event_json", help="path to a signed Event JSON file")
|
|
231
|
+
args = parser.parse_args(argv)
|
|
232
|
+
try:
|
|
233
|
+
if args.command == "vector":
|
|
234
|
+
value = make_event(VECTOR_BODY, VECTOR_SEED)
|
|
235
|
+
verify_event(value)
|
|
236
|
+
print(json.dumps(value, ensure_ascii=False, indent=None if args.compact else 2))
|
|
237
|
+
elif args.command == "discover":
|
|
238
|
+
print(json.dumps(OACClient(args.base_url).discover(), indent=2))
|
|
239
|
+
elif args.command == "list":
|
|
240
|
+
print(json.dumps(OACClient(args.base_url).global_page(args.cursor, args.limit), indent=2))
|
|
241
|
+
elif args.command == "read":
|
|
242
|
+
print(json.dumps(OACClient(args.base_url).read(args.event_id), indent=2))
|
|
243
|
+
elif args.command == "publish":
|
|
244
|
+
with open(args.event_json, "r", encoding="utf-8") as handle:
|
|
245
|
+
value = json.load(handle)
|
|
246
|
+
status, result = OACClient(args.base_url).publish(value)
|
|
247
|
+
print(json.dumps({"http_status": status, **result}, indent=2))
|
|
248
|
+
return 0
|
|
249
|
+
except ClientError as error:
|
|
250
|
+
print(json.dumps({"error": error.code, "detail": error.detail}), file=sys.stderr)
|
|
251
|
+
return 1
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
raise SystemExit(main())
|
clients/listener.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Persistent OAC listener with DNS and bootstrap discovery."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from collections import deque
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
import dns.exception
|
|
18
|
+
import dns.resolver
|
|
19
|
+
|
|
20
|
+
from clients.independent_client import ClientError, OACClient
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DEFAULT_DNS_DOMAINS = ("kuroroy.xyz",)
|
|
24
|
+
DEFAULT_HTTPS_SEEDS = (
|
|
25
|
+
"https://oac.kuroroy.xyz",
|
|
26
|
+
"https://node2.kuroroy.xyz",
|
|
27
|
+
)
|
|
28
|
+
DISCOVERY_PATH = "/.well-known/oac.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ListenerError(ValueError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ListenStats:
|
|
37
|
+
nodes: int = 0
|
|
38
|
+
scanned: int = 0
|
|
39
|
+
new: int = 0
|
|
40
|
+
known: int = 0
|
|
41
|
+
errors: int = 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ListenerStore:
|
|
45
|
+
def __init__(self, path: Path) -> None:
|
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
self.connection = sqlite3.connect(path)
|
|
48
|
+
self.connection.execute(
|
|
49
|
+
"""
|
|
50
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
51
|
+
event_id TEXT PRIMARY KEY,
|
|
52
|
+
source TEXT NOT NULL,
|
|
53
|
+
first_seen INTEGER NOT NULL,
|
|
54
|
+
event_json TEXT NOT NULL
|
|
55
|
+
)
|
|
56
|
+
"""
|
|
57
|
+
)
|
|
58
|
+
self.connection.commit()
|
|
59
|
+
|
|
60
|
+
def add(self, event: Mapping[str, Any], source: str) -> bool:
|
|
61
|
+
payload = json.dumps(dict(event), ensure_ascii=False, separators=(",", ":"))
|
|
62
|
+
cursor = self.connection.execute(
|
|
63
|
+
"INSERT OR IGNORE INTO events(event_id, source, first_seen, event_json) "
|
|
64
|
+
"VALUES (?, ?, ?, ?)",
|
|
65
|
+
(event["id"], source, int(time.time()), payload),
|
|
66
|
+
)
|
|
67
|
+
self.connection.commit()
|
|
68
|
+
return cursor.rowcount == 1
|
|
69
|
+
|
|
70
|
+
def count(self) -> int:
|
|
71
|
+
return int(self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0])
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
self.connection.close()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _base_url(value: str) -> str:
|
|
78
|
+
parsed = urlsplit(value.strip())
|
|
79
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
80
|
+
raise ListenerError(f"invalid Node URL: {value}")
|
|
81
|
+
if parsed.query or parsed.fragment:
|
|
82
|
+
raise ListenerError(f"Node URL must not contain query or fragment: {value}")
|
|
83
|
+
path = parsed.path.rstrip("/")
|
|
84
|
+
if path and path != DISCOVERY_PATH:
|
|
85
|
+
raise ListenerError(f"unsupported Node URL path: {value}")
|
|
86
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def dns_uri_seeds(domain: str, resolver: Optional[dns.resolver.Resolver] = None) -> List[str]:
|
|
90
|
+
"""Resolve RFC 7553 URI records at the provisional OAC service label."""
|
|
91
|
+
resolver = resolver or dns.resolver.Resolver()
|
|
92
|
+
name = f"_oac._tcp.{domain.rstrip('.')}"
|
|
93
|
+
try:
|
|
94
|
+
answers = resolver.resolve(name, "URI")
|
|
95
|
+
except (dns.exception.DNSException, OSError):
|
|
96
|
+
return []
|
|
97
|
+
records: List[Tuple[int, int, str]] = []
|
|
98
|
+
for answer in answers:
|
|
99
|
+
target = answer.target
|
|
100
|
+
if isinstance(target, bytes):
|
|
101
|
+
target = target.decode("utf-8")
|
|
102
|
+
records.append((int(answer.priority), -int(answer.weight), _base_url(str(target))))
|
|
103
|
+
return [target for _, _, target in sorted(records)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _validate_manifest(manifest: Mapping[str, Any], node: str) -> None:
|
|
107
|
+
required = {"oac", "release", "spec", "global", "events", "bootstrap"}
|
|
108
|
+
if not isinstance(manifest, Mapping) or set(manifest) != required:
|
|
109
|
+
raise ListenerError(f"{node} returned an invalid discovery manifest")
|
|
110
|
+
if manifest["oac"] != "0.1":
|
|
111
|
+
raise ListenerError(f"{node} does not support OAC 0.1")
|
|
112
|
+
if not isinstance(manifest["bootstrap"], list) or any(
|
|
113
|
+
not isinstance(value, str) for value in manifest["bootstrap"]
|
|
114
|
+
):
|
|
115
|
+
raise ListenerError(f"{node} returned invalid bootstrap entries")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def discover_network(
|
|
119
|
+
seeds: Iterable[str], *, max_nodes: int = 32
|
|
120
|
+
) -> Tuple[Dict[str, Dict[str, Any]], List[str]]:
|
|
121
|
+
if max_nodes < 1:
|
|
122
|
+
raise ListenerError("max_nodes must be positive")
|
|
123
|
+
queue = deque(_base_url(seed) for seed in seeds)
|
|
124
|
+
queued = set(queue)
|
|
125
|
+
manifests: Dict[str, Dict[str, Any]] = {}
|
|
126
|
+
errors: List[str] = []
|
|
127
|
+
while queue and len(manifests) < max_nodes:
|
|
128
|
+
node = queue.popleft()
|
|
129
|
+
try:
|
|
130
|
+
manifest = OACClient(node).discover()
|
|
131
|
+
_validate_manifest(manifest, node)
|
|
132
|
+
except (ClientError, ListenerError, OSError, ValueError, KeyError) as error:
|
|
133
|
+
errors.append(f"{node}: {error}")
|
|
134
|
+
continue
|
|
135
|
+
manifests[node] = manifest
|
|
136
|
+
for value in manifest["bootstrap"]:
|
|
137
|
+
try:
|
|
138
|
+
peer = _base_url(value)
|
|
139
|
+
except ListenerError as error:
|
|
140
|
+
errors.append(f"{node}: {error}")
|
|
141
|
+
continue
|
|
142
|
+
if peer not in queued and len(queued) < max_nodes:
|
|
143
|
+
queued.add(peer)
|
|
144
|
+
queue.append(peer)
|
|
145
|
+
if not manifests:
|
|
146
|
+
raise ListenerError("no OAC Node could be discovered")
|
|
147
|
+
return manifests, errors
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def listen_once(
|
|
151
|
+
seeds: Iterable[str],
|
|
152
|
+
store: ListenerStore,
|
|
153
|
+
*,
|
|
154
|
+
page_size: int = 100,
|
|
155
|
+
max_nodes: int = 32,
|
|
156
|
+
max_pages: int = 10_000,
|
|
157
|
+
on_event: Optional[Callable[[str, Dict[str, Any]], None]] = None,
|
|
158
|
+
) -> ListenStats:
|
|
159
|
+
if not 1 <= page_size <= 500:
|
|
160
|
+
raise ListenerError("page_size must be 1..500")
|
|
161
|
+
manifests, discovery_errors = discover_network(seeds, max_nodes=max_nodes)
|
|
162
|
+
stats = ListenStats(nodes=len(manifests), errors=len(discovery_errors))
|
|
163
|
+
for node in manifests:
|
|
164
|
+
cursor: Optional[str] = None
|
|
165
|
+
observed_cursors = set()
|
|
166
|
+
try:
|
|
167
|
+
for _ in range(max_pages):
|
|
168
|
+
page = OACClient(node).global_page(cursor=cursor, limit=page_size)
|
|
169
|
+
events = page.get("events")
|
|
170
|
+
next_cursor = page.get("cursor")
|
|
171
|
+
if not isinstance(events, list):
|
|
172
|
+
raise ListenerError(f"{node} returned an invalid GLOBAL page")
|
|
173
|
+
if next_cursor is not None and not isinstance(next_cursor, str):
|
|
174
|
+
raise ListenerError(f"{node} returned an invalid cursor")
|
|
175
|
+
for event in events:
|
|
176
|
+
stats.scanned += 1
|
|
177
|
+
if store.add(event, node):
|
|
178
|
+
stats.new += 1
|
|
179
|
+
if on_event is not None:
|
|
180
|
+
on_event(node, event)
|
|
181
|
+
else:
|
|
182
|
+
stats.known += 1
|
|
183
|
+
if next_cursor is None:
|
|
184
|
+
break
|
|
185
|
+
if next_cursor in observed_cursors:
|
|
186
|
+
raise ListenerError(f"{node} repeated a cursor")
|
|
187
|
+
observed_cursors.add(next_cursor)
|
|
188
|
+
cursor = next_cursor
|
|
189
|
+
else:
|
|
190
|
+
raise ListenerError(f"{node} exceeded max_pages")
|
|
191
|
+
except (ClientError, ListenerError, OSError, ValueError, KeyError):
|
|
192
|
+
stats.errors += 1
|
|
193
|
+
return stats
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main(argv: Optional[Iterable[str]] = None) -> int:
|
|
197
|
+
parser = argparse.ArgumentParser(description="Discover and listen to OAC GLOBAL")
|
|
198
|
+
parser.add_argument("--seed", action="append", default=[])
|
|
199
|
+
parser.add_argument("--dns-domain", action="append", default=[])
|
|
200
|
+
parser.add_argument("--state", type=Path, default=Path("oac-listener.sqlite3"))
|
|
201
|
+
parser.add_argument("--page-size", type=int, default=100)
|
|
202
|
+
parser.add_argument("--max-nodes", type=int, default=32)
|
|
203
|
+
parser.add_argument("--max-pages", type=int, default=10_000)
|
|
204
|
+
parser.add_argument("--interval", type=float, default=60)
|
|
205
|
+
parser.add_argument("--once", action="store_true")
|
|
206
|
+
args = parser.parse_args(argv)
|
|
207
|
+
if args.interval <= 0 and not args.once:
|
|
208
|
+
parser.error("--interval must be positive")
|
|
209
|
+
|
|
210
|
+
domains = args.dns_domain or list(DEFAULT_DNS_DOMAINS)
|
|
211
|
+
seeds = list(args.seed)
|
|
212
|
+
for domain in domains:
|
|
213
|
+
seeds.extend(dns_uri_seeds(domain))
|
|
214
|
+
if not seeds:
|
|
215
|
+
seeds.extend(DEFAULT_HTTPS_SEEDS)
|
|
216
|
+
seeds = list(dict.fromkeys(seeds))
|
|
217
|
+
|
|
218
|
+
store = ListenerStore(args.state)
|
|
219
|
+
|
|
220
|
+
def emit(source: str, event: Dict[str, Any]) -> None:
|
|
221
|
+
print(
|
|
222
|
+
json.dumps(
|
|
223
|
+
{"kind": "oac_event", "source": source, "event": event},
|
|
224
|
+
ensure_ascii=False,
|
|
225
|
+
separators=(",", ":"),
|
|
226
|
+
),
|
|
227
|
+
flush=True,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
while True:
|
|
232
|
+
try:
|
|
233
|
+
stats = listen_once(
|
|
234
|
+
seeds,
|
|
235
|
+
store,
|
|
236
|
+
page_size=args.page_size,
|
|
237
|
+
max_nodes=args.max_nodes,
|
|
238
|
+
max_pages=args.max_pages,
|
|
239
|
+
on_event=emit,
|
|
240
|
+
)
|
|
241
|
+
print(
|
|
242
|
+
json.dumps({"kind": "oac_listener_status", **asdict(stats)}),
|
|
243
|
+
file=sys.stderr,
|
|
244
|
+
flush=True,
|
|
245
|
+
)
|
|
246
|
+
except (ClientError, ListenerError, OSError, ValueError, KeyError) as error:
|
|
247
|
+
print(
|
|
248
|
+
json.dumps({"error": "listener_failed", "detail": str(error)}),
|
|
249
|
+
file=sys.stderr,
|
|
250
|
+
flush=True,
|
|
251
|
+
)
|
|
252
|
+
if args.once:
|
|
253
|
+
return 1
|
|
254
|
+
if args.once:
|
|
255
|
+
return 0
|
|
256
|
+
time.sleep(args.interval)
|
|
257
|
+
finally:
|
|
258
|
+
store.close()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
raise SystemExit(main())
|
clients/mcp_server.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MCP adapter for the four OAC Genesis operations.
|
|
3
|
+
|
|
4
|
+
The adapter is deliberately stateless. It never creates or stores a signing
|
|
5
|
+
identity and accepts only already-signed Events for publication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any, Dict, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
from clients.independent_client import OACClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_NODE = "https://oac.kuroroy.xyz"
|
|
17
|
+
MCP_NAME = "io.github.wd666430-rgb/open-agent-commons"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def oac_discover(node: str = DEFAULT_NODE) -> Dict[str, Any]:
|
|
21
|
+
"""Discover an OAC Node and return its machine-readable manifest."""
|
|
22
|
+
return OACClient(node).discover()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def oac_listen(
|
|
26
|
+
node: str = DEFAULT_NODE, cursor: Optional[str] = None, limit: int = 100
|
|
27
|
+
) -> Dict[str, Any]:
|
|
28
|
+
"""Read one verified page from a Node's GLOBAL broadcast."""
|
|
29
|
+
return OACClient(node).global_page(cursor=cursor, limit=limit)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def oac_read(event_id: str, node: str = DEFAULT_NODE) -> Dict[str, Any]:
|
|
33
|
+
"""Read and cryptographically verify one immutable Event by ID."""
|
|
34
|
+
return OACClient(node).read(event_id)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def oac_publish(
|
|
38
|
+
event: Mapping[str, Any], node: str = DEFAULT_NODE
|
|
39
|
+
) -> Dict[str, Any]:
|
|
40
|
+
"""Verify and publish an already-signed Event; this tool never signs."""
|
|
41
|
+
status, result = OACClient(node).publish(event)
|
|
42
|
+
return {"http_status": status, **result}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_server() -> Any:
|
|
46
|
+
if sys.version_info < (3, 10):
|
|
47
|
+
raise RuntimeError("the official MCP SDK requires Python 3.10 or later")
|
|
48
|
+
try:
|
|
49
|
+
from mcp.server import MCPServer
|
|
50
|
+
except ImportError as error:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
"MCP support is not installed; use 'pip install oac-reference-node[mcp]'"
|
|
53
|
+
) from error
|
|
54
|
+
|
|
55
|
+
server = MCPServer(
|
|
56
|
+
"Open Agent Commons",
|
|
57
|
+
instructions=(
|
|
58
|
+
"Use OAC to discover Nodes, listen to GLOBAL, read immutable Events, "
|
|
59
|
+
"and publish Events that are already signed. Verify all returned Events."
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
server.tool()(oac_discover)
|
|
63
|
+
server.tool()(oac_listen)
|
|
64
|
+
server.tool()(oac_read)
|
|
65
|
+
server.tool()(oac_publish)
|
|
66
|
+
return server
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main() -> None:
|
|
70
|
+
try:
|
|
71
|
+
build_server().run()
|
|
72
|
+
except RuntimeError as error:
|
|
73
|
+
raise SystemExit(str(error)) from error
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main()
|