cembedding 0.5.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.
- cembedding/__init__.py +3 -0
- cembedding/__main__.py +6 -0
- cembedding/_vendored_mcp_common/__init__.py +14 -0
- cembedding/_vendored_mcp_common/mcp_utils.py +152 -0
- cembedding/_vendored_mcp_common/validation.py +65 -0
- cembedding/download_model.py +132 -0
- cembedding/server.py +1351 -0
- cembedding-0.5.0.dist-info/METADATA +138 -0
- cembedding-0.5.0.dist-info/RECORD +12 -0
- cembedding-0.5.0.dist-info/WHEEL +4 -0
- cembedding-0.5.0.dist-info/entry_points.txt +3 -0
- cembedding-0.5.0.dist-info/licenses/LICENSE +21 -0
cembedding/__init__.py
ADDED
cembedding/__main__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Vendored subset of the MGP Python common layer.
|
|
2
|
+
|
|
3
|
+
Ported from ``clotohub-servers/servers/common/`` so this server runs
|
|
4
|
+
standalone without depending on that (now private) monorepo:
|
|
5
|
+
|
|
6
|
+
- :mod:`_vendored_mcp_common.validation` — graceful-degradation argument
|
|
7
|
+
validators (``validate_bool`` / ``validate_str`` / ``validate_int`` /
|
|
8
|
+
``validate_dict`` / ``validate_float`` / ``validate_list``).
|
|
9
|
+
- :mod:`_vendored_mcp_common.mcp_utils` — ``ToolRegistry`` MCP tool
|
|
10
|
+
registration helper.
|
|
11
|
+
|
|
12
|
+
Only the symbols this server actually imports are vendored; keep this
|
|
13
|
+
copy in sync with the upstream common layer when it changes.
|
|
14
|
+
"""
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decorator-based MCP tool registration utility.
|
|
3
|
+
Eliminates boilerplate list_tools/call_tool patterns across all servers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from mcp.server import Server
|
|
11
|
+
from mcp.server.stdio import stdio_server
|
|
12
|
+
from mcp.types import TextContent, Tool, ToolAnnotations
|
|
13
|
+
|
|
14
|
+
from cembedding._vendored_mcp_common.validation import validate_bool, validate_dict, validate_float, validate_int, validate_list, validate_str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _MgpValidationFilter(logging.Filter):
|
|
18
|
+
"""Drop mcp.shared.session's bulk pydantic validation warnings.
|
|
19
|
+
|
|
20
|
+
The Python MCP SDK's ``ClientRequest`` union doesn't include MGP
|
|
21
|
+
extensions (``mgp/callback/respond``, ``notifications/mgp.*``). Every
|
|
22
|
+
time the kernel sends one the SDK logs a 30+ line ``Failed to validate
|
|
23
|
+
request`` warning against every known method, even though the SDK's
|
|
24
|
+
own error-response path handles it cleanly. These warnings are pure
|
|
25
|
+
noise and drown out genuine errors.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
29
|
+
msg = record.getMessage()
|
|
30
|
+
return not msg.startswith("Failed to validate request:") and not msg.startswith(
|
|
31
|
+
"Failed to validate notification:"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_MGP_FILTER_INSTALLED = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def install_mgp_validation_filter() -> None:
|
|
39
|
+
"""Install the MGP validation log filter on the root logger.
|
|
40
|
+
|
|
41
|
+
Called automatically by ``run_mcp_server``. Servers with a custom
|
|
42
|
+
main loop (e.g. ones that also serve HTTP) should call this
|
|
43
|
+
explicitly before entering ``stdio_server``.
|
|
44
|
+
"""
|
|
45
|
+
global _MGP_FILTER_INSTALLED
|
|
46
|
+
if _MGP_FILTER_INSTALLED:
|
|
47
|
+
return
|
|
48
|
+
logging.getLogger().addFilter(_MgpValidationFilter())
|
|
49
|
+
_MGP_FILTER_INSTALLED = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_VALIDATORS: dict[type, Callable] = {
|
|
53
|
+
bool: validate_bool,
|
|
54
|
+
str: validate_str,
|
|
55
|
+
int: validate_int,
|
|
56
|
+
float: validate_float,
|
|
57
|
+
dict: validate_dict,
|
|
58
|
+
list: validate_list,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ToolRegistry:
|
|
63
|
+
"""Decorator-based MCP tool registration."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, server_name: str):
|
|
66
|
+
self.server = Server(server_name)
|
|
67
|
+
self._tools: list[Tool] = []
|
|
68
|
+
self._handlers: dict[str, Callable] = {}
|
|
69
|
+
self._bind()
|
|
70
|
+
|
|
71
|
+
def tool(
|
|
72
|
+
self,
|
|
73
|
+
name: str,
|
|
74
|
+
description: str,
|
|
75
|
+
schema: dict,
|
|
76
|
+
annotations: ToolAnnotations | None = None,
|
|
77
|
+
):
|
|
78
|
+
"""Decorator: register a tool handler.
|
|
79
|
+
|
|
80
|
+
The decorated function receives (arguments: dict) and returns a dict.
|
|
81
|
+
JSON serialization and TextContent wrapping are handled automatically.
|
|
82
|
+
|
|
83
|
+
*annotations* is forwarded to the MCP Tool schema. The kernel reads
|
|
84
|
+
``destructiveHint`` from annotations to trigger the HITL approval
|
|
85
|
+
gate for destructive tools.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def decorator(fn):
|
|
89
|
+
tool_kwargs = {"name": name, "description": description, "inputSchema": schema}
|
|
90
|
+
if annotations is not None:
|
|
91
|
+
tool_kwargs["annotations"] = annotations
|
|
92
|
+
self._tools.append(Tool(**tool_kwargs))
|
|
93
|
+
self._handlers[name] = fn
|
|
94
|
+
return fn
|
|
95
|
+
|
|
96
|
+
return decorator
|
|
97
|
+
|
|
98
|
+
def auto_tool(
|
|
99
|
+
self,
|
|
100
|
+
name: str,
|
|
101
|
+
description: str,
|
|
102
|
+
schema: dict,
|
|
103
|
+
handler: Callable,
|
|
104
|
+
params: list[tuple],
|
|
105
|
+
annotations: ToolAnnotations | None = None,
|
|
106
|
+
):
|
|
107
|
+
"""Register a tool with auto-validated parameter extraction.
|
|
108
|
+
|
|
109
|
+
Each entry in *params* is ``(key, type)`` or ``(key, type, default)``.
|
|
110
|
+
Supported types: ``str``, ``int``, ``dict``, ``list``.
|
|
111
|
+
The extracted values are passed positionally to *handler*.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
async def _handler(arguments: dict) -> dict:
|
|
115
|
+
args = []
|
|
116
|
+
for spec in params:
|
|
117
|
+
key, typ = spec[0], spec[1]
|
|
118
|
+
default = spec[2] if len(spec) > 2 else None
|
|
119
|
+
validator = _VALIDATORS[typ]
|
|
120
|
+
if default is not None:
|
|
121
|
+
args.append(validator(arguments, key, default))
|
|
122
|
+
else:
|
|
123
|
+
args.append(validator(arguments, key))
|
|
124
|
+
return await handler(*args)
|
|
125
|
+
|
|
126
|
+
self._tools.append(Tool(name=name, description=description, inputSchema=schema, annotations=annotations))
|
|
127
|
+
self._handlers[name] = _handler
|
|
128
|
+
|
|
129
|
+
def _bind(self):
|
|
130
|
+
registry = self
|
|
131
|
+
|
|
132
|
+
@self.server.list_tools()
|
|
133
|
+
async def list_tools() -> list[Tool]:
|
|
134
|
+
return registry._tools
|
|
135
|
+
|
|
136
|
+
@self.server.call_tool()
|
|
137
|
+
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
138
|
+
handler = registry._handlers.get(name)
|
|
139
|
+
if handler is None:
|
|
140
|
+
return [TextContent(type="text", text=json.dumps({"error": f"Unknown tool: {name}"}))]
|
|
141
|
+
try:
|
|
142
|
+
result = await handler(arguments)
|
|
143
|
+
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
144
|
+
except Exception as e:
|
|
145
|
+
return [TextContent(type="text", text=json.dumps({"error": str(e)}))]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def run_mcp_server(registry: ToolRegistry):
|
|
149
|
+
"""Standard MCP server main loop."""
|
|
150
|
+
install_mgp_validation_filter()
|
|
151
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
152
|
+
await registry.server.run(read_stream, write_stream, registry.server.create_initialization_options())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Common argument validation helpers for MCP tool handlers.
|
|
2
|
+
|
|
3
|
+
All validators return a safe default on type mismatch (graceful degradation).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def validate_bool(arguments: dict, key: str, default: bool = False) -> bool:
|
|
8
|
+
"""Extract a boolean value, returning *default* if missing or wrong type."""
|
|
9
|
+
val = arguments.get(key, default)
|
|
10
|
+
if not isinstance(val, bool):
|
|
11
|
+
return default
|
|
12
|
+
return val
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_str(arguments: dict, key: str, default: str = "") -> str:
|
|
16
|
+
"""Extract a string value, returning *default* if missing or wrong type."""
|
|
17
|
+
val = arguments.get(key, default)
|
|
18
|
+
if not isinstance(val, str):
|
|
19
|
+
return default
|
|
20
|
+
return val
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_int(arguments: dict, key: str, default: int = 0) -> int:
|
|
24
|
+
"""Extract an integer value, returning *default* if missing or wrong type.
|
|
25
|
+
|
|
26
|
+
``bool`` is explicitly excluded (``isinstance(True, int)`` is ``True``).
|
|
27
|
+
"""
|
|
28
|
+
val = arguments.get(key, default)
|
|
29
|
+
if isinstance(val, bool) or not isinstance(val, int):
|
|
30
|
+
return default
|
|
31
|
+
return val
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_dict(arguments: dict, key: str, default: dict | None = None) -> dict:
|
|
35
|
+
"""Extract a dict value, returning *default* (or ``{}``) if missing or wrong type."""
|
|
36
|
+
if default is None:
|
|
37
|
+
default = {}
|
|
38
|
+
val = arguments.get(key, default)
|
|
39
|
+
if not isinstance(val, dict):
|
|
40
|
+
return default
|
|
41
|
+
return val
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_float(arguments: dict, key: str, default: float = 0.0) -> float:
|
|
45
|
+
"""Extract a float value, returning *default* if missing or wrong type.
|
|
46
|
+
|
|
47
|
+
Accepts both ``float`` and ``int`` (JSON integers are valid float inputs).
|
|
48
|
+
``bool`` is explicitly excluded.
|
|
49
|
+
"""
|
|
50
|
+
val = arguments.get(key, default)
|
|
51
|
+
if isinstance(val, bool):
|
|
52
|
+
return default
|
|
53
|
+
if isinstance(val, (int, float)):
|
|
54
|
+
return float(val)
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_list(arguments: dict, key: str, default: list | None = None) -> list:
|
|
59
|
+
"""Extract a list value, returning *default* (or ``[]``) if missing or wrong type."""
|
|
60
|
+
if default is None:
|
|
61
|
+
default = []
|
|
62
|
+
val = arguments.get(key, default)
|
|
63
|
+
if not isinstance(val, list):
|
|
64
|
+
return default
|
|
65
|
+
return val
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Download ONNX embedding models for local inference."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _hf_download(repo_id: str, repo_filename: str, dest_path: str) -> bool:
|
|
10
|
+
"""Download a single file from HuggingFace Hub.
|
|
11
|
+
|
|
12
|
+
Uses huggingface_hub if available (handles LFS, caching, auth).
|
|
13
|
+
Falls back to direct urllib download for minimal-dependency environments.
|
|
14
|
+
"""
|
|
15
|
+
if os.path.exists(dest_path):
|
|
16
|
+
print(f" Already exists: {dest_path}")
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
|
20
|
+
print(f" Downloading: {repo_filename} ...")
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from huggingface_hub import hf_hub_download
|
|
24
|
+
|
|
25
|
+
cached = hf_hub_download(repo_id=repo_id, filename=repo_filename)
|
|
26
|
+
import shutil
|
|
27
|
+
|
|
28
|
+
shutil.copy2(cached, dest_path)
|
|
29
|
+
size_mb = os.path.getsize(dest_path) / (1024 * 1024)
|
|
30
|
+
print(f" Saved: {dest_path} ({size_mb:.1f} MB)")
|
|
31
|
+
return True
|
|
32
|
+
except ImportError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
# Fallback: direct URL
|
|
36
|
+
url = f"https://huggingface.co/{repo_id}/resolve/main/{repo_filename}"
|
|
37
|
+
try:
|
|
38
|
+
urllib.request.urlretrieve(url, dest_path)
|
|
39
|
+
size_mb = os.path.getsize(dest_path) / (1024 * 1024)
|
|
40
|
+
print(f" Saved: {dest_path} ({size_mb:.1f} MB)")
|
|
41
|
+
return True
|
|
42
|
+
except Exception as e:
|
|
43
|
+
print(f" Failed: {e}", file=sys.stderr)
|
|
44
|
+
if os.path.exists(dest_path):
|
|
45
|
+
os.remove(dest_path)
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# MiniLM
|
|
50
|
+
MINIML_DIR = os.environ.get("ONNX_MODEL_DIR", "data/models/all-MiniLM-L6-v2")
|
|
51
|
+
MINIML_REPO = "sentence-transformers/all-MiniLM-L6-v2"
|
|
52
|
+
MINIML_FILES = {
|
|
53
|
+
"model.onnx": "onnx/model.onnx",
|
|
54
|
+
"tokenizer.json": "tokenizer.json",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# jina-v5-nano (retrieval variant with merged LoRA, external data format)
|
|
58
|
+
JINA_REPO = "jinaai/jina-embeddings-v5-text-nano-retrieval"
|
|
59
|
+
JINA_FILES = {
|
|
60
|
+
"model.onnx": "onnx/model.onnx",
|
|
61
|
+
"model.onnx_data": "onnx/model.onnx_data",
|
|
62
|
+
"tokenizer.json": "tokenizer.json",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# bge-m3 (Xenova int8 single-file, ~542MB)
|
|
66
|
+
# Xenova/bge-m3 is the canonical Transformers.js ONNX conversion maintained by HuggingFace
|
|
67
|
+
BGE_M3_REPO = "Xenova/bge-m3"
|
|
68
|
+
BGE_M3_FILES = {
|
|
69
|
+
"model.onnx": "onnx/model_int8.onnx",
|
|
70
|
+
"tokenizer.json": "tokenizer.json",
|
|
71
|
+
"sentencepiece.bpe.model": "sentencepiece.bpe.model",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _download_repo_files(repo_id: str, files: dict[str, str], model_dir: str) -> bool:
|
|
76
|
+
"""Download a set of repo_filename→local_filename mappings into model_dir."""
|
|
77
|
+
os.makedirs(model_dir, exist_ok=True)
|
|
78
|
+
for local_name, repo_filename in files.items():
|
|
79
|
+
dest = os.path.join(model_dir, local_name)
|
|
80
|
+
if not _hf_download(repo_id, repo_filename, dest):
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def download():
|
|
86
|
+
"""Download MiniLM (legacy entrypoint)."""
|
|
87
|
+
print("=== Downloading all-MiniLM-L6-v2 ONNX model ===")
|
|
88
|
+
ok = _download_repo_files(MINIML_REPO, MINIML_FILES, MINIML_DIR)
|
|
89
|
+
if ok:
|
|
90
|
+
print(f"Model ready at {MINIML_DIR}")
|
|
91
|
+
return ok
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def download_jina_v5_nano(model_dir: str = "") -> bool:
|
|
95
|
+
"""Download jina-embeddings-v5-text-nano-retrieval ONNX model."""
|
|
96
|
+
if not model_dir:
|
|
97
|
+
model_dir = os.environ.get("ONNX_MODEL_DIR", "data/models/jina-embeddings-v5-text-nano")
|
|
98
|
+
print("=== Downloading jina-embeddings-v5-text-nano-retrieval ===")
|
|
99
|
+
ok = _download_repo_files(JINA_REPO, JINA_FILES, model_dir)
|
|
100
|
+
if ok:
|
|
101
|
+
print(f"Model ready at {model_dir}")
|
|
102
|
+
return ok
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def download_bge_m3(model_dir: str = "") -> bool:
|
|
106
|
+
"""Download BAAI/bge-m3 int8 quantized ONNX model (~542MB) via Xenova conversion."""
|
|
107
|
+
if not model_dir:
|
|
108
|
+
model_dir = os.environ.get("ONNX_MODEL_DIR", "data/models/bge-m3")
|
|
109
|
+
print("=== Downloading BAAI/bge-m3 ONNX int8 (~542 MB) from Xenova/bge-m3 ===")
|
|
110
|
+
ok = _download_repo_files(BGE_M3_REPO, BGE_M3_FILES, model_dir)
|
|
111
|
+
if ok:
|
|
112
|
+
print(f"Model ready at {model_dir}")
|
|
113
|
+
return ok
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main():
|
|
117
|
+
"""Console-script / ``python -m cembedding.download_model`` entry point."""
|
|
118
|
+
parser = argparse.ArgumentParser(description="Download ONNX embedding models")
|
|
119
|
+
parser.add_argument("--model", default="miniml", choices=["miniml", "jina-v5-nano", "bge-m3"])
|
|
120
|
+
args = parser.parse_args()
|
|
121
|
+
|
|
122
|
+
if args.model == "miniml":
|
|
123
|
+
success = download()
|
|
124
|
+
elif args.model == "jina-v5-nano":
|
|
125
|
+
success = download_jina_v5_nano()
|
|
126
|
+
else:
|
|
127
|
+
success = download_bge_m3()
|
|
128
|
+
sys.exit(0 if success else 1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
main()
|