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.
- rcp_protocol-1.0.0/LICENSE +21 -0
- rcp_protocol-1.0.0/PKG-INFO +142 -0
- rcp_protocol-1.0.0/README.md +116 -0
- rcp_protocol-1.0.0/pyproject.toml +35 -0
- rcp_protocol-1.0.0/rcp/__init__.py +66 -0
- rcp_protocol-1.0.0/rcp/_client.py +222 -0
- rcp_protocol-1.0.0/rcp/_selector.py +143 -0
- rcp_protocol-1.0.0/rcp/_server.py +269 -0
- rcp_protocol-1.0.0/rcp/_transport.py +146 -0
- rcp_protocol-1.0.0/rcp/_types.py +124 -0
- rcp_protocol-1.0.0/rcp_protocol.egg-info/PKG-INFO +142 -0
- rcp_protocol-1.0.0/rcp_protocol.egg-info/SOURCES.txt +14 -0
- rcp_protocol-1.0.0/rcp_protocol.egg-info/dependency_links.txt +1 -0
- rcp_protocol-1.0.0/rcp_protocol.egg-info/top_level.txt +1 -0
- rcp_protocol-1.0.0/setup.cfg +4 -0
- rcp_protocol-1.0.0/setup.py +9 -0
|
@@ -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 {}
|