rcp-protocol 1.0.0__tar.gz

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.
@@ -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,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,116 @@
1
+ # rcp — Retrieval Context Protocol, Python SDK
2
+
3
+ A **native, pure-standard-library** Python SDK for
4
+ [RCP](https://rcp-6d6ef6d5.mintlify.site/) — the open, versioned JSON-RPC
5
+ protocol that lets any RAG engine expose
6
+ `embed` / `rerank` / `retrieve` / `graph` / `index` / `catalog`, and any client
7
+ consume it uniformly.
8
+
9
+ No dependencies, no compiler, no build step — just `subprocess`, `http`, `socket`,
10
+ and `json` from the standard library. It speaks the exact same wire format as the
11
+ [C++](../cpp), [Node.js](../node), and [Rust](../rust) SDKs, so a Python client
12
+ and a C++/Node/Rust server (or vice-versa) interoperate byte-for-byte.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ pip install rcp-protocol
18
+ ```
19
+
20
+ Requires Python ≥ 3.9. The import name is `rcp`:
21
+
22
+ ```python
23
+ import rcp
24
+ ```
25
+
26
+ ## Client
27
+
28
+ Connect to any RCP server over a subprocess (stdio) or HTTP, then make typed,
29
+ capability-gated calls.
30
+
31
+ ```python
32
+ import rcp
33
+
34
+ c = rcp.connect_stdio(["python3", "my_server.py"])
35
+ # or: c = rcp.connect_http("http://127.0.0.1:8000/rcp")
36
+
37
+ print(c.server(), c.protocol_version(), list(c.capabilities()))
38
+
39
+ if c.supports(rcp.Capability.Retrieve):
40
+ for hit in c.retrieve("eiffel tower", k=3):
41
+ print(hit["id"], hit["score"], hit["text"])
42
+
43
+ if c.supports(rcp.Capability.Embed):
44
+ (vec,) = c.embed(["hello world"])
45
+ print("dim", len(vec))
46
+
47
+ c.shutdown()
48
+ ```
49
+
50
+ A call to a capability the server never advertised raises **before** any I/O:
51
+
52
+ ```python
53
+ try:
54
+ c.rerank("q", ["a", "b"])
55
+ except rcp.RcpError as e:
56
+ # e.code == rcp.Errc.CAPABILITY_MISSING (-32003)
57
+ ...
58
+ ```
59
+
60
+ Client methods: `embed`, `embed_sparse`, `embed_multi`, `rerank`, `retrieve`,
61
+ `search` (returns `hits` + `usage` + `nextCursor`), `graph`, `transform`,
62
+ `index_add`, `index_delete`, `catalog`, `info`, `ping`, `call`, `shutdown`.
63
+
64
+ ## Server
65
+
66
+ Expose a Python RAG engine as an RCP server.
67
+
68
+ ```python
69
+ import rcp
70
+
71
+ s = rcp.Server()
72
+ s.set_info("my-engine", "1.0")
73
+ s.advertise(rcp.Capability.Retrieve, {"maxK": 100, "modes": ["hybrid"]})
74
+
75
+ @s.on(rcp.Method.RETRIEVE)
76
+ def _(params):
77
+ hits = my_index.search(params["query"], params.get("k", 10))
78
+ return {"hits": [{"id": h.id, "score": h.score, "text": h.text} for h in hits]}
79
+
80
+ s.serve_stdio() # or: s.serve_http(8000)
81
+ ```
82
+
83
+ The `Server` owns the `initialize` handshake, capability gating, JSON-RPC framing
84
+ and batching, and error mapping. It answers `initialize` / `info` / `ping` /
85
+ `shutdown` itself; a gated call before `initialize` is `-32001`, an unadvertised
86
+ or unimplemented method is `-32003`, an unknown method is `-32004`.
87
+
88
+ ## Selecting a backend
89
+
90
+ Pick one engine from a registry — by id, by required capability, or by priority
91
+ with liveness fallback. Connection is lazy.
92
+
93
+ ```python
94
+ sel = rcp.Selector.loads('''{
95
+ "engines": [
96
+ {"id": "docs", "transport": "stdio", "command": ["python3", "server.py"], "priority": 10},
97
+ {"id": "web", "transport": "http", "url": "http://127.0.0.1:8000/rcp", "priority": 5}
98
+ ]
99
+ }''')
100
+
101
+ c = sel.select_capable(rcp.Capability.Retrieve)
102
+ ```
103
+
104
+ ## Examples & tests
105
+
106
+ ```sh
107
+ python3 examples/example_server.py # stdio (default)
108
+ python3 examples/example_server.py --http 8000
109
+ python3 examples/example_client.py # drives the example server
110
+
111
+ python3 test_bindings.py # smoke test incl. Python client ↔ C++ server
112
+ ```
113
+
114
+ ## License
115
+
116
+ MIT © 2026 Ayush Bhat.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rcp-protocol"
7
+ version = "1.0.0"
8
+ description = "Retrieval Context Protocol — native Python SDK (pure standard library, no dependencies)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "RCP Working Group" }]
14
+ keywords = ["rcp", "rag", "retrieval", "protocol", "json-rpc", "mcp", "acp"]
15
+ classifiers = [
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Operating System :: OS Independent",
21
+ "Topic :: Software Development :: Libraries",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ "Typing :: Typed",
24
+ ]
25
+
26
+ [tool.setuptools]
27
+ packages = ["rcp"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://rcp-6d6ef6d5.mintlify.site/"
31
+ Documentation = "https://rcp-6d6ef6d5.mintlify.site/"
32
+ Specification = "https://github.com/1ay1/rcp/blob/main/spec/rcp-1.0.md"
33
+ Source = "https://github.com/1ay1/rcp"
34
+ Changelog = "https://rcp-6d6ef6d5.mintlify.site/reference/changelog"
35
+ Issues = "https://github.com/1ay1/rcp/issues"
@@ -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
@@ -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 {}