rcp-protocol 1.0.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.
- rcp/__init__.py +66 -0
- rcp/_client.py +222 -0
- rcp/_selector.py +143 -0
- rcp/_server.py +269 -0
- rcp/_transport.py +146 -0
- rcp/_types.py +124 -0
- rcp_protocol-1.0.0.dist-info/METADATA +142 -0
- rcp_protocol-1.0.0.dist-info/RECORD +11 -0
- rcp_protocol-1.0.0.dist-info/WHEEL +5 -0
- rcp_protocol-1.0.0.dist-info/licenses/LICENSE +21 -0
- rcp_protocol-1.0.0.dist-info/top_level.txt +1 -0
rcp/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""rcp — the Retrieval Context Protocol, a native Python SDK.
|
|
2
|
+
|
|
3
|
+
Pure Python, zero dependencies (standard library only): no compiler, no C++, no
|
|
4
|
+
build step to ``pip install``. RCP is an open, versioned JSON-RPC protocol so any
|
|
5
|
+
RAG engine — any language, any vendor — can expose embed / rerank / retrieve /
|
|
6
|
+
graph / index / catalog, and any client can consume it uniformly. This SDK speaks
|
|
7
|
+
the exact same wire format as the type-theoretic C++ SDK, so a Python client and
|
|
8
|
+
a C++ server (or vice-versa) interoperate byte-for-byte.
|
|
9
|
+
|
|
10
|
+
Client (connects to any RCP server, over a subprocess or HTTP):
|
|
11
|
+
|
|
12
|
+
import rcp
|
|
13
|
+
c = rcp.connect_stdio(["python3", "my_server.py"])
|
|
14
|
+
print(c.server, c.capabilities)
|
|
15
|
+
if c.supports(rcp.Capability.Retrieve):
|
|
16
|
+
for hit in c.retrieve("eiffel tower", k=3):
|
|
17
|
+
print(hit["id"], hit["score"])
|
|
18
|
+
|
|
19
|
+
Server (expose a Python RAG engine as an RCP server):
|
|
20
|
+
|
|
21
|
+
import rcp
|
|
22
|
+
s = rcp.Server()
|
|
23
|
+
s.set_info("my-engine", "1.0")
|
|
24
|
+
s.advertise(rcp.Capability.Retrieve, {"maxK": 100, "modes": ["hybrid"]})
|
|
25
|
+
|
|
26
|
+
@s.on("retrieve")
|
|
27
|
+
def _(params):
|
|
28
|
+
hits = my_index.search(params["query"], params.get("k", 10))
|
|
29
|
+
return {"hits": [{"id": h.id, "score": h.score, "text": h.text} for h in hits]}
|
|
30
|
+
|
|
31
|
+
s.serve_stdio()
|
|
32
|
+
"""
|
|
33
|
+
from ._client import Client
|
|
34
|
+
from ._selector import EngineSpec, Selector
|
|
35
|
+
from ._server import Server, make_log_notification, make_progress_notification
|
|
36
|
+
from ._transport import HttpTransport, StdioTransport
|
|
37
|
+
from ._types import (
|
|
38
|
+
PROTOCOL_VERSION,
|
|
39
|
+
Capability,
|
|
40
|
+
Errc,
|
|
41
|
+
Method,
|
|
42
|
+
RcpError,
|
|
43
|
+
negotiate_version,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"Client", "Server", "Selector", "EngineSpec", "Capability", "PROTOCOL_VERSION",
|
|
48
|
+
"Method", "Errc", "RcpError", "connect_stdio", "connect_http",
|
|
49
|
+
"make_log_notification", "make_progress_notification",
|
|
50
|
+
"StdioTransport", "HttpTransport", "negotiate_version",
|
|
51
|
+
]
|
|
52
|
+
__version__ = "1.0.0"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def connect_stdio(argv, **kwargs) -> Client:
|
|
56
|
+
"""Spawn an RCP server subprocess and connect. `argv` e.g. ["python3","srv.py"]."""
|
|
57
|
+
c = Client()
|
|
58
|
+
c.connect_stdio(list(argv))
|
|
59
|
+
return c
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def connect_http(base_url: str, **kwargs) -> Client:
|
|
63
|
+
"""Connect to an HTTP RCP server, e.g. "http://127.0.0.1:8000/rcp"."""
|
|
64
|
+
c = Client()
|
|
65
|
+
c.connect_http(base_url)
|
|
66
|
+
return c
|
rcp/_client.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""RCP client — connect to any RCP server (subprocess or HTTP), then typed calls.
|
|
2
|
+
|
|
3
|
+
Mirrors the C++ ``rcp::Client`` (``sdk/cpp/include/rcp/client.hpp``): connecting
|
|
4
|
+
performs the ``initialize`` handshake and caches the negotiated protocol version,
|
|
5
|
+
server identity, and capabilities. Every typed call is **capability-gated** — a
|
|
6
|
+
call to a feature the server never advertised raises before any round-trip.
|
|
7
|
+
|
|
8
|
+
Errors raise :class:`RcpError` (a ``RuntimeError``) with ``str(e)`` of the form
|
|
9
|
+
``"[RCP <code>] <message>"``.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
from ._transport import HttpTransport, StdioTransport
|
|
16
|
+
from ._types import (
|
|
17
|
+
MIN_PROTOCOL_VERSION,
|
|
18
|
+
PROTOCOL_VERSION,
|
|
19
|
+
Capability,
|
|
20
|
+
Errc,
|
|
21
|
+
Method,
|
|
22
|
+
RcpError,
|
|
23
|
+
cap_key,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_CLIENT_INFO = {"name": "rcp-python", "version": "1.0"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _hit_to_dict(h) -> dict:
|
|
30
|
+
"""Normalise one retrieval Hit to ``{id, score, text[, citation]}`` — the
|
|
31
|
+
canonical hit shape (id coerced to str, extra fields dropped)."""
|
|
32
|
+
if not isinstance(h, dict):
|
|
33
|
+
return {"id": "", "score": 0.0, "text": str(h)}
|
|
34
|
+
raw_id = h.get("id", "")
|
|
35
|
+
hit_id = raw_id if isinstance(raw_id, str) else json.dumps(raw_id, separators=(",", ":"))
|
|
36
|
+
out = {"id": hit_id, "score": float(h.get("score", 0.0)), "text": h.get("text", "")}
|
|
37
|
+
if h.get("citation") is not None:
|
|
38
|
+
out["citation"] = h["citation"]
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Client:
|
|
43
|
+
"""A connected RCP client. Obtain one via :func:`rcp.connect_stdio` /
|
|
44
|
+
:func:`rcp.connect_http`, or construct and call ``connect_stdio`` /
|
|
45
|
+
``connect_http`` directly — either style works."""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self._transport = None
|
|
49
|
+
self._next_id = 0
|
|
50
|
+
self._protocol_version = PROTOCOL_VERSION
|
|
51
|
+
self._server = {"name": "unknown", "version": "0"}
|
|
52
|
+
self._caps: dict = {}
|
|
53
|
+
|
|
54
|
+
# ── connection ──────────────────────────────────────────────────────────
|
|
55
|
+
def connect_stdio(self, argv) -> "Client":
|
|
56
|
+
self._transport = StdioTransport.spawn(list(argv))
|
|
57
|
+
self._handshake()
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def connect_http(self, base_url: str) -> "Client":
|
|
61
|
+
self._transport = HttpTransport(base_url)
|
|
62
|
+
self._handshake()
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def _handshake(self) -> None:
|
|
66
|
+
res = self._request(Method.INITIALIZE, {
|
|
67
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
68
|
+
"client": dict(_CLIENT_INFO),
|
|
69
|
+
"capabilities": {},
|
|
70
|
+
})
|
|
71
|
+
pv = res.get("protocolVersion", PROTOCOL_VERSION)
|
|
72
|
+
if not isinstance(pv, int) or pv < MIN_PROTOCOL_VERSION:
|
|
73
|
+
raise RcpError(Errc.VERSION_MISMATCH, "server offered no usable protocol version")
|
|
74
|
+
self._protocol_version = pv
|
|
75
|
+
srv = res.get("server", {}) or {}
|
|
76
|
+
self._server = {"name": srv.get("name", "unknown"), "version": srv.get("version", "0")}
|
|
77
|
+
self._caps = res.get("capabilities", {}) or {}
|
|
78
|
+
|
|
79
|
+
# ── introspection ───────────────────────────────────────────────────────
|
|
80
|
+
@property
|
|
81
|
+
def protocol_version(self) -> int:
|
|
82
|
+
return self._protocol_version
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def server(self) -> dict:
|
|
86
|
+
return self._server
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def capabilities(self) -> dict:
|
|
90
|
+
return self._caps
|
|
91
|
+
|
|
92
|
+
def supports(self, capability) -> bool:
|
|
93
|
+
return self._caps.get(cap_key(capability)) is not None
|
|
94
|
+
|
|
95
|
+
# ── typed, capability-gated calls ───────────────────────────────────────
|
|
96
|
+
def embed(self, texts, kind=None):
|
|
97
|
+
"""Dense embeddings → ``list[list[float]]``. ``kind`` ("query"|"document")
|
|
98
|
+
selects asymmetric pooling."""
|
|
99
|
+
self._gate(Capability.Embed)
|
|
100
|
+
p = {"inputs": list(texts)}
|
|
101
|
+
if kind:
|
|
102
|
+
p["kind"] = kind
|
|
103
|
+
r = self._request(Method.EMBED, p)
|
|
104
|
+
vecs = r.get("vectors", r) if isinstance(r, dict) else r
|
|
105
|
+
return [[float(x) for x in row] for row in vecs]
|
|
106
|
+
|
|
107
|
+
def embed_sparse(self, texts):
|
|
108
|
+
"""Learned-sparse (SPLADE) terms → raw result ``{sparse: [...]}``."""
|
|
109
|
+
self._gate(Capability.SparseEmbed)
|
|
110
|
+
return self._request(Method.EMBED_SPARSE, {"inputs": list(texts)})
|
|
111
|
+
|
|
112
|
+
def embed_multi(self, inputs):
|
|
113
|
+
"""Per-token multi-vector (ColBERT/ColPali) → ``{matrices, dimension}``."""
|
|
114
|
+
self._gate(Capability.MultiVector)
|
|
115
|
+
return self._request(Method.EMBED_MULTI, {"inputs": list(inputs)})
|
|
116
|
+
|
|
117
|
+
def rerank(self, query, passages):
|
|
118
|
+
"""Cross-encoder rerank → ``list[float]`` scores (one per passage)."""
|
|
119
|
+
self._gate(Capability.Rerank)
|
|
120
|
+
r = self._request(Method.RERANK, {"query": query, "passages": list(passages)})
|
|
121
|
+
scores = r.get("scores", r) if isinstance(r, dict) else r
|
|
122
|
+
return [float(x) for x in scores]
|
|
123
|
+
|
|
124
|
+
def search(self, query, k=10, opts=None):
|
|
125
|
+
"""Full retrieve → ``{hits, usage, next_cursor}`` (spec §7.6)."""
|
|
126
|
+
self._gate(Capability.Retrieve)
|
|
127
|
+
if k < 1:
|
|
128
|
+
raise RcpError(Errc.INVALID_PARAMS, "value must be >= 1")
|
|
129
|
+
p = dict(opts) if opts else {}
|
|
130
|
+
p["query"] = query
|
|
131
|
+
p["k"] = k
|
|
132
|
+
r = self._request(Method.RETRIEVE, p)
|
|
133
|
+
hits = r.get("hits", []) if isinstance(r, dict) else []
|
|
134
|
+
return {
|
|
135
|
+
"hits": [_hit_to_dict(h) for h in hits],
|
|
136
|
+
"usage": (r.get("usage", {}) if isinstance(r, dict) else {}),
|
|
137
|
+
"next_cursor": (r.get("nextCursor") if isinstance(r, dict) else None),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def retrieve(self, query, k=10, opts=None):
|
|
141
|
+
"""Convenience over :meth:`search` → just the ``list[dict]`` of hits."""
|
|
142
|
+
return self.search(query, k, opts)["hits"]
|
|
143
|
+
|
|
144
|
+
def graph(self, op, params=None):
|
|
145
|
+
"""GraphRAG op ("local"|"global"|...) → raw result dict (spec §7.9)."""
|
|
146
|
+
self._gate(Capability.Graph)
|
|
147
|
+
p = dict(params) if params else {}
|
|
148
|
+
p["op"] = op
|
|
149
|
+
return self._request(Method.GRAPH, p)
|
|
150
|
+
|
|
151
|
+
def transform(self, query, method):
|
|
152
|
+
"""Query transform (HyDE, expansion, ...) → raw result (spec §7.8)."""
|
|
153
|
+
self._gate(Capability.Transform)
|
|
154
|
+
return self._request(Method.TRANSFORM, {"query": query, "method": method})
|
|
155
|
+
|
|
156
|
+
def index_add(self, params=None):
|
|
157
|
+
"""Add documents to the corpus (spec §7.10). ``params`` e.g. ``{documents:[...]}``."""
|
|
158
|
+
self._gate(Capability.Index)
|
|
159
|
+
return self._request(Method.INDEX_ADD, dict(params) if params else {})
|
|
160
|
+
|
|
161
|
+
def index_delete(self, params=None):
|
|
162
|
+
"""Delete documents by id (spec §7.11). ``params`` e.g. ``{ids:[...]}``."""
|
|
163
|
+
self._gate(Capability.Index)
|
|
164
|
+
return self._request(Method.INDEX_DELETE, dict(params) if params else {})
|
|
165
|
+
|
|
166
|
+
def catalog(self):
|
|
167
|
+
"""List available sub-indexes / collections (spec §7.12)."""
|
|
168
|
+
self._gate(Capability.Catalog)
|
|
169
|
+
return self._request(Method.CATALOG_LIST, {})
|
|
170
|
+
|
|
171
|
+
def info(self):
|
|
172
|
+
"""Re-fetch server identity + capabilities without re-initialising."""
|
|
173
|
+
r = self._request(Method.INFO, {})
|
|
174
|
+
srv = r.get("server", {}) or {}
|
|
175
|
+
self._server = {"name": srv.get("name", "unknown"), "version": srv.get("version", "0")}
|
|
176
|
+
self._caps = r.get("capabilities", {}) or {}
|
|
177
|
+
return r
|
|
178
|
+
|
|
179
|
+
def call(self, method, params=None):
|
|
180
|
+
"""Escape hatch: invoke an arbitrary method with raw params."""
|
|
181
|
+
return self._request(method, params if params is not None else {})
|
|
182
|
+
|
|
183
|
+
def ping(self, nonce=None):
|
|
184
|
+
"""Liveness check; echoes ``{nonce}`` if a nonce is given (spec §9)."""
|
|
185
|
+
p = {}
|
|
186
|
+
if nonce is not None:
|
|
187
|
+
p["nonce"] = nonce
|
|
188
|
+
return self._request(Method.PING, p)
|
|
189
|
+
|
|
190
|
+
def shutdown(self) -> None:
|
|
191
|
+
"""Politely ask the server to stop, then close the transport. Never raises."""
|
|
192
|
+
if self._transport is None:
|
|
193
|
+
return
|
|
194
|
+
try:
|
|
195
|
+
self._request(Method.SHUTDOWN, {})
|
|
196
|
+
except RcpError:
|
|
197
|
+
pass
|
|
198
|
+
finally:
|
|
199
|
+
self._transport.close()
|
|
200
|
+
self._transport = None
|
|
201
|
+
|
|
202
|
+
# ── internals ───────────────────────────────────────────────────────────
|
|
203
|
+
def _gate(self, capability) -> None:
|
|
204
|
+
if not self.supports(capability):
|
|
205
|
+
raise RcpError(Errc.CAPABILITY_MISSING,
|
|
206
|
+
f"server does not advertise '{cap_key(capability)}'")
|
|
207
|
+
|
|
208
|
+
def _request(self, method, params):
|
|
209
|
+
if self._transport is None:
|
|
210
|
+
raise RcpError(Errc.BACKEND_UNAVAILABLE, "client is not connected")
|
|
211
|
+
self._next_id += 1
|
|
212
|
+
reply = self._transport.call({
|
|
213
|
+
"jsonrpc": "2.0", "id": self._next_id, "method": method, "params": params,
|
|
214
|
+
})
|
|
215
|
+
if isinstance(reply, dict):
|
|
216
|
+
err = reply.get("error")
|
|
217
|
+
if err is not None:
|
|
218
|
+
raise RcpError(err.get("code", Errc.INTERNAL_ERROR),
|
|
219
|
+
err.get("message", "error"), err.get("data"))
|
|
220
|
+
if "result" in reply:
|
|
221
|
+
return reply["result"]
|
|
222
|
+
return {}
|
rcp/_selector.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""RCP Selector — choose ONE backend from several reachable engines.
|
|
2
|
+
|
|
3
|
+
Mirrors ``rcp::Selector`` (``sdk/cpp/include/rcp/selector.hpp``): you list
|
|
4
|
+
candidate engines (by hand or from an ``rcp.json`` registry) and pick one — by
|
|
5
|
+
id, by required capability, or by priority with liveness fallback. Connection is
|
|
6
|
+
**lazy**: an engine is dialled only when actually selected. The result is a
|
|
7
|
+
fully-connected :class:`~rcp._client.Client`.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from ._client import Client
|
|
14
|
+
from ._types import Errc, RcpError, cap_key
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EngineSpec:
|
|
18
|
+
"""How to reach one backend. Inert until selected; exactly one of ``argv``
|
|
19
|
+
(stdio subprocess) or ``url`` (HTTP) is set."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, id, argv=None, url="", priority=0, weight=1.0, required=False):
|
|
22
|
+
self.id = id
|
|
23
|
+
self.argv = list(argv) if argv else []
|
|
24
|
+
self.url = url
|
|
25
|
+
self.priority = priority
|
|
26
|
+
self.weight = weight
|
|
27
|
+
self.required = required
|
|
28
|
+
|
|
29
|
+
def open(self) -> Client:
|
|
30
|
+
c = Client()
|
|
31
|
+
if self.argv:
|
|
32
|
+
return c.connect_stdio(self.argv)
|
|
33
|
+
if self.url:
|
|
34
|
+
return c.connect_http(self.url)
|
|
35
|
+
raise RcpError(Errc.INVALID_PARAMS, f"engine '{self.id}' has no transport")
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def from_json(entry) -> "EngineSpec":
|
|
39
|
+
"""Parse one registry entry (spec §16.1). ``transport`` is "stdio" (needs
|
|
40
|
+
``command``) or "http" (needs ``url``); inferred if omitted."""
|
|
41
|
+
if not isinstance(entry, dict) or "id" not in entry:
|
|
42
|
+
raise RcpError(Errc.INVALID_PARAMS, "registry entry needs an 'id'")
|
|
43
|
+
transport = entry.get("transport", "")
|
|
44
|
+
command = entry.get("command") or entry.get("argv")
|
|
45
|
+
url = entry.get("url", "")
|
|
46
|
+
spec = EngineSpec(
|
|
47
|
+
id=entry.get("id", ""),
|
|
48
|
+
priority=entry.get("priority", 0),
|
|
49
|
+
weight=entry.get("weight", 1.0),
|
|
50
|
+
required=entry.get("required", False),
|
|
51
|
+
)
|
|
52
|
+
if transport == "stdio" or (not transport and command):
|
|
53
|
+
if not isinstance(command, list) or not command:
|
|
54
|
+
raise RcpError(Errc.INVALID_PARAMS,
|
|
55
|
+
f"stdio engine '{spec.id}' needs a 'command' array")
|
|
56
|
+
spec.argv = [str(x) for x in command]
|
|
57
|
+
elif transport == "http" or (not transport and url):
|
|
58
|
+
if not url:
|
|
59
|
+
raise RcpError(Errc.INVALID_PARAMS, f"http engine '{spec.id}' needs a 'url'")
|
|
60
|
+
spec.url = url
|
|
61
|
+
else:
|
|
62
|
+
raise RcpError(Errc.INVALID_PARAMS,
|
|
63
|
+
f"engine '{spec.id}' has no usable transport")
|
|
64
|
+
return spec
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Selector:
|
|
68
|
+
"""Registry of candidate engines; selection connects and returns a Client."""
|
|
69
|
+
|
|
70
|
+
def __init__(self):
|
|
71
|
+
self._specs: list[EngineSpec] = []
|
|
72
|
+
|
|
73
|
+
# ── construction ────────────────────────────────────────────────────────
|
|
74
|
+
@staticmethod
|
|
75
|
+
def load(path: str) -> "Selector":
|
|
76
|
+
"""Build a Selector from a registry JSON file."""
|
|
77
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
78
|
+
return Selector.loads(f.read())
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def loads(text: str) -> "Selector":
|
|
82
|
+
"""Build a Selector from a registry JSON string
|
|
83
|
+
(``{"engines": [ ... ]}``)."""
|
|
84
|
+
try:
|
|
85
|
+
doc = json.loads(text)
|
|
86
|
+
except ValueError as e:
|
|
87
|
+
raise RcpError(Errc.PARSE_ERROR, f"invalid registry JSON: {e}")
|
|
88
|
+
engines = doc.get("engines", []) if isinstance(doc, dict) else []
|
|
89
|
+
sel = Selector()
|
|
90
|
+
for entry in engines:
|
|
91
|
+
sel._specs.append(EngineSpec.from_json(entry))
|
|
92
|
+
return sel
|
|
93
|
+
|
|
94
|
+
def add_stdio(self, id, argv, priority=0) -> "Selector":
|
|
95
|
+
self._specs.append(EngineSpec(id, argv=list(argv), priority=priority))
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def add_http(self, id, url, priority=0) -> "Selector":
|
|
99
|
+
self._specs.append(EngineSpec(id, url=url, priority=priority))
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def size(self) -> int:
|
|
104
|
+
return len(self._specs)
|
|
105
|
+
|
|
106
|
+
# ── selection ───────────────────────────────────────────────────────────
|
|
107
|
+
def select(self, id) -> Client:
|
|
108
|
+
"""Connect the engine with the given id (or raise if unknown)."""
|
|
109
|
+
for spec in self._specs:
|
|
110
|
+
if spec.id == id:
|
|
111
|
+
return spec.open()
|
|
112
|
+
raise RcpError(Errc.INVALID_PARAMS, f"no engine with id '{id}'")
|
|
113
|
+
|
|
114
|
+
def select_primary(self) -> Client:
|
|
115
|
+
"""Connect the highest-priority engine that answers (liveness fallback)."""
|
|
116
|
+
last = None
|
|
117
|
+
for spec in self._by_priority():
|
|
118
|
+
try:
|
|
119
|
+
return spec.open()
|
|
120
|
+
except RcpError as e:
|
|
121
|
+
last = e
|
|
122
|
+
raise last or RcpError(Errc.BACKEND_UNAVAILABLE, "no engines configured")
|
|
123
|
+
|
|
124
|
+
def select_capable(self, capability) -> Client:
|
|
125
|
+
"""Connect the highest-priority engine that answers AND advertises
|
|
126
|
+
``capability``."""
|
|
127
|
+
last = None
|
|
128
|
+
for spec in self._by_priority():
|
|
129
|
+
try:
|
|
130
|
+
c = spec.open()
|
|
131
|
+
except RcpError as e:
|
|
132
|
+
last = e
|
|
133
|
+
continue
|
|
134
|
+
if c.supports(capability):
|
|
135
|
+
return c
|
|
136
|
+
c.shutdown()
|
|
137
|
+
last = RcpError(Errc.CAPABILITY_MISSING,
|
|
138
|
+
f"engine '{spec.id}' lacks '{cap_key(capability)}'")
|
|
139
|
+
raise last or RcpError(Errc.BACKEND_UNAVAILABLE, "no capable engine")
|
|
140
|
+
|
|
141
|
+
def _by_priority(self) -> list[EngineSpec]:
|
|
142
|
+
# Stable sort by descending priority (Python's sort is stable).
|
|
143
|
+
return sorted(self._specs, key=lambda s: s.priority, reverse=True)
|
rcp/_server.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""RCP server — expose a Python RAG engine as an RCP/1 server.
|
|
2
|
+
|
|
3
|
+
Mirrors the C++ ``rcp::Server`` (``sdk/cpp/include/rcp/server.hpp``): it owns the
|
|
4
|
+
JSON-RPC framing, the ``initialize`` handshake, capability gating, and error
|
|
5
|
+
mapping; you provide method handlers (``on``) and identity/capabilities
|
|
6
|
+
(``set_info`` / ``advertise``).
|
|
7
|
+
|
|
8
|
+
Dispatch rules replicated exactly:
|
|
9
|
+
|
|
10
|
+
* ``initialize`` / ``info`` / ``ping`` / ``shutdown`` / ``notifications/cancel``
|
|
11
|
+
are ungated and answer before initialize.
|
|
12
|
+
* A gated method before ``initialize`` → **-32001** NotInitialized.
|
|
13
|
+
* A method whose capability is unadvertised or whose handler is unregistered →
|
|
14
|
+
**-32003** CapabilityMissing.
|
|
15
|
+
* An unknown method → **-32004** UnknownMethod. A malformed request → **-32600**.
|
|
16
|
+
* Batches (JSON array) yield one response per request; notifications drop out.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import socket
|
|
22
|
+
|
|
23
|
+
from ._types import (
|
|
24
|
+
CAP_FOR_METHOD,
|
|
25
|
+
MIN_PROTOCOL_VERSION,
|
|
26
|
+
PROTOCOL_VERSION,
|
|
27
|
+
Capability,
|
|
28
|
+
Errc,
|
|
29
|
+
Method,
|
|
30
|
+
RcpError,
|
|
31
|
+
cap_key,
|
|
32
|
+
negotiate_version,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_COMPACT = (",", ":")
|
|
36
|
+
_KNOWN_METHODS = set(CAP_FOR_METHOD)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _dumps(obj) -> str:
|
|
40
|
+
return json.dumps(obj, separators=_COMPACT)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _ok(id_, result) -> dict:
|
|
44
|
+
return {"jsonrpc": "2.0", "id": id_, "result": result}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _err(id_, code: int, message: str, data=None) -> dict:
|
|
48
|
+
error = {"code": code, "message": message}
|
|
49
|
+
if data is not None:
|
|
50
|
+
error["data"] = data
|
|
51
|
+
return {"jsonrpc": "2.0", "id": id_, "error": error}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def make_log_notification(level: str, message: str, data=None) -> str:
|
|
55
|
+
"""Build a ``notifications/log`` frame (spec §17.1) for a server to emit on
|
|
56
|
+
its output stream. ``level`` ∈ debug|info|notice|warning|error."""
|
|
57
|
+
params = {"level": level, "message": message}
|
|
58
|
+
if data is not None:
|
|
59
|
+
params["data"] = data
|
|
60
|
+
return _dumps({"jsonrpc": "2.0", "method": Method.LOG, "params": params})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def make_progress_notification(token, progress: float, stage: str = "", partial=None) -> str:
|
|
64
|
+
"""Build a ``notifications/progress`` frame (spec §9) during a long retrieve.
|
|
65
|
+
``token`` echoes the request's ``_meta.progressToken``; ``progress`` is 0..1."""
|
|
66
|
+
params = {"progressToken": token, "progress": progress}
|
|
67
|
+
if stage:
|
|
68
|
+
params["stage"] = stage
|
|
69
|
+
if partial is not None:
|
|
70
|
+
params["partial"] = partial
|
|
71
|
+
return _dumps({"jsonrpc": "2.0", "method": Method.PROGRESS, "params": params})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Server:
|
|
75
|
+
"""An RCP/1 server. Register handlers with :meth:`on` (usable as a decorator),
|
|
76
|
+
then serve over stdio or HTTP — or drive :meth:`handle` directly for tests."""
|
|
77
|
+
|
|
78
|
+
def __init__(self):
|
|
79
|
+
self._info = {"name": "unknown", "version": "0"}
|
|
80
|
+
self._caps: dict = {} # wire JSON key -> metadata object
|
|
81
|
+
self._handlers: dict = {} # method string -> callable(params) -> result
|
|
82
|
+
self._initialized = False
|
|
83
|
+
|
|
84
|
+
# ── configuration ───────────────────────────────────────────────────────
|
|
85
|
+
def set_info(self, name: str, version: str) -> None:
|
|
86
|
+
self._info = {"name": str(name), "version": str(version)}
|
|
87
|
+
|
|
88
|
+
def advertise(self, capability, meta=None) -> None:
|
|
89
|
+
"""Advertise a capability with optional metadata (stored under its wire
|
|
90
|
+
key). Advertising is independent of registering a handler; a gated call
|
|
91
|
+
needs both or it is CapabilityMissing."""
|
|
92
|
+
self._caps[cap_key(capability)] = {} if meta is None else meta
|
|
93
|
+
|
|
94
|
+
def on(self, method, fn=None):
|
|
95
|
+
"""Register a handler for ``method``. Works as a decorator
|
|
96
|
+
(``@s.on("retrieve")``) or directly (``s.on("retrieve", fn)``). The
|
|
97
|
+
handler takes the params dict and returns the result object."""
|
|
98
|
+
name = method.value if isinstance(method, Capability) else str(method)
|
|
99
|
+
if name not in _KNOWN_METHODS:
|
|
100
|
+
raise ValueError(f"unknown method hook: {name!r} "
|
|
101
|
+
f"(expected one of {sorted(_KNOWN_METHODS)})")
|
|
102
|
+
if fn is None:
|
|
103
|
+
def deco(f):
|
|
104
|
+
self._handlers[name] = f
|
|
105
|
+
return f
|
|
106
|
+
return deco
|
|
107
|
+
self._handlers[name] = fn
|
|
108
|
+
return fn
|
|
109
|
+
|
|
110
|
+
# ── dispatch ─────────────────────────────────────────────────────────────
|
|
111
|
+
def handle(self, request):
|
|
112
|
+
"""Handle one JSON-RPC request object → reply dict, or ``None`` for a
|
|
113
|
+
notification that warrants no response. Never raises."""
|
|
114
|
+
id_ = request.get("id") if isinstance(request, dict) else None
|
|
115
|
+
if not isinstance(request, dict) or not isinstance(request.get("method"), str):
|
|
116
|
+
return _err(id_, Errc.INVALID_REQUEST, "missing 'method'")
|
|
117
|
+
m = request["method"]
|
|
118
|
+
params = request["params"] if "params" in request else {}
|
|
119
|
+
|
|
120
|
+
if m == Method.INITIALIZE:
|
|
121
|
+
self._initialized = True
|
|
122
|
+
neg = negotiate_version(params.get("protocolVersion", PROTOCOL_VERSION)
|
|
123
|
+
if isinstance(params, dict) else PROTOCOL_VERSION)
|
|
124
|
+
if neg < MIN_PROTOCOL_VERSION:
|
|
125
|
+
return _err(id_, Errc.VERSION_MISMATCH, "no common version")
|
|
126
|
+
return _ok(id_, self._info_result(neg))
|
|
127
|
+
if m == Method.INFO:
|
|
128
|
+
return _ok(id_, self._info_result(PROTOCOL_VERSION))
|
|
129
|
+
if m == Method.PING:
|
|
130
|
+
has_nonce = isinstance(params, dict) and "nonce" in params
|
|
131
|
+
return _ok(id_, {"nonce": params["nonce"]} if has_nonce else {})
|
|
132
|
+
if m == Method.CANCEL:
|
|
133
|
+
return None # cancellation is a notification: never answered
|
|
134
|
+
if m == Method.SHUTDOWN:
|
|
135
|
+
return _ok(id_, {})
|
|
136
|
+
|
|
137
|
+
if not self._initialized:
|
|
138
|
+
return _err(id_, Errc.NOT_INITIALIZED, "call 'initialize' first")
|
|
139
|
+
|
|
140
|
+
return self._dispatch(id_, m, params if isinstance(params, dict) else {})
|
|
141
|
+
|
|
142
|
+
def _dispatch(self, id_, m, params):
|
|
143
|
+
cap = CAP_FOR_METHOD.get(m)
|
|
144
|
+
if cap is None:
|
|
145
|
+
return _err(id_, Errc.UNKNOWN_METHOD, f"unknown method '{m}'")
|
|
146
|
+
fn = self._handlers.get(m)
|
|
147
|
+
if fn is None:
|
|
148
|
+
return _err(id_, Errc.CAPABILITY_MISSING, f"'{m}' not implemented")
|
|
149
|
+
if self._caps.get(cap.value) is None:
|
|
150
|
+
return _err(id_, Errc.CAPABILITY_MISSING, f"capability '{cap.value}' not supported")
|
|
151
|
+
try:
|
|
152
|
+
result = fn(params)
|
|
153
|
+
except RcpError as e:
|
|
154
|
+
return _err(id_, e.code, e.message, e.data)
|
|
155
|
+
except Exception as e: # a handler bug is an Internal error, never a crash
|
|
156
|
+
return _err(id_, Errc.INTERNAL_ERROR, str(e))
|
|
157
|
+
return _ok(id_, result if result is not None else {})
|
|
158
|
+
|
|
159
|
+
def handle_line(self, line: str) -> str:
|
|
160
|
+
"""Parse a JSON line, dispatch (single or batch), and return the reply
|
|
161
|
+
string ("" when there is no response, e.g. an all-notification batch)."""
|
|
162
|
+
try:
|
|
163
|
+
msg = json.loads(line)
|
|
164
|
+
except ValueError as e:
|
|
165
|
+
return _dumps(_err(None, Errc.PARSE_ERROR, str(e)))
|
|
166
|
+
|
|
167
|
+
if isinstance(msg, list):
|
|
168
|
+
if not msg:
|
|
169
|
+
return _dumps(_err(None, Errc.INVALID_REQUEST, "empty batch"))
|
|
170
|
+
out = []
|
|
171
|
+
for el in msg:
|
|
172
|
+
reply = self.handle(el)
|
|
173
|
+
if reply is not None:
|
|
174
|
+
out.append(reply)
|
|
175
|
+
return _dumps(out) if out else ""
|
|
176
|
+
|
|
177
|
+
reply = self.handle(msg)
|
|
178
|
+
return _dumps(reply) if reply is not None else ""
|
|
179
|
+
|
|
180
|
+
def _info_result(self, version: int) -> dict:
|
|
181
|
+
return {
|
|
182
|
+
"protocolVersion": version,
|
|
183
|
+
"server": dict(self._info),
|
|
184
|
+
"capabilities": dict(self._caps),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# ── serving ──────────────────────────────────────────────────────────────
|
|
188
|
+
def serve_stdio(self) -> None:
|
|
189
|
+
"""Serve newline-delimited JSON-RPC over stdin/stdout until shutdown/EOF."""
|
|
190
|
+
import sys
|
|
191
|
+
|
|
192
|
+
stdin = sys.stdin.buffer
|
|
193
|
+
stdout = sys.stdout.buffer
|
|
194
|
+
for raw in stdin:
|
|
195
|
+
line = raw.decode("utf-8", "replace").strip()
|
|
196
|
+
if not line:
|
|
197
|
+
continue
|
|
198
|
+
is_shutdown = False
|
|
199
|
+
try:
|
|
200
|
+
parsed = json.loads(line)
|
|
201
|
+
is_shutdown = isinstance(parsed, dict) and parsed.get("method") == Method.SHUTDOWN
|
|
202
|
+
except ValueError:
|
|
203
|
+
pass
|
|
204
|
+
reply = self.handle_line(line)
|
|
205
|
+
if reply:
|
|
206
|
+
try:
|
|
207
|
+
stdout.write((reply + "\n").encode("utf-8"))
|
|
208
|
+
stdout.flush()
|
|
209
|
+
except (BrokenPipeError, OSError):
|
|
210
|
+
break # peer gone
|
|
211
|
+
if is_shutdown:
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
def serve_http(self, port: int) -> None:
|
|
215
|
+
"""Serve JSON-RPC over minimal HTTP/1.1 on ``127.0.0.1:port``. A response
|
|
216
|
+
returns 200; a notification (no reply) returns 204. Loops until killed."""
|
|
217
|
+
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
218
|
+
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
219
|
+
srv.bind(("127.0.0.1", port))
|
|
220
|
+
srv.listen(16)
|
|
221
|
+
try:
|
|
222
|
+
while True:
|
|
223
|
+
conn, _ = srv.accept()
|
|
224
|
+
try:
|
|
225
|
+
self._serve_http_conn(conn)
|
|
226
|
+
except OSError:
|
|
227
|
+
pass
|
|
228
|
+
finally:
|
|
229
|
+
conn.close()
|
|
230
|
+
finally:
|
|
231
|
+
srv.close()
|
|
232
|
+
|
|
233
|
+
def _serve_http_conn(self, conn: socket.socket) -> None:
|
|
234
|
+
conn.settimeout(30.0)
|
|
235
|
+
buf = b""
|
|
236
|
+
while b"\r\n\r\n" not in buf:
|
|
237
|
+
chunk = conn.recv(4096)
|
|
238
|
+
if not chunk:
|
|
239
|
+
return
|
|
240
|
+
buf += chunk
|
|
241
|
+
head, _, rest = buf.partition(b"\r\n\r\n")
|
|
242
|
+
length = 0
|
|
243
|
+
for hl in head.split(b"\r\n")[1:]:
|
|
244
|
+
name, _, value = hl.partition(b":")
|
|
245
|
+
if name.strip().lower() == b"content-length":
|
|
246
|
+
try:
|
|
247
|
+
length = int(value.strip())
|
|
248
|
+
except ValueError:
|
|
249
|
+
length = 0
|
|
250
|
+
body = rest
|
|
251
|
+
while len(body) < length:
|
|
252
|
+
chunk = conn.recv(4096)
|
|
253
|
+
if not chunk:
|
|
254
|
+
break
|
|
255
|
+
body += chunk
|
|
256
|
+
text = body[:length].decode("utf-8", "replace") if length else (body.decode("utf-8", "replace") or "{}")
|
|
257
|
+
if not text.strip():
|
|
258
|
+
text = "{}"
|
|
259
|
+
reply = self.handle_line(text)
|
|
260
|
+
if reply:
|
|
261
|
+
payload = reply.encode("utf-8")
|
|
262
|
+
resp = (b"HTTP/1.1 200 OK\r\n"
|
|
263
|
+
b"Content-Type: application/json\r\n"
|
|
264
|
+
b"Content-Length: " + str(len(payload)).encode() + b"\r\n"
|
|
265
|
+
b"Connection: close\r\n\r\n" + payload)
|
|
266
|
+
else:
|
|
267
|
+
resp = (b"HTTP/1.1 204 No Content\r\n"
|
|
268
|
+
b"Connection: close\r\n\r\n")
|
|
269
|
+
conn.sendall(resp)
|
rcp/_transport.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""RCP transports — the request/reply seam (subprocess pipe or HTTP/1.1).
|
|
2
|
+
|
|
3
|
+
A transport is a total function ``dict -> dict``: send one JSON-RPC request
|
|
4
|
+
object, return the reply object. Two concrete transports mirror the C++ SDK
|
|
5
|
+
(``sdk/cpp/include/rcp/transport.hpp``):
|
|
6
|
+
|
|
7
|
+
* :class:`StdioTransport` — newline-delimited JSON over a child's stdin/stdout.
|
|
8
|
+
* :class:`HttpTransport` — minimal HTTP/1.1 POST to ``<base>/<method>``.
|
|
9
|
+
|
|
10
|
+
Failures raise :class:`RcpError` with the same typed codes as the C++ SDK, so a
|
|
11
|
+
caller sees ``[RCP -32010] ...`` whether the fault was local or remote.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
import urllib.parse
|
|
18
|
+
from http.client import HTTPConnection
|
|
19
|
+
|
|
20
|
+
from ._types import Errc, RcpError
|
|
21
|
+
|
|
22
|
+
_COMPACT = (",", ":") # compact JSON separators, matching nlohmann's dump()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _dumps(obj) -> str:
|
|
26
|
+
return json.dumps(obj, separators=_COMPACT)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StdioTransport:
|
|
30
|
+
"""Newline-delimited JSON over a subprocess' stdio.
|
|
31
|
+
|
|
32
|
+
The client writes ``json + "\\n"`` and reads reply lines, **skipping** any
|
|
33
|
+
server-initiated notification (a frame carrying ``method``) and any stray
|
|
34
|
+
reply whose ``id`` does not match the request (spec §4.7).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, proc: subprocess.Popen):
|
|
38
|
+
self._proc = proc
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def spawn(cls, argv) -> "StdioTransport":
|
|
42
|
+
argv = list(argv)
|
|
43
|
+
try:
|
|
44
|
+
proc = subprocess.Popen(
|
|
45
|
+
argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, bufsize=0,
|
|
46
|
+
)
|
|
47
|
+
except OSError as e:
|
|
48
|
+
raise RcpError(Errc.BACKEND_UNAVAILABLE, f"cannot spawn {argv!r}: {e}")
|
|
49
|
+
return cls(proc)
|
|
50
|
+
|
|
51
|
+
def call(self, request: dict) -> dict:
|
|
52
|
+
line = (_dumps(request) + "\n").encode("utf-8")
|
|
53
|
+
try:
|
|
54
|
+
self._proc.stdin.write(line)
|
|
55
|
+
self._proc.stdin.flush()
|
|
56
|
+
except (BrokenPipeError, OSError):
|
|
57
|
+
raise RcpError(Errc.BACKEND_UNAVAILABLE, "write to server failed")
|
|
58
|
+
|
|
59
|
+
want_id = request.get("id")
|
|
60
|
+
while True:
|
|
61
|
+
raw = self._proc.stdout.readline()
|
|
62
|
+
if not raw:
|
|
63
|
+
raise RcpError(Errc.BACKEND_UNAVAILABLE, "server closed the connection")
|
|
64
|
+
try:
|
|
65
|
+
reply = json.loads(raw)
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
raise RcpError(Errc.PARSE_ERROR, str(e))
|
|
68
|
+
if isinstance(reply, dict):
|
|
69
|
+
# Skip a server-initiated notification (has "method", no reply id
|
|
70
|
+
# to match) and a mismatched-id reply from an earlier request.
|
|
71
|
+
if "method" in reply:
|
|
72
|
+
continue
|
|
73
|
+
if want_id is not None and "id" in reply and reply["id"] != want_id:
|
|
74
|
+
continue
|
|
75
|
+
return reply
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
proc = self._proc
|
|
79
|
+
if proc.poll() is not None:
|
|
80
|
+
return
|
|
81
|
+
for stream in (proc.stdin, proc.stdout):
|
|
82
|
+
try:
|
|
83
|
+
if stream is not None:
|
|
84
|
+
stream.close()
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
try:
|
|
88
|
+
proc.terminate()
|
|
89
|
+
proc.wait(timeout=2)
|
|
90
|
+
except Exception:
|
|
91
|
+
try:
|
|
92
|
+
proc.kill()
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class HttpTransport:
|
|
98
|
+
"""Minimal HTTP/1.1 client: POST the request to ``<base_url>/<method>``.
|
|
99
|
+
|
|
100
|
+
The server ignores the path and dispatches on the body's ``method``; the path
|
|
101
|
+
is a human-readable convenience (e.g. ``POST /rcp/retrieve``). Maps HTTP 429
|
|
102
|
+
→ RateLimited and 503 → BackendUnavailable (spec §5.2).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, base_url: str, timeout: float = 30.0):
|
|
106
|
+
self._base = base_url.rstrip("/")
|
|
107
|
+
self._timeout = timeout
|
|
108
|
+
|
|
109
|
+
def call(self, request: dict) -> dict:
|
|
110
|
+
method = request.get("method", "")
|
|
111
|
+
url = self._base + "/" + method
|
|
112
|
+
parts = urllib.parse.urlsplit(url)
|
|
113
|
+
if parts.scheme != "http":
|
|
114
|
+
raise RcpError(Errc.INVALID_PARAMS, f"unsupported URL scheme '{parts.scheme}'")
|
|
115
|
+
path = parts.path or "/"
|
|
116
|
+
if parts.query:
|
|
117
|
+
path += "?" + parts.query
|
|
118
|
+
body = _dumps(request).encode("utf-8")
|
|
119
|
+
|
|
120
|
+
conn = HTTPConnection(parts.hostname or "127.0.0.1", parts.port or 80,
|
|
121
|
+
timeout=self._timeout)
|
|
122
|
+
try:
|
|
123
|
+
conn.request("POST", path, body=body,
|
|
124
|
+
headers={"Content-Type": "application/json",
|
|
125
|
+
"Content-Length": str(len(body))})
|
|
126
|
+
resp = conn.getresponse()
|
|
127
|
+
status = resp.status
|
|
128
|
+
data = resp.read()
|
|
129
|
+
except OSError as e:
|
|
130
|
+
raise RcpError(Errc.BACKEND_UNAVAILABLE, f"HTTP request failed: {e}")
|
|
131
|
+
finally:
|
|
132
|
+
conn.close()
|
|
133
|
+
|
|
134
|
+
if status == 429:
|
|
135
|
+
raise RcpError(Errc.RATE_LIMITED, "HTTP 429")
|
|
136
|
+
if status == 503:
|
|
137
|
+
raise RcpError(Errc.BACKEND_UNAVAILABLE, "HTTP 503")
|
|
138
|
+
if (status < 200 or status >= 300) and not data.strip():
|
|
139
|
+
raise RcpError(Errc.BACKEND_UNAVAILABLE, f"HTTP {status}")
|
|
140
|
+
try:
|
|
141
|
+
return json.loads(data)
|
|
142
|
+
except ValueError as e:
|
|
143
|
+
raise RcpError(Errc.PARSE_ERROR, str(e))
|
|
144
|
+
|
|
145
|
+
def close(self) -> None: # HTTP is connectionless here; nothing to hold open.
|
|
146
|
+
pass
|
rcp/_types.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Core RCP/1 wire vocabulary — protocol version, capabilities, methods, errors.
|
|
2
|
+
|
|
3
|
+
Pure Python, no dependencies. The values here mirror the type-theoretic C++ SDK
|
|
4
|
+
(``sdk/cpp/include/rcp/{types,protocol}.hpp``) byte-for-byte on the wire, so a
|
|
5
|
+
Python client and a C++ server (or vice-versa) speak the exact same JSON-RPC.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
|
|
11
|
+
# Protocol version negotiation (spec §7.1). We speak exactly version 1.
|
|
12
|
+
PROTOCOL_VERSION = 1
|
|
13
|
+
MIN_PROTOCOL_VERSION = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def negotiate_version(theirs) -> int:
|
|
17
|
+
"""min(peer, ours), clamped — mirrors rcp::negotiate_version."""
|
|
18
|
+
try:
|
|
19
|
+
t = int(theirs)
|
|
20
|
+
except (TypeError, ValueError):
|
|
21
|
+
t = PROTOCOL_VERSION
|
|
22
|
+
return t if t < PROTOCOL_VERSION else PROTOCOL_VERSION
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Capability(enum.Enum):
|
|
26
|
+
"""The capability lattice (spec §7.2). ``.value`` is the wire JSON key, so a
|
|
27
|
+
capability check is a total match on a typed enum, never string-poking."""
|
|
28
|
+
Embed = "embed"
|
|
29
|
+
SparseEmbed = "sparseEmbed"
|
|
30
|
+
MultiVector = "multiVector"
|
|
31
|
+
Rerank = "rerank"
|
|
32
|
+
Retrieve = "retrieve"
|
|
33
|
+
Transform = "transform"
|
|
34
|
+
Graph = "graph"
|
|
35
|
+
Index = "index"
|
|
36
|
+
Catalog = "catalog"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Method:
|
|
40
|
+
"""RCP/1 method names (spec §9). String constants — usable anywhere a wire
|
|
41
|
+
method name is expected (``s.on(Method.RETRIEVE)`` or ``s.on("retrieve")``)."""
|
|
42
|
+
INITIALIZE = "initialize"
|
|
43
|
+
INFO = "info"
|
|
44
|
+
EMBED = "embed"
|
|
45
|
+
EMBED_SPARSE = "embed/sparse"
|
|
46
|
+
EMBED_MULTI = "embed/multi"
|
|
47
|
+
RERANK = "rerank"
|
|
48
|
+
RETRIEVE = "retrieve"
|
|
49
|
+
TRANSFORM = "query/transform"
|
|
50
|
+
GRAPH = "graph"
|
|
51
|
+
INDEX_ADD = "index/add"
|
|
52
|
+
INDEX_DELETE = "index/delete"
|
|
53
|
+
CATALOG_LIST = "catalog/list"
|
|
54
|
+
CANCEL = "notifications/cancel"
|
|
55
|
+
PROGRESS = "notifications/progress"
|
|
56
|
+
LOG = "notifications/log"
|
|
57
|
+
PING = "ping"
|
|
58
|
+
SHUTDOWN = "shutdown"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Errc:
|
|
62
|
+
"""RCP/1 error codes (spec §12). -326xx are JSON-RPC; -320xx are RCP-specific."""
|
|
63
|
+
PARSE_ERROR = -32700
|
|
64
|
+
INVALID_REQUEST = -32600
|
|
65
|
+
METHOD_NOT_FOUND = -32601
|
|
66
|
+
INVALID_PARAMS = -32602
|
|
67
|
+
INTERNAL_ERROR = -32603
|
|
68
|
+
NOT_INITIALIZED = -32001
|
|
69
|
+
VERSION_MISMATCH = -32002
|
|
70
|
+
CAPABILITY_MISSING = -32003
|
|
71
|
+
UNKNOWN_METHOD = -32004
|
|
72
|
+
OPTION_UNSUPPORTED = -32005
|
|
73
|
+
CANCELLED = -32006
|
|
74
|
+
BACKEND_UNAVAILABLE = -32010
|
|
75
|
+
RATE_LIMITED = -32011
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RcpError(RuntimeError):
|
|
79
|
+
"""An RCP protocol or transport error, raised by client calls.
|
|
80
|
+
|
|
81
|
+
Subclasses ``RuntimeError`` so existing ``except RuntimeError`` handlers keep
|
|
82
|
+
working; ``str(e)`` is ``"[RCP <code>] <message>"`` (stable and greppable).
|
|
83
|
+
Carries the structured ``data`` payload (spec §12) plus retry hints.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, code: int, message: str, data=None):
|
|
87
|
+
super().__init__(f"[RCP {code}] {message}")
|
|
88
|
+
self.code = code
|
|
89
|
+
self.message = message
|
|
90
|
+
self.data = data
|
|
91
|
+
|
|
92
|
+
def retryable(self) -> bool:
|
|
93
|
+
"""RateLimited / BackendUnavailable are transient by default; an explicit
|
|
94
|
+
``data.retryable`` overrides the code-based default (spec §12)."""
|
|
95
|
+
if isinstance(self.data, dict) and isinstance(self.data.get("retryable"), bool):
|
|
96
|
+
return self.data["retryable"]
|
|
97
|
+
return self.code in (Errc.RATE_LIMITED, Errc.BACKEND_UNAVAILABLE)
|
|
98
|
+
|
|
99
|
+
def retry_after_ms(self) -> int:
|
|
100
|
+
"""Server-suggested backoff in ms (``data.retryAfterMs``), or -1."""
|
|
101
|
+
if isinstance(self.data, dict) and isinstance(self.data.get("retryAfterMs"), int):
|
|
102
|
+
return self.data["retryAfterMs"]
|
|
103
|
+
return -1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Which capability gates which method (spec §7.2 / §9). Used by both the client
|
|
107
|
+
# (pre-flight gating) and the server (dispatch gating).
|
|
108
|
+
CAP_FOR_METHOD = {
|
|
109
|
+
Method.EMBED: Capability.Embed,
|
|
110
|
+
Method.EMBED_SPARSE: Capability.SparseEmbed,
|
|
111
|
+
Method.EMBED_MULTI: Capability.MultiVector,
|
|
112
|
+
Method.RERANK: Capability.Rerank,
|
|
113
|
+
Method.RETRIEVE: Capability.Retrieve,
|
|
114
|
+
Method.TRANSFORM: Capability.Transform,
|
|
115
|
+
Method.GRAPH: Capability.Graph,
|
|
116
|
+
Method.INDEX_ADD: Capability.Index,
|
|
117
|
+
Method.INDEX_DELETE: Capability.Index,
|
|
118
|
+
Method.CATALOG_LIST: Capability.Catalog,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cap_key(capability) -> str:
|
|
123
|
+
"""Wire JSON key for a Capability enum member (or a raw string passthrough)."""
|
|
124
|
+
return capability.value if isinstance(capability, Capability) else str(capability)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rcp-protocol
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Retrieval Context Protocol — native Python SDK (pure standard library, no dependencies)
|
|
5
|
+
Author: RCP Working Group
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://rcp-6d6ef6d5.mintlify.site/
|
|
8
|
+
Project-URL: Documentation, https://rcp-6d6ef6d5.mintlify.site/
|
|
9
|
+
Project-URL: Specification, https://github.com/1ay1/rcp/blob/main/spec/rcp-1.0.md
|
|
10
|
+
Project-URL: Source, https://github.com/1ay1/rcp
|
|
11
|
+
Project-URL: Changelog, https://rcp-6d6ef6d5.mintlify.site/reference/changelog
|
|
12
|
+
Project-URL: Issues, https://github.com/1ay1/rcp/issues
|
|
13
|
+
Keywords: rcp,rag,retrieval,protocol,json-rpc,mcp,acp
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# rcp — Retrieval Context Protocol, Python SDK
|
|
28
|
+
|
|
29
|
+
A **native, pure-standard-library** Python SDK for
|
|
30
|
+
[RCP](https://rcp-6d6ef6d5.mintlify.site/) — the open, versioned JSON-RPC
|
|
31
|
+
protocol that lets any RAG engine expose
|
|
32
|
+
`embed` / `rerank` / `retrieve` / `graph` / `index` / `catalog`, and any client
|
|
33
|
+
consume it uniformly.
|
|
34
|
+
|
|
35
|
+
No dependencies, no compiler, no build step — just `subprocess`, `http`, `socket`,
|
|
36
|
+
and `json` from the standard library. It speaks the exact same wire format as the
|
|
37
|
+
[C++](../cpp), [Node.js](../node), and [Rust](../rust) SDKs, so a Python client
|
|
38
|
+
and a C++/Node/Rust server (or vice-versa) interoperate byte-for-byte.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pip install rcp-protocol
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python ≥ 3.9. The import name is `rcp`:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import rcp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Client
|
|
53
|
+
|
|
54
|
+
Connect to any RCP server over a subprocess (stdio) or HTTP, then make typed,
|
|
55
|
+
capability-gated calls.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import rcp
|
|
59
|
+
|
|
60
|
+
c = rcp.connect_stdio(["python3", "my_server.py"])
|
|
61
|
+
# or: c = rcp.connect_http("http://127.0.0.1:8000/rcp")
|
|
62
|
+
|
|
63
|
+
print(c.server(), c.protocol_version(), list(c.capabilities()))
|
|
64
|
+
|
|
65
|
+
if c.supports(rcp.Capability.Retrieve):
|
|
66
|
+
for hit in c.retrieve("eiffel tower", k=3):
|
|
67
|
+
print(hit["id"], hit["score"], hit["text"])
|
|
68
|
+
|
|
69
|
+
if c.supports(rcp.Capability.Embed):
|
|
70
|
+
(vec,) = c.embed(["hello world"])
|
|
71
|
+
print("dim", len(vec))
|
|
72
|
+
|
|
73
|
+
c.shutdown()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A call to a capability the server never advertised raises **before** any I/O:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
try:
|
|
80
|
+
c.rerank("q", ["a", "b"])
|
|
81
|
+
except rcp.RcpError as e:
|
|
82
|
+
# e.code == rcp.Errc.CAPABILITY_MISSING (-32003)
|
|
83
|
+
...
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Client methods: `embed`, `embed_sparse`, `embed_multi`, `rerank`, `retrieve`,
|
|
87
|
+
`search` (returns `hits` + `usage` + `nextCursor`), `graph`, `transform`,
|
|
88
|
+
`index_add`, `index_delete`, `catalog`, `info`, `ping`, `call`, `shutdown`.
|
|
89
|
+
|
|
90
|
+
## Server
|
|
91
|
+
|
|
92
|
+
Expose a Python RAG engine as an RCP server.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import rcp
|
|
96
|
+
|
|
97
|
+
s = rcp.Server()
|
|
98
|
+
s.set_info("my-engine", "1.0")
|
|
99
|
+
s.advertise(rcp.Capability.Retrieve, {"maxK": 100, "modes": ["hybrid"]})
|
|
100
|
+
|
|
101
|
+
@s.on(rcp.Method.RETRIEVE)
|
|
102
|
+
def _(params):
|
|
103
|
+
hits = my_index.search(params["query"], params.get("k", 10))
|
|
104
|
+
return {"hits": [{"id": h.id, "score": h.score, "text": h.text} for h in hits]}
|
|
105
|
+
|
|
106
|
+
s.serve_stdio() # or: s.serve_http(8000)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The `Server` owns the `initialize` handshake, capability gating, JSON-RPC framing
|
|
110
|
+
and batching, and error mapping. It answers `initialize` / `info` / `ping` /
|
|
111
|
+
`shutdown` itself; a gated call before `initialize` is `-32001`, an unadvertised
|
|
112
|
+
or unimplemented method is `-32003`, an unknown method is `-32004`.
|
|
113
|
+
|
|
114
|
+
## Selecting a backend
|
|
115
|
+
|
|
116
|
+
Pick one engine from a registry — by id, by required capability, or by priority
|
|
117
|
+
with liveness fallback. Connection is lazy.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
sel = rcp.Selector.loads('''{
|
|
121
|
+
"engines": [
|
|
122
|
+
{"id": "docs", "transport": "stdio", "command": ["python3", "server.py"], "priority": 10},
|
|
123
|
+
{"id": "web", "transport": "http", "url": "http://127.0.0.1:8000/rcp", "priority": 5}
|
|
124
|
+
]
|
|
125
|
+
}''')
|
|
126
|
+
|
|
127
|
+
c = sel.select_capable(rcp.Capability.Retrieve)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Examples & tests
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
python3 examples/example_server.py # stdio (default)
|
|
134
|
+
python3 examples/example_server.py --http 8000
|
|
135
|
+
python3 examples/example_client.py # drives the example server
|
|
136
|
+
|
|
137
|
+
python3 test_bindings.py # smoke test incl. Python client ↔ C++ server
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT © 2026 Ayush Bhat.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
rcp/__init__.py,sha256=N8uA_Zn2CHlUTDy2t9P61As6fFtLkzxR843ALveRUCc,2288
|
|
2
|
+
rcp/_client.py,sha256=oUX7HB0GwRY3GI1R8VFqbfYuQ7qk__jFdOXFzqv6DlE,9267
|
|
3
|
+
rcp/_selector.py,sha256=fo2RZIJBbFh3lL9AYzmBqM1AfRmr1cRc6F6lyr_jEi8,5825
|
|
4
|
+
rcp/_server.py,sha256=GVy4nR5AwyuHMA5AMLNghdhWtJV85lAxGEB33T1Rdv0,11066
|
|
5
|
+
rcp/_transport.py,sha256=90MxHyHk6oFhWQbtiJkpmkPnxv56qiH3K15MJ5U726E,5287
|
|
6
|
+
rcp/_types.py,sha256=t1kWDWUkMX_INbdQKLYJDLD1tai6cxOoKh3YOIdiPUk,4306
|
|
7
|
+
rcp_protocol-1.0.0.dist-info/licenses/LICENSE,sha256=z64CEQ5xJIj69_97LLH5UTFFKIfX7T-3hLUWRtfGt9U,1067
|
|
8
|
+
rcp_protocol-1.0.0.dist-info/METADATA,sha256=yGAG72Dp5uWWFzMo-P2PfWHZGB8bHiXNDJ-MBMRl1t4,4508
|
|
9
|
+
rcp_protocol-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
rcp_protocol-1.0.0.dist-info/top_level.txt,sha256=1fy4Y26VJuryrN2s_38oW3KxNMz6lD6MaNUZW8tKPiE,4
|
|
11
|
+
rcp_protocol-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ayush Bhat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rcp
|