koshi 0.4.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.
- koshi/__init__.py +45 -0
- koshi/_manifest.py +38 -0
- koshi/binary.py +251 -0
- koshi/client.py +471 -0
- koshi/errors.py +50 -0
- koshi/py.typed +0 -0
- koshi-0.4.0.dist-info/METADATA +144 -0
- koshi-0.4.0.dist-info/RECORD +9 -0
- koshi-0.4.0.dist-info/WHEEL +4 -0
koshi/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
koshi — Python client for the Koshi MCP server.
|
|
3
|
+
|
|
4
|
+
Quickstart::
|
|
5
|
+
|
|
6
|
+
from koshi import Client
|
|
7
|
+
|
|
8
|
+
with Client() as koshi:
|
|
9
|
+
print(koshi.version())
|
|
10
|
+
koshi.index_directory("/path/to/repo")
|
|
11
|
+
print(koshi.search("auth pattern", top_k=5))
|
|
12
|
+
|
|
13
|
+
The first call auto-downloads the matching native AOT binary (~15 MB)
|
|
14
|
+
into your user cache. No .NET install required. Set ``KOSHI_BIN`` to
|
|
15
|
+
override the resolver with an explicit path (e.g. an air-gapped
|
|
16
|
+
deployment, or a build of your own).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from ._manifest import VERSION as __version__
|
|
22
|
+
from .client import Client
|
|
23
|
+
from .errors import (
|
|
24
|
+
BinaryCorruptedError,
|
|
25
|
+
BinaryNotFoundError,
|
|
26
|
+
IncompatibleBinaryError,
|
|
27
|
+
KoshiError,
|
|
28
|
+
KoshiTimeoutError,
|
|
29
|
+
ProtocolError,
|
|
30
|
+
ToolError,
|
|
31
|
+
UnsupportedPlatformError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"BinaryCorruptedError",
|
|
36
|
+
"BinaryNotFoundError",
|
|
37
|
+
"Client",
|
|
38
|
+
"IncompatibleBinaryError",
|
|
39
|
+
"KoshiError",
|
|
40
|
+
"KoshiTimeoutError",
|
|
41
|
+
"ProtocolError",
|
|
42
|
+
"ToolError",
|
|
43
|
+
"UnsupportedPlatformError",
|
|
44
|
+
"__version__",
|
|
45
|
+
]
|
koshi/_manifest.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AUTO-GENERATED at release time by scripts/inject-manifest.py.
|
|
3
|
+
DO NOT EDIT BY HAND. Re-run from the release workflow if it needs to change.
|
|
4
|
+
|
|
5
|
+
Binds this Python wheel to the exact AOT binaries published in the
|
|
6
|
+
matching GitHub release. The binary auto-downloader verifies every
|
|
7
|
+
fetched binary against EXPECTED_BINARIES; a tampered binary on the
|
|
8
|
+
GitHub release alone is not enough to compromise users.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
VERSION: str = "0.4.0"
|
|
14
|
+
|
|
15
|
+
RELEASE_URL_TEMPLATE: str = "https://github.com/jsharma1105/Koshi/releases/download/v{version}/{filename}"
|
|
16
|
+
|
|
17
|
+
EXPECTED_BINARIES: dict[str, dict[str, str]] = {
|
|
18
|
+
"linux-arm64": {
|
|
19
|
+
"filename": "koshi-mcp-linux-arm64",
|
|
20
|
+
"sha256": "9534bb3bf856d84c7b62d05dc04a9749b43c92f24e5b474f12cd5e7276ffd347",
|
|
21
|
+
},
|
|
22
|
+
"linux-x64": {
|
|
23
|
+
"filename": "koshi-mcp-linux-x64",
|
|
24
|
+
"sha256": "50580dd397bcca12b97a2385cc75babb32dc426015b03fbee1d3eb3ff4baac97",
|
|
25
|
+
},
|
|
26
|
+
"osx-arm64": {
|
|
27
|
+
"filename": "koshi-mcp-osx-arm64",
|
|
28
|
+
"sha256": "2bc707ffee8f1a42208da68fcbe2814ce849dc7594b57915ef524d362e1c1768",
|
|
29
|
+
},
|
|
30
|
+
"win-arm64": {
|
|
31
|
+
"filename": "koshi-mcp-win-arm64.exe",
|
|
32
|
+
"sha256": "0b1b8e8469803113ef0cc74e5a87219a51f8977097fea1fb4e9f38d7e4c3546c",
|
|
33
|
+
},
|
|
34
|
+
"win-x64": {
|
|
35
|
+
"filename": "koshi-mcp-win-x64.exe",
|
|
36
|
+
"sha256": "9c9645c1d635aa5be27837d88644b83411169acf91c68bd292e49f5b624f061c",
|
|
37
|
+
},
|
|
38
|
+
}
|
koshi/binary.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resolve and (if needed) download the koshi-mcp AOT binary.
|
|
3
|
+
|
|
4
|
+
Resolution order:
|
|
5
|
+
1. ``KOSHI_BIN`` env var (explicit override; version is verified after spawn).
|
|
6
|
+
2. Versioned cache: ``<cache>/koshi/bin/<VERSION>/koshi-mcp-<rid>[.exe]``.
|
|
7
|
+
The path itself is version-pinned, so a hit cannot be stale.
|
|
8
|
+
3. ``shutil.which("koshi-mcp")`` (e.g. installed via ``dotnet tool install``);
|
|
9
|
+
version is verified after spawn.
|
|
10
|
+
4. Download from the matching GitHub release with strict SHA-256
|
|
11
|
+
verification against the hashes embedded in ``_manifest.py``.
|
|
12
|
+
|
|
13
|
+
Atomicity: downloads write to a temp file, are hash-verified, then renamed
|
|
14
|
+
with ``os.replace`` (atomic on every supported OS).
|
|
15
|
+
|
|
16
|
+
Concurrency: a per-version file lock protects the cache so two processes
|
|
17
|
+
constructing ``Client()`` at the same time cannot both fetch the binary.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import hashlib
|
|
24
|
+
import os
|
|
25
|
+
import platform
|
|
26
|
+
import shutil
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from contextlib import contextmanager
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from urllib.error import HTTPError, URLError
|
|
33
|
+
from urllib.request import Request, urlopen
|
|
34
|
+
|
|
35
|
+
from ._manifest import EXPECTED_BINARIES, RELEASE_URL_TEMPLATE, VERSION
|
|
36
|
+
from .errors import (
|
|
37
|
+
BinaryCorruptedError,
|
|
38
|
+
BinaryNotFoundError,
|
|
39
|
+
UnsupportedPlatformError,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_RID_MAP: dict[tuple[str, str], str] = {
|
|
43
|
+
("Linux", "x86_64"): "linux-x64",
|
|
44
|
+
("Linux", "aarch64"): "linux-arm64",
|
|
45
|
+
("Linux", "arm64"): "linux-arm64",
|
|
46
|
+
# ("Darwin", "x86_64"): "osx-x64", # Intel Mac not published as of v0.4.0
|
|
47
|
+
("Darwin", "arm64"): "osx-arm64",
|
|
48
|
+
("Windows", "AMD64"): "win-x64",
|
|
49
|
+
("Windows", "x86_64"): "win-x64",
|
|
50
|
+
("Windows", "ARM64"): "win-arm64",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_DOWNLOAD_TIMEOUT_S = 60
|
|
54
|
+
_DOWNLOAD_CHUNK = 1 << 16 # 64 KiB
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def detect_rid() -> str:
|
|
58
|
+
"""Return the .NET RID for the current OS/CPU, raising on unsupported combos."""
|
|
59
|
+
system = platform.system()
|
|
60
|
+
machine = platform.machine()
|
|
61
|
+
rid = _RID_MAP.get((system, machine))
|
|
62
|
+
if rid is None:
|
|
63
|
+
hint = ""
|
|
64
|
+
if system == "Darwin" and machine == "x86_64":
|
|
65
|
+
hint = (
|
|
66
|
+
" (Intel Macs are not in the AOT release matrix as of v0.4.0; "
|
|
67
|
+
"Apple stopped shipping Intel Macs in 2023.)"
|
|
68
|
+
)
|
|
69
|
+
raise UnsupportedPlatformError(
|
|
70
|
+
f"No published Koshi AOT artifact for {system}/{machine}.{hint} "
|
|
71
|
+
f"Install .NET 10 and `dotnet tool install --global Koshi.Mcp` instead, "
|
|
72
|
+
f"then set KOSHI_BIN to the resulting koshi-mcp executable."
|
|
73
|
+
)
|
|
74
|
+
return rid
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cache_root() -> Path:
|
|
78
|
+
"""Return the OS-appropriate cache root (stdlib-only — no platformdirs)."""
|
|
79
|
+
if sys.platform == "win32":
|
|
80
|
+
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
81
|
+
elif sys.platform == "darwin":
|
|
82
|
+
base = str(Path.home() / "Library" / "Caches")
|
|
83
|
+
else:
|
|
84
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
85
|
+
return Path(base) / "koshi"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def versioned_cache_dir() -> Path:
|
|
89
|
+
return _cache_root() / "bin" / VERSION
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _binary_filename(rid: str) -> str:
|
|
93
|
+
info = EXPECTED_BINARIES.get(rid)
|
|
94
|
+
if info is not None:
|
|
95
|
+
return info["filename"]
|
|
96
|
+
return f"koshi-mcp-{rid}{'.exe' if rid.startswith('win-') else ''}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _sha256_file(path: Path) -> str:
|
|
100
|
+
h = hashlib.sha256()
|
|
101
|
+
with path.open("rb") as f:
|
|
102
|
+
for chunk in iter(lambda: f.read(_DOWNLOAD_CHUNK), b""):
|
|
103
|
+
h.update(chunk)
|
|
104
|
+
return h.hexdigest()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@contextmanager
|
|
108
|
+
def _file_lock(lock_path: Path) -> Iterator[None]:
|
|
109
|
+
"""Best-effort cross-platform exclusive lock on ``lock_path``."""
|
|
110
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o600)
|
|
112
|
+
try:
|
|
113
|
+
if sys.platform == "win32":
|
|
114
|
+
import msvcrt
|
|
115
|
+
|
|
116
|
+
for _ in range(600): # ≤ 60 s
|
|
117
|
+
try:
|
|
118
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
119
|
+
break
|
|
120
|
+
except OSError:
|
|
121
|
+
time.sleep(0.1)
|
|
122
|
+
else:
|
|
123
|
+
raise BinaryNotFoundError(
|
|
124
|
+
f"Timed out acquiring cache lock {lock_path}"
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
import fcntl
|
|
128
|
+
|
|
129
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
130
|
+
yield
|
|
131
|
+
finally:
|
|
132
|
+
try:
|
|
133
|
+
if sys.platform == "win32":
|
|
134
|
+
import msvcrt
|
|
135
|
+
|
|
136
|
+
with contextlib.suppress(OSError):
|
|
137
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
138
|
+
else:
|
|
139
|
+
import fcntl
|
|
140
|
+
|
|
141
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
142
|
+
finally:
|
|
143
|
+
os.close(fd)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _atomic_download(url: str, dest: Path, expected_sha256: str | None) -> None:
|
|
147
|
+
"""Download ``url`` to ``dest`` atomically. Raise on hash mismatch."""
|
|
148
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
tmp = dest.parent / f".{dest.name}.{os.getpid()}.{int(time.time_ns())}.tmp"
|
|
150
|
+
|
|
151
|
+
headers = {"User-Agent": f"koshi-py/{VERSION}", "Accept": "application/octet-stream"}
|
|
152
|
+
request = Request(url, headers=headers)
|
|
153
|
+
try:
|
|
154
|
+
with urlopen(request, timeout=_DOWNLOAD_TIMEOUT_S) as resp:
|
|
155
|
+
if resp.status != 200:
|
|
156
|
+
raise BinaryNotFoundError(
|
|
157
|
+
f"Failed to download {url}: HTTP {resp.status}"
|
|
158
|
+
)
|
|
159
|
+
with tmp.open("wb") as f:
|
|
160
|
+
while True:
|
|
161
|
+
chunk = resp.read(_DOWNLOAD_CHUNK)
|
|
162
|
+
if not chunk:
|
|
163
|
+
break
|
|
164
|
+
f.write(chunk)
|
|
165
|
+
except HTTPError as e:
|
|
166
|
+
tmp.unlink(missing_ok=True)
|
|
167
|
+
raise BinaryNotFoundError(f"HTTP {e.code} fetching {url}: {e.reason}") from e
|
|
168
|
+
except URLError as e:
|
|
169
|
+
tmp.unlink(missing_ok=True)
|
|
170
|
+
raise BinaryNotFoundError(f"Network error fetching {url}: {e.reason}") from e
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
if expected_sha256 is not None:
|
|
174
|
+
actual = _sha256_file(tmp)
|
|
175
|
+
if actual.lower() != expected_sha256.lower():
|
|
176
|
+
raise BinaryCorruptedError(
|
|
177
|
+
f"Downloaded binary hash mismatch for {dest.name}: "
|
|
178
|
+
f"expected {expected_sha256}, got {actual}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if os.name != "nt":
|
|
182
|
+
os.chmod(tmp, 0o755)
|
|
183
|
+
|
|
184
|
+
os.replace(tmp, dest)
|
|
185
|
+
finally:
|
|
186
|
+
# If replace() above succeeded, tmp no longer exists; this is a safety net.
|
|
187
|
+
with contextlib.suppress(FileNotFoundError):
|
|
188
|
+
tmp.unlink()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _download_to_cache(rid: str) -> Path:
|
|
192
|
+
"""Download the matching AOT binary into the versioned cache."""
|
|
193
|
+
expected = EXPECTED_BINARIES.get(rid)
|
|
194
|
+
if expected is None:
|
|
195
|
+
raise BinaryNotFoundError(
|
|
196
|
+
f"This dev build of koshi has no manifest entry for {rid}. "
|
|
197
|
+
f"Either install koshi-mcp via `dotnet tool install --global Koshi.Mcp`, "
|
|
198
|
+
f"or set KOSHI_BIN to a built koshi-mcp binary, or install koshi from PyPI."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
filename = expected["filename"]
|
|
202
|
+
sha256 = expected["sha256"]
|
|
203
|
+
url = RELEASE_URL_TEMPLATE.format(version=VERSION, filename=filename)
|
|
204
|
+
|
|
205
|
+
cache_dir = versioned_cache_dir()
|
|
206
|
+
dest = cache_dir / filename
|
|
207
|
+
lock = cache_dir / ".lock"
|
|
208
|
+
|
|
209
|
+
with _file_lock(lock):
|
|
210
|
+
# Another process may have completed the download while we waited.
|
|
211
|
+
if dest.exists() and _sha256_file(dest) == sha256:
|
|
212
|
+
return dest
|
|
213
|
+
_atomic_download(url, dest, expected_sha256=sha256)
|
|
214
|
+
return dest
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def resolve_binary() -> Path:
|
|
218
|
+
"""
|
|
219
|
+
Resolve the koshi-mcp binary by walking the resolution chain.
|
|
220
|
+
|
|
221
|
+
Version-matching is enforced at spawn time by :class:`koshi.Client` via
|
|
222
|
+
the ``serverInfo.version`` returned during MCP initialize, not here.
|
|
223
|
+
This function's job is only to return *some* path that exists.
|
|
224
|
+
"""
|
|
225
|
+
rid = detect_rid()
|
|
226
|
+
|
|
227
|
+
env_bin = os.environ.get("KOSHI_BIN")
|
|
228
|
+
if env_bin:
|
|
229
|
+
p = Path(env_bin)
|
|
230
|
+
if not p.exists():
|
|
231
|
+
raise BinaryNotFoundError(
|
|
232
|
+
f"KOSHI_BIN is set to '{env_bin}' but that path does not exist."
|
|
233
|
+
)
|
|
234
|
+
return p
|
|
235
|
+
|
|
236
|
+
cached = versioned_cache_dir() / _binary_filename(rid)
|
|
237
|
+
if cached.exists():
|
|
238
|
+
info = EXPECTED_BINARIES.get(rid)
|
|
239
|
+
if info is not None:
|
|
240
|
+
if _sha256_file(cached) == info["sha256"]:
|
|
241
|
+
return cached
|
|
242
|
+
with contextlib.suppress(OSError):
|
|
243
|
+
cached.unlink()
|
|
244
|
+
else:
|
|
245
|
+
return cached
|
|
246
|
+
|
|
247
|
+
on_path = shutil.which("koshi-mcp") or shutil.which("koshi-mcp.exe")
|
|
248
|
+
if on_path:
|
|
249
|
+
return Path(on_path)
|
|
250
|
+
|
|
251
|
+
return _download_to_cache(rid)
|
koshi/client.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Synchronous MCP client for the koshi-mcp server.
|
|
3
|
+
|
|
4
|
+
Spawns the koshi-mcp binary as a subprocess, performs the JSON-RPC 2.0
|
|
5
|
+
``initialize`` / ``notifications/initialized`` handshake, then exposes
|
|
6
|
+
one Python method per Koshi MCP tool. Every tool method is a thin
|
|
7
|
+
wrapper around the MCP ``tools/call`` envelope — the wire format is
|
|
8
|
+
fixed and asserted in the test suite.
|
|
9
|
+
|
|
10
|
+
Threading: each ``Client`` is safe for use from a single thread (or
|
|
11
|
+
under a user-supplied lock). The internal lock serialises request /
|
|
12
|
+
response correlation; it is not a re-entrant lock.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable, Mapping
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from types import TracebackType
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ._manifest import VERSION
|
|
29
|
+
from .binary import resolve_binary
|
|
30
|
+
from .errors import (
|
|
31
|
+
IncompatibleBinaryError,
|
|
32
|
+
KoshiError,
|
|
33
|
+
KoshiTimeoutError,
|
|
34
|
+
ProtocolError,
|
|
35
|
+
ToolError,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = ["Client"]
|
|
39
|
+
|
|
40
|
+
_LogHandler = Callable[[str], None]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Client:
|
|
44
|
+
"""Stdio MCP client bound to a single koshi-mcp subprocess.
|
|
45
|
+
|
|
46
|
+
Use as a context manager::
|
|
47
|
+
|
|
48
|
+
with koshi.Client() as c:
|
|
49
|
+
print(c.version())
|
|
50
|
+
c.index_directory("/path/to/repo")
|
|
51
|
+
print(c.search("auth pattern"))
|
|
52
|
+
|
|
53
|
+
Or manage lifecycle manually::
|
|
54
|
+
|
|
55
|
+
c = koshi.Client()
|
|
56
|
+
c.open()
|
|
57
|
+
try:
|
|
58
|
+
print(c.health())
|
|
59
|
+
finally:
|
|
60
|
+
c.close()
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
binary: str | Path | None = None,
|
|
66
|
+
log_handler: _LogHandler | None = None,
|
|
67
|
+
timeout: float = 15.0,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._binary_arg: Path | None = Path(binary) if binary is not None else None
|
|
70
|
+
self._log_handler = log_handler
|
|
71
|
+
self.timeout = timeout
|
|
72
|
+
|
|
73
|
+
self._binary: Path | None = None
|
|
74
|
+
self._proc: subprocess.Popen[str] | None = None
|
|
75
|
+
self._next_id = 0
|
|
76
|
+
self._lock = threading.Lock()
|
|
77
|
+
self._stderr_thread: threading.Thread | None = None
|
|
78
|
+
self._server_version: str | None = None
|
|
79
|
+
|
|
80
|
+
# ─── lifecycle ───────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def __enter__(self) -> Client:
|
|
83
|
+
self.open()
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def __exit__(
|
|
87
|
+
self,
|
|
88
|
+
exc_type: type[BaseException] | None,
|
|
89
|
+
exc: BaseException | None,
|
|
90
|
+
tb: TracebackType | None,
|
|
91
|
+
) -> None:
|
|
92
|
+
self.close()
|
|
93
|
+
|
|
94
|
+
def open(self) -> None:
|
|
95
|
+
if self._proc is not None:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
self._binary = self._binary_arg or resolve_binary()
|
|
99
|
+
|
|
100
|
+
# text=True + line buffering means every JSON document is a single
|
|
101
|
+
# readline()/write() call. Koshi-mcp emits one document per line.
|
|
102
|
+
self._proc = subprocess.Popen(
|
|
103
|
+
[str(self._binary)],
|
|
104
|
+
stdin=subprocess.PIPE,
|
|
105
|
+
stdout=subprocess.PIPE,
|
|
106
|
+
stderr=subprocess.PIPE,
|
|
107
|
+
text=True,
|
|
108
|
+
bufsize=1,
|
|
109
|
+
encoding="utf-8",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True)
|
|
113
|
+
self._stderr_thread.start()
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
init = self._rpc(
|
|
117
|
+
"initialize",
|
|
118
|
+
{
|
|
119
|
+
"protocolVersion": "2024-11-05",
|
|
120
|
+
"capabilities": {},
|
|
121
|
+
"clientInfo": {"name": "koshi-py", "version": VERSION},
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
except KoshiError:
|
|
125
|
+
self.close()
|
|
126
|
+
raise
|
|
127
|
+
|
|
128
|
+
server_info = init.get("serverInfo") or {}
|
|
129
|
+
raw_version = str(server_info.get("version", ""))
|
|
130
|
+
# Strip "+<git-sha>" if present (InformationalVersionAttribute appends it).
|
|
131
|
+
version_core = raw_version.split("+", 1)[0]
|
|
132
|
+
self._server_version = version_core
|
|
133
|
+
|
|
134
|
+
if version_core and version_core != VERSION:
|
|
135
|
+
self.close()
|
|
136
|
+
raise IncompatibleBinaryError(
|
|
137
|
+
f"Resolved koshi-mcp binary reports version '{raw_version}', "
|
|
138
|
+
f"but Python koshi {VERSION} requires an exact-version match. "
|
|
139
|
+
f"Either upgrade koshi-mcp to {VERSION}, downgrade `pip install koshi=={version_core}`, "
|
|
140
|
+
f"unset KOSHI_BIN, or remove the stale `dotnet tool install` of Koshi.Mcp from PATH."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
self._notify("notifications/initialized")
|
|
144
|
+
|
|
145
|
+
def close(self) -> None:
|
|
146
|
+
proc = self._proc
|
|
147
|
+
if proc is None:
|
|
148
|
+
return
|
|
149
|
+
self._proc = None
|
|
150
|
+
try:
|
|
151
|
+
if proc.stdin is not None:
|
|
152
|
+
with contextlib.suppress(OSError):
|
|
153
|
+
proc.stdin.close()
|
|
154
|
+
try:
|
|
155
|
+
proc.wait(timeout=5)
|
|
156
|
+
except subprocess.TimeoutExpired:
|
|
157
|
+
proc.kill()
|
|
158
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
159
|
+
proc.wait(timeout=2)
|
|
160
|
+
finally:
|
|
161
|
+
self._server_version = None
|
|
162
|
+
|
|
163
|
+
# ─── transport ──────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def _drain_stderr(self) -> None:
|
|
166
|
+
proc = self._proc
|
|
167
|
+
if proc is None or proc.stderr is None:
|
|
168
|
+
return
|
|
169
|
+
try:
|
|
170
|
+
for line in proc.stderr:
|
|
171
|
+
line = line.rstrip("\n")
|
|
172
|
+
if self._log_handler is not None:
|
|
173
|
+
# Never let a user log handler crash the drainer.
|
|
174
|
+
with contextlib.suppress(Exception):
|
|
175
|
+
self._log_handler(line)
|
|
176
|
+
except (ValueError, OSError):
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
def _ensure_open(self) -> subprocess.Popen[str]:
|
|
180
|
+
if self._proc is None:
|
|
181
|
+
raise ProtocolError("Client is not open. Call .open() or use `with` statement.")
|
|
182
|
+
return self._proc
|
|
183
|
+
|
|
184
|
+
def _send_line(self, payload: Mapping[str, Any]) -> None:
|
|
185
|
+
proc = self._ensure_open()
|
|
186
|
+
if proc.stdin is None:
|
|
187
|
+
raise ProtocolError("subprocess stdin is unexpectedly None")
|
|
188
|
+
proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
|
189
|
+
proc.stdin.flush()
|
|
190
|
+
|
|
191
|
+
def _rpc(self, method: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
192
|
+
with self._lock:
|
|
193
|
+
self._next_id += 1
|
|
194
|
+
req_id = self._next_id
|
|
195
|
+
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method}
|
|
196
|
+
if params is not None:
|
|
197
|
+
payload["params"] = params
|
|
198
|
+
|
|
199
|
+
self._send_line(payload)
|
|
200
|
+
|
|
201
|
+
proc = self._ensure_open()
|
|
202
|
+
assert proc.stdout is not None
|
|
203
|
+
|
|
204
|
+
deadline = time.monotonic() + self.timeout
|
|
205
|
+
while True:
|
|
206
|
+
remaining = deadline - time.monotonic()
|
|
207
|
+
if remaining <= 0:
|
|
208
|
+
raise KoshiTimeoutError(
|
|
209
|
+
f"No response to '{method}' within {self.timeout:.1f}s"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
line = proc.stdout.readline()
|
|
213
|
+
if not line:
|
|
214
|
+
raise ProtocolError(
|
|
215
|
+
f"koshi-mcp closed stdout while waiting for '{method}' response"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
line = line.strip()
|
|
219
|
+
if not line:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
msg = json.loads(line)
|
|
224
|
+
except json.JSONDecodeError as e:
|
|
225
|
+
raise ProtocolError(
|
|
226
|
+
f"Non-JSON line on stdout while waiting for '{method}': {line!r}"
|
|
227
|
+
) from e
|
|
228
|
+
|
|
229
|
+
if msg.get("id") != req_id:
|
|
230
|
+
# Stray notification or out-of-order reply; ignore and keep reading.
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
if "error" in msg:
|
|
234
|
+
err = msg["error"]
|
|
235
|
+
raise ProtocolError(
|
|
236
|
+
f"{method}: {err.get('message', err)} (code {err.get('code')})"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return msg.get("result") or {}
|
|
240
|
+
|
|
241
|
+
def _notify(self, method: str, params: Mapping[str, Any] | None = None) -> None:
|
|
242
|
+
with self._lock:
|
|
243
|
+
payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
244
|
+
if params is not None:
|
|
245
|
+
payload["params"] = params
|
|
246
|
+
self._send_line(payload)
|
|
247
|
+
|
|
248
|
+
def _tool_call(self, tool: str, arguments: Mapping[str, Any] | None = None) -> str:
|
|
249
|
+
result = self._rpc("tools/call", {"name": tool, "arguments": dict(arguments or {})})
|
|
250
|
+
content = result.get("content") or []
|
|
251
|
+
text = ""
|
|
252
|
+
if content and isinstance(content, list):
|
|
253
|
+
first = content[0]
|
|
254
|
+
if isinstance(first, dict):
|
|
255
|
+
text = str(first.get("text", ""))
|
|
256
|
+
|
|
257
|
+
is_error = bool(result.get("isError"))
|
|
258
|
+
if is_error or text.startswith("❌"):
|
|
259
|
+
raise ToolError(tool, text or "tool returned an error", raw=result)
|
|
260
|
+
return text
|
|
261
|
+
|
|
262
|
+
# ─── server metadata ────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def server_version(self) -> str | None:
|
|
266
|
+
"""Version reported by the koshi-mcp server during initialize (or None)."""
|
|
267
|
+
return self._server_version
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def binary_path(self) -> Path | None:
|
|
271
|
+
"""Filesystem path of the koshi-mcp binary actually spawned."""
|
|
272
|
+
return self._binary
|
|
273
|
+
|
|
274
|
+
# ─── Retrieval (5) ──────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
def index(self, documents: list[Mapping[str, Any]]) -> str:
|
|
277
|
+
"""Index a list of in-memory documents for BM25 retrieval."""
|
|
278
|
+
return self._tool_call("koshi_index", {"documents": json.dumps(documents)})
|
|
279
|
+
|
|
280
|
+
def index_directory(
|
|
281
|
+
self,
|
|
282
|
+
path: str | None = None,
|
|
283
|
+
pattern: str | None = None,
|
|
284
|
+
max_file_size_kb: int = 256,
|
|
285
|
+
max_files: int = 5000,
|
|
286
|
+
) -> str:
|
|
287
|
+
"""Recursively index supported text files under ``path``."""
|
|
288
|
+
args: dict[str, Any] = {"maxFileSizeKb": max_file_size_kb, "maxFiles": max_files}
|
|
289
|
+
if path is not None:
|
|
290
|
+
args["path"] = path
|
|
291
|
+
if pattern is not None:
|
|
292
|
+
args["pattern"] = pattern
|
|
293
|
+
return self._tool_call("koshi_index_directory", args)
|
|
294
|
+
|
|
295
|
+
def search(self, query: str, top_k: int = 5) -> str:
|
|
296
|
+
"""Search the indexed corpus with BM25; return the top ``top_k`` chunks."""
|
|
297
|
+
return self._tool_call("koshi_search", {"query": query, "topK": top_k})
|
|
298
|
+
|
|
299
|
+
def list_indexed(self) -> str:
|
|
300
|
+
"""List all indexed documents grouped by source."""
|
|
301
|
+
return self._tool_call("koshi_list_indexed")
|
|
302
|
+
|
|
303
|
+
def clear_index(self) -> str:
|
|
304
|
+
"""Clear the indexed corpus."""
|
|
305
|
+
return self._tool_call("koshi_clear_index")
|
|
306
|
+
|
|
307
|
+
# ─── Memory (5) ─────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
def remember(
|
|
310
|
+
self,
|
|
311
|
+
content: str,
|
|
312
|
+
subject: str,
|
|
313
|
+
type: str = "Fact",
|
|
314
|
+
confidence: float = 0.8,
|
|
315
|
+
source: str = "user",
|
|
316
|
+
) -> str:
|
|
317
|
+
"""Store a fact / decision / pattern / preference in memory."""
|
|
318
|
+
return self._tool_call(
|
|
319
|
+
"koshi_remember",
|
|
320
|
+
{
|
|
321
|
+
"content": content,
|
|
322
|
+
"subject": subject,
|
|
323
|
+
"type": type,
|
|
324
|
+
"confidence": confidence,
|
|
325
|
+
"source": source,
|
|
326
|
+
},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def recall(self, query: str, type: str = "All", top_k: int = 5) -> str:
|
|
330
|
+
"""Recall memories relevant to ``query``."""
|
|
331
|
+
return self._tool_call(
|
|
332
|
+
"koshi_recall",
|
|
333
|
+
{"query": query, "type": type, "topK": top_k},
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def memory_stats(self) -> str:
|
|
337
|
+
"""Return summary stats about the memory store."""
|
|
338
|
+
return self._tool_call("koshi_memory_stats")
|
|
339
|
+
|
|
340
|
+
def forget(self, subject: str) -> str:
|
|
341
|
+
"""Remove all memories with the given subject (case-insensitive)."""
|
|
342
|
+
return self._tool_call("koshi_forget", {"subject": subject})
|
|
343
|
+
|
|
344
|
+
def clear_memories(self, confirm: bool = False) -> str:
|
|
345
|
+
"""Clear ALL stored memories. Pass ``confirm=True`` to actually delete."""
|
|
346
|
+
return self._tool_call("koshi_clear_memories", {"confirm": confirm})
|
|
347
|
+
|
|
348
|
+
# ─── Context (3) ────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
def compile_context(
|
|
351
|
+
self,
|
|
352
|
+
system_prompt: str,
|
|
353
|
+
user_query: str,
|
|
354
|
+
retrieved_content: str | None = None,
|
|
355
|
+
memories: str | None = None,
|
|
356
|
+
team_context: str | None = None,
|
|
357
|
+
token_budget: int = 8192,
|
|
358
|
+
strategy: str = "CacheOptimized",
|
|
359
|
+
) -> str:
|
|
360
|
+
"""Compile a context bundle suitable for an LLM call."""
|
|
361
|
+
args: dict[str, Any] = {
|
|
362
|
+
"systemPrompt": system_prompt,
|
|
363
|
+
"userQuery": user_query,
|
|
364
|
+
"tokenBudget": token_budget,
|
|
365
|
+
"strategy": strategy,
|
|
366
|
+
}
|
|
367
|
+
if retrieved_content is not None:
|
|
368
|
+
args["retrievedContent"] = retrieved_content
|
|
369
|
+
if memories is not None:
|
|
370
|
+
args["memories"] = memories
|
|
371
|
+
if team_context is not None:
|
|
372
|
+
args["teamContext"] = team_context
|
|
373
|
+
return self._tool_call("koshi_compile_context", args)
|
|
374
|
+
|
|
375
|
+
def token_count(self, text: str) -> str:
|
|
376
|
+
"""Count GPT-4 (cl100k) tokens in ``text``."""
|
|
377
|
+
return self._tool_call("koshi_token_count", {"text": text})
|
|
378
|
+
|
|
379
|
+
def budget_plan(
|
|
380
|
+
self,
|
|
381
|
+
total_budget: int = 8192,
|
|
382
|
+
system_prompt: str | None = None,
|
|
383
|
+
team_context: str | None = None,
|
|
384
|
+
) -> str:
|
|
385
|
+
"""Plan a token budget across system / retrieval / memory / history."""
|
|
386
|
+
args: dict[str, Any] = {"totalBudget": total_budget}
|
|
387
|
+
if system_prompt is not None:
|
|
388
|
+
args["systemPrompt"] = system_prompt
|
|
389
|
+
if team_context is not None:
|
|
390
|
+
args["teamContext"] = team_context
|
|
391
|
+
return self._tool_call("koshi_budget_plan", args)
|
|
392
|
+
|
|
393
|
+
# ─── Team / Quality (5) ─────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
def register_team(
|
|
396
|
+
self,
|
|
397
|
+
team_id: str,
|
|
398
|
+
name: str,
|
|
399
|
+
description: str | None = None,
|
|
400
|
+
token_budget: int = 8192,
|
|
401
|
+
top_k: int = 5,
|
|
402
|
+
quality_target: float = 0.7,
|
|
403
|
+
system_prompt: str | None = None,
|
|
404
|
+
team_context: str | None = None,
|
|
405
|
+
) -> str:
|
|
406
|
+
"""Register a team for quality scoring."""
|
|
407
|
+
args: dict[str, Any] = {
|
|
408
|
+
"teamId": team_id,
|
|
409
|
+
"name": name,
|
|
410
|
+
"tokenBudget": token_budget,
|
|
411
|
+
"topK": top_k,
|
|
412
|
+
"qualityTarget": quality_target,
|
|
413
|
+
}
|
|
414
|
+
if description is not None:
|
|
415
|
+
args["description"] = description
|
|
416
|
+
if system_prompt is not None:
|
|
417
|
+
args["systemPrompt"] = system_prompt
|
|
418
|
+
if team_context is not None:
|
|
419
|
+
args["teamContext"] = team_context
|
|
420
|
+
return self._tool_call("koshi_register_team", args)
|
|
421
|
+
|
|
422
|
+
def score_turn(
|
|
423
|
+
self,
|
|
424
|
+
team_id: str,
|
|
425
|
+
retrieved_chunks: int = 0,
|
|
426
|
+
memories_recalled: int = 0,
|
|
427
|
+
budget_utilization: float = 0.5,
|
|
428
|
+
cache_ratio: float = 0.0,
|
|
429
|
+
latency_ms: int = 3000,
|
|
430
|
+
user_rating: int = 0,
|
|
431
|
+
issues: str | None = None,
|
|
432
|
+
) -> str:
|
|
433
|
+
"""Score a single turn and update the team's running quality score."""
|
|
434
|
+
args: dict[str, Any] = {
|
|
435
|
+
"teamId": team_id,
|
|
436
|
+
"retrievedChunks": retrieved_chunks,
|
|
437
|
+
"memoriesRecalled": memories_recalled,
|
|
438
|
+
"budgetUtilization": budget_utilization,
|
|
439
|
+
"cacheRatio": cache_ratio,
|
|
440
|
+
"latencyMs": latency_ms,
|
|
441
|
+
"userRating": user_rating,
|
|
442
|
+
}
|
|
443
|
+
if issues is not None:
|
|
444
|
+
args["issues"] = issues
|
|
445
|
+
return self._tool_call("koshi_score_turn", args)
|
|
446
|
+
|
|
447
|
+
def team_dashboard(self, team_id: str) -> str:
|
|
448
|
+
"""Render the quality dashboard for a team."""
|
|
449
|
+
return self._tool_call("koshi_team_dashboard", {"teamId": team_id})
|
|
450
|
+
|
|
451
|
+
def analyze_feedback(self, team_id: str) -> str:
|
|
452
|
+
"""Analyse user feedback trends for a team."""
|
|
453
|
+
return self._tool_call("koshi_analyze_feedback", {"teamId": team_id})
|
|
454
|
+
|
|
455
|
+
def list_teams(self) -> str:
|
|
456
|
+
"""List all registered teams."""
|
|
457
|
+
return self._tool_call("koshi_list_teams")
|
|
458
|
+
|
|
459
|
+
# ─── Diagnostics (2) ────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
def version(self) -> str:
|
|
462
|
+
"""Report Koshi MCP server version, .NET runtime, OS, and uptime."""
|
|
463
|
+
return self._tool_call("koshi_version")
|
|
464
|
+
|
|
465
|
+
def health(self) -> str:
|
|
466
|
+
"""Report runtime health: indexed corpus, memory, persistence, uptime."""
|
|
467
|
+
return self._tool_call("koshi_health")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
if sys.version_info < (3, 10): # pragma: no cover # noqa: UP036
|
|
471
|
+
raise RuntimeError("koshi requires Python 3.10 or newer.")
|
koshi/errors.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Exception hierarchy for the koshi Python client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KoshiError(Exception):
|
|
7
|
+
"""Base class for all koshi errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BinaryNotFoundError(KoshiError):
|
|
11
|
+
"""No suitable koshi-mcp binary was found on this system."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IncompatibleBinaryError(KoshiError):
|
|
15
|
+
"""The resolved binary's version does not match this koshi package.
|
|
16
|
+
|
|
17
|
+
Raised when:
|
|
18
|
+
* KOSHI_BIN points to a binary whose `serverInfo.version` differs from
|
|
19
|
+
koshi.__version__.
|
|
20
|
+
* A binary found on PATH (e.g. an old `dotnet tool install` of
|
|
21
|
+
Koshi.Mcp) has a mismatched version.
|
|
22
|
+
|
|
23
|
+
The Python package and the koshi-mcp binary version must match exactly.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BinaryCorruptedError(KoshiError):
|
|
28
|
+
"""A downloaded binary's SHA-256 hash did not match the expected hash."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnsupportedPlatformError(KoshiError):
|
|
32
|
+
"""No AOT artifact is published for this OS/CPU combination."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProtocolError(KoshiError):
|
|
36
|
+
"""Received an unexpected or malformed message from the koshi-mcp server."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ToolError(KoshiError):
|
|
40
|
+
"""The MCP server returned an error response for a tools/call request."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, tool: str, message: str, raw: object | None = None) -> None:
|
|
43
|
+
self.tool = tool
|
|
44
|
+
self.message = message
|
|
45
|
+
self.raw = raw
|
|
46
|
+
super().__init__(f"{tool}: {message}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class KoshiTimeoutError(KoshiError):
|
|
50
|
+
"""A request to the koshi-mcp server timed out before a response arrived."""
|
koshi/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: koshi
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Python client for the Koshi MCP server — retrieval, memory, context, and quality tools for any LLM workflow. Auto-downloads a native AOT binary; no .NET install required.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jsharma1105/Koshi
|
|
6
|
+
Project-URL: Documentation, https://github.com/jsharma1105/Koshi#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/jsharma1105/Koshi
|
|
8
|
+
Project-URL: Issues, https://github.com/jsharma1105/Koshi/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/jsharma1105/Koshi/blob/main/CHANGELOG.md
|
|
10
|
+
Author: Koshi Contributors
|
|
11
|
+
License: MIT
|
|
12
|
+
Keywords: agent,ai,bm25,claude,context-engineering,copilot,cursor,llm,mcp,memory,model-context-protocol,rag,retrieval
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
17
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
34
|
+
Requires-Dist: twine>=5.1; extra == 'dev'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# koshi
|
|
38
|
+
|
|
39
|
+
[](https://pypi.org/project/koshi/)
|
|
40
|
+
[](https://pypi.org/project/koshi/)
|
|
41
|
+
[](https://github.com/jsharma1105/Koshi/blob/main/LICENSE)
|
|
42
|
+
|
|
43
|
+
**Python client for the [Koshi MCP server](https://github.com/jsharma1105/Koshi).** Retrieval, memory, context engineering, and quality scoring for any LLM workflow — 20 tools, all wrapped as Pythonic methods.
|
|
44
|
+
|
|
45
|
+
> No `.NET install` required. The first call auto-downloads a small native AOT binary (~15 MB) into your user cache.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install koshi
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Requires Python 3.10+ on Linux x64/arm64, macOS x64/arm64, or Windows x64/arm64. Zero runtime dependencies — every byte is Python stdlib.
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from koshi import Client
|
|
61
|
+
|
|
62
|
+
with Client() as koshi:
|
|
63
|
+
print(koshi.version()) # diagnostics
|
|
64
|
+
koshi.index_directory("/path/to/your/repo") # BM25 index of all text files
|
|
65
|
+
print(koshi.search("auth pattern", top_k=5)) # ranked retrieval
|
|
66
|
+
koshi.remember(content="JWT tokens expire after 1h",
|
|
67
|
+
subject="auth", type="Decision")
|
|
68
|
+
print(koshi.recall("auth")) # memory recall with recency + confidence
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The first `Client()` call:
|
|
72
|
+
|
|
73
|
+
1. Detects your OS/CPU.
|
|
74
|
+
2. Looks for the matching `koshi-mcp` binary in `KOSHI_BIN` → versioned cache → `PATH` → GitHub release.
|
|
75
|
+
3. Downloads, verifies SHA-256 (hashes are baked into the wheel — supply-chain safe), caches under your user cache dir.
|
|
76
|
+
4. Spawns it as a subprocess, performs the MCP `initialize` handshake.
|
|
77
|
+
5. Confirms the server's reported version matches the Python package version exactly. Mismatch raises `IncompatibleBinaryError`.
|
|
78
|
+
|
|
79
|
+
## The 20 tools
|
|
80
|
+
|
|
81
|
+
| Group | Methods |
|
|
82
|
+
|---|---|
|
|
83
|
+
| **Retrieval** | `index_directory`, `index`, `search`, `list_indexed`, `clear_index` |
|
|
84
|
+
| **Memory** | `remember`, `recall`, `forget`, `memory_stats`, `clear_memories` |
|
|
85
|
+
| **Context** | `compile_context`, `token_count`, `budget_plan` |
|
|
86
|
+
| **Team / Quality** | `register_team`, `score_turn`, `team_dashboard`, `analyze_feedback`, `list_teams` |
|
|
87
|
+
| **Diagnostics** | `version`, `health` |
|
|
88
|
+
|
|
89
|
+
Every method returns the formatted text response from the MCP server. The full Koshi tool reference lives in the [main README](https://github.com/jsharma1105/Koshi#mcp-tools).
|
|
90
|
+
|
|
91
|
+
## Environment variables
|
|
92
|
+
|
|
93
|
+
| Var | Effect |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `KOSHI_BIN` | Path to a `koshi-mcp` binary. Skips auto-download, overrides PATH lookup. Version still verified at spawn time. |
|
|
96
|
+
| `KOSHI_MEMORY_FILE` | Persist memories to this JSON file across server restarts. Default: in-memory only. |
|
|
97
|
+
| `KOSHI_INDEX_PATH` | Default directory for `search` / `index_directory` if you don't pass one. |
|
|
98
|
+
|
|
99
|
+
## Where binaries live
|
|
100
|
+
|
|
101
|
+
| OS | Cache path |
|
|
102
|
+
|---|---|
|
|
103
|
+
| Linux | `$XDG_CACHE_HOME/koshi/bin/<version>/` or `~/.cache/koshi/bin/<version>/` |
|
|
104
|
+
| macOS | `~/Library/Caches/koshi/bin/<version>/` |
|
|
105
|
+
| Windows | `%LOCALAPPDATA%\koshi\bin\<version>\` |
|
|
106
|
+
|
|
107
|
+
The version is part of the path, so upgrading `pip install -U koshi` does not invalidate the cache for older Python projects pinned to an older `koshi` version.
|
|
108
|
+
|
|
109
|
+
## Air-gapped / offline
|
|
110
|
+
|
|
111
|
+
Download the binary that matches your platform from a GitHub release of [Koshi](https://github.com/jsharma1105/Koshi/releases) on a machine with internet, copy it onto the target machine, and point `KOSHI_BIN` at it. The version must match the installed `koshi` Python package exactly.
|
|
112
|
+
|
|
113
|
+
## Error handling
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from koshi import (
|
|
117
|
+
Client,
|
|
118
|
+
KoshiError, # base class
|
|
119
|
+
BinaryNotFoundError, # nothing on disk + download failed
|
|
120
|
+
IncompatibleBinaryError, # version mismatch
|
|
121
|
+
BinaryCorruptedError, # SHA-256 mismatch
|
|
122
|
+
UnsupportedPlatformError, # no AOT artifact for this OS/CPU
|
|
123
|
+
ProtocolError, # malformed MCP message
|
|
124
|
+
ToolError, # tool returned an error response
|
|
125
|
+
KoshiTimeoutError, # request took longer than client.timeout
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Why this exists
|
|
130
|
+
|
|
131
|
+
Koshi's engine is .NET. That's a deliberate choice — the runtime gets us best-in-class JSON, threading, and a great MCP SDK. But it's also adoption friction: most MCP users live in Python.
|
|
132
|
+
|
|
133
|
+
`koshi` (Python) closes the gap. You `pip install koshi` and never think about .NET. Under the hood, you're talking to the same engine the .NET ecosystem uses (`Koshi.Mcp` on NuGet), wire-compatible byte-for-byte.
|
|
134
|
+
|
|
135
|
+
## Links
|
|
136
|
+
|
|
137
|
+
- **Source & issues**: <https://github.com/jsharma1105/Koshi>
|
|
138
|
+
- **Release binaries**: <https://github.com/jsharma1105/Koshi/releases>
|
|
139
|
+
- **NuGet (.NET tool)**: <https://www.nuget.org/packages/Koshi.Mcp>
|
|
140
|
+
- **Changelog**: <https://github.com/jsharma1105/Koshi/blob/main/CHANGELOG.md>
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT — see [LICENSE](https://github.com/jsharma1105/Koshi/blob/main/LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
koshi/__init__.py,sha256=hmH6_KAWm08tFjlfh82DHHu8-Qr_Cx1r0vBv9dTX1PI,1067
|
|
2
|
+
koshi/_manifest.py,sha256=OouDwKw72LOuR6V7t9-zI_4ZB8qzo4OyugsRUoP20Us,1418
|
|
3
|
+
koshi/binary.py,sha256=uV6a_wlPOMDfkd7nrkMZZqiIDGVfKifG123oSDcLeK8,8570
|
|
4
|
+
koshi/client.py,sha256=PI6KwD1tK0x1zxphwV8CMG3IWBVFr4Ktm4_ZL6hDsSg,17262
|
|
5
|
+
koshi/errors.py,sha256=z2Vk6yBXqJfDFhxCSvydcoZxdOffbnynMSNRXuW7jBs,1499
|
|
6
|
+
koshi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
koshi-0.4.0.dist-info/METADATA,sha256=voOsZUVehs0peYXpTt09UwsWi0ytgPqn2e9b5zIMxtM,6687
|
|
8
|
+
koshi-0.4.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
koshi-0.4.0.dist-info/RECORD,,
|