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 ADDED
@@ -0,0 +1,3 @@
1
+ """CEmbedding — local-first embedding server (the reference /embed server for CPersona)."""
2
+
3
+ __version__ = "0.5.0"
cembedding/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m cembedding``."""
2
+
3
+ from cembedding.server import run
4
+
5
+ if __name__ == "__main__":
6
+ run()
@@ -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()