code-analysis-client 1.0.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.
- code_analysis_client/__init__.py +54 -0
- code_analysis_client/client.py +225 -0
- code_analysis_client/commands_proxy.py +60 -0
- code_analysis_client/config.py +126 -0
- code_analysis_client/exceptions.py +20 -0
- code_analysis_client/py.typed +0 -0
- code_analysis_client/server_schema.py +57 -0
- code_analysis_client/validation.py +101 -0
- code_analysis_client/version.txt +1 -0
- code_analysis_client-1.0.0.dist-info/METADATA +93 -0
- code_analysis_client-1.0.0.dist-info/RECORD +13 -0
- code_analysis_client-1.0.0.dist-info/WHEEL +5 -0
- code_analysis_client-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public async client for code-analysis-server (JSON-RPC via mcp-proxy-adapter).
|
|
3
|
+
|
|
4
|
+
Author: Vasiliy Zdanovskiy
|
|
5
|
+
email: vasilyvz@gmail.com
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from code_analysis_client.client import CodeAnalysisAsyncClient
|
|
13
|
+
from code_analysis_client.commands_proxy import ValidatedCommandsProxy
|
|
14
|
+
from code_analysis_client.config import (
|
|
15
|
+
adapter_settings_from_server_config,
|
|
16
|
+
adapter_settings_to_jsonrpc_kwargs,
|
|
17
|
+
load_server_config,
|
|
18
|
+
)
|
|
19
|
+
from code_analysis_client.exceptions import ClientValidationError
|
|
20
|
+
from code_analysis_client.server_schema import (
|
|
21
|
+
fetch_command_schema_from_server,
|
|
22
|
+
parse_schema_from_help_payload,
|
|
23
|
+
)
|
|
24
|
+
from code_analysis_client.validation import (
|
|
25
|
+
prepare_params_for_schema,
|
|
26
|
+
validate_params_against_schema,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ClientValidationError",
|
|
31
|
+
"CodeAnalysisAsyncClient",
|
|
32
|
+
"ValidatedCommandsProxy",
|
|
33
|
+
"adapter_settings_from_server_config",
|
|
34
|
+
"adapter_settings_to_jsonrpc_kwargs",
|
|
35
|
+
"fetch_command_schema_from_server",
|
|
36
|
+
"load_server_config",
|
|
37
|
+
"parse_schema_from_help_payload",
|
|
38
|
+
"prepare_params_for_schema",
|
|
39
|
+
"validate_params_against_schema",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def _read_package_version() -> str:
|
|
43
|
+
vf = Path(__file__).resolve().parent / "version.txt"
|
|
44
|
+
if vf.is_file():
|
|
45
|
+
return vf.read_text(encoding="utf-8").strip()
|
|
46
|
+
try:
|
|
47
|
+
import importlib.metadata as _imd
|
|
48
|
+
|
|
49
|
+
return _imd.version("code-analysis-client")
|
|
50
|
+
except Exception:
|
|
51
|
+
return "0.0.0"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__version__ = _read_package_version()
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async façade over mcp-proxy-adapter JsonRpcClient for code-analysis-server.
|
|
3
|
+
|
|
4
|
+
Author: Vasiliy Zdanovskiy
|
|
5
|
+
email: vasilyvz@gmail.com
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from mcp_proxy_adapter.client.jsonrpc_client.client import JsonRpcClient
|
|
15
|
+
except Exception: # pragma: no cover - compatibility with older layouts
|
|
16
|
+
from mcp_proxy_adapter.client.jsonrpc_client import JsonRpcClient
|
|
17
|
+
|
|
18
|
+
from code_analysis_client.commands_proxy import ValidatedCommandsProxy
|
|
19
|
+
from code_analysis_client.config import (
|
|
20
|
+
adapter_settings_from_server_config,
|
|
21
|
+
adapter_settings_to_jsonrpc_kwargs,
|
|
22
|
+
load_server_config,
|
|
23
|
+
)
|
|
24
|
+
from code_analysis_client.server_schema import fetch_command_schema_from_server
|
|
25
|
+
from code_analysis_client.validation import (
|
|
26
|
+
prepare_params_for_schema,
|
|
27
|
+
validate_params_against_schema,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CodeAnalysisAsyncClient:
|
|
32
|
+
"""Async client: domain commands via :meth:`call` / :meth:`call_unified`; full adapter API on :attr:`rpc`."""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("_rpc", "_command_schema_cache", "_commands_proxy")
|
|
35
|
+
|
|
36
|
+
def __init__(self, **jsonrpc_kwargs: Any) -> None:
|
|
37
|
+
"""Same keyword arguments as ``JsonRpcClient`` (``protocol``, ``host``, ``port``, ``cert``, …)."""
|
|
38
|
+
self._rpc = JsonRpcClient(**jsonrpc_kwargs)
|
|
39
|
+
self._command_schema_cache: Dict[str, Dict[str, Any]] = {}
|
|
40
|
+
self._commands_proxy: Optional[ValidatedCommandsProxy] = None
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_jsonrpc_kwargs(cls, **kwargs: Any) -> CodeAnalysisAsyncClient:
|
|
44
|
+
"""Alias for ``CodeAnalysisAsyncClient(**kwargs)``."""
|
|
45
|
+
return cls(**kwargs)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_adapter_settings(
|
|
49
|
+
cls,
|
|
50
|
+
settings: Mapping[str, Any],
|
|
51
|
+
*,
|
|
52
|
+
timeout: float | None = 60.0,
|
|
53
|
+
check_hostname: bool = False,
|
|
54
|
+
token_header: str | None = None,
|
|
55
|
+
token: str | None = None,
|
|
56
|
+
) -> CodeAnalysisAsyncClient:
|
|
57
|
+
"""Build from a dict like ``PipelineConfig.generate_adapter_client_settings()``."""
|
|
58
|
+
kwargs = adapter_settings_to_jsonrpc_kwargs(
|
|
59
|
+
settings,
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
check_hostname=check_hostname,
|
|
62
|
+
token_header=token_header,
|
|
63
|
+
token=token,
|
|
64
|
+
)
|
|
65
|
+
return cls(**kwargs)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_server_config(
|
|
69
|
+
cls,
|
|
70
|
+
config: Mapping[str, Any],
|
|
71
|
+
*,
|
|
72
|
+
timeout: float | None = 60.0,
|
|
73
|
+
check_hostname: bool = False,
|
|
74
|
+
token_header: str | None = None,
|
|
75
|
+
token: str | None = None,
|
|
76
|
+
) -> CodeAnalysisAsyncClient:
|
|
77
|
+
"""Derive connection settings from a code-analysis server config object."""
|
|
78
|
+
settings = adapter_settings_from_server_config(config)
|
|
79
|
+
return cls.from_adapter_settings(
|
|
80
|
+
settings,
|
|
81
|
+
timeout=timeout,
|
|
82
|
+
check_hostname=check_hostname,
|
|
83
|
+
token_header=token_header,
|
|
84
|
+
token=token,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_server_config_path(
|
|
89
|
+
cls,
|
|
90
|
+
path: str | Path,
|
|
91
|
+
*,
|
|
92
|
+
timeout: float | None = 60.0,
|
|
93
|
+
check_hostname: bool = False,
|
|
94
|
+
token_header: str | None = None,
|
|
95
|
+
token: str | None = None,
|
|
96
|
+
) -> CodeAnalysisAsyncClient:
|
|
97
|
+
"""Load ``config.json`` and connect."""
|
|
98
|
+
return cls.from_server_config(
|
|
99
|
+
load_server_config(path),
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
check_hostname=check_hostname,
|
|
102
|
+
token_header=token_header,
|
|
103
|
+
token=token,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def rpc(self) -> JsonRpcClient:
|
|
108
|
+
"""Underlying mcp-proxy-adapter client (queue, transfer, ``help``, ``execute_command``, …)."""
|
|
109
|
+
return self._rpc
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def commands(self) -> ValidatedCommandsProxy:
|
|
113
|
+
"""Dynamic validated wrappers: schema is loaded from the server via ``help`` (cached)."""
|
|
114
|
+
if self._commands_proxy is None:
|
|
115
|
+
self._commands_proxy = ValidatedCommandsProxy(self)
|
|
116
|
+
return self._commands_proxy
|
|
117
|
+
|
|
118
|
+
def clear_command_schema_cache(self) -> None:
|
|
119
|
+
"""Drop cached command schemas (after ``reload`` or when server definitions change)."""
|
|
120
|
+
self._command_schema_cache.clear()
|
|
121
|
+
|
|
122
|
+
async def get_command_schema(
|
|
123
|
+
self, command: str, *, refresh: bool = False
|
|
124
|
+
) -> Dict[str, Any]:
|
|
125
|
+
"""Fetch input JSON schema for ``command`` using server ``help`` (with in-memory cache)."""
|
|
126
|
+
if not refresh and command in self._command_schema_cache:
|
|
127
|
+
return self._command_schema_cache[command]
|
|
128
|
+
schema = await fetch_command_schema_from_server(self._rpc, command)
|
|
129
|
+
self._command_schema_cache[command] = schema
|
|
130
|
+
return schema
|
|
131
|
+
|
|
132
|
+
async def call_validated(
|
|
133
|
+
self,
|
|
134
|
+
command: str,
|
|
135
|
+
params: Optional[Dict[str, Any]] = None,
|
|
136
|
+
*,
|
|
137
|
+
use_cmd_endpoint: bool = False,
|
|
138
|
+
refresh_schema: bool = False,
|
|
139
|
+
) -> Dict[str, Any]:
|
|
140
|
+
"""``help`` → schema on server, shallow local validation, then ``execute_command``."""
|
|
141
|
+
schema = await self.get_command_schema(command, refresh=refresh_schema)
|
|
142
|
+
merged = dict(params or {})
|
|
143
|
+
prepared = prepare_params_for_schema(merged, schema)
|
|
144
|
+
validate_params_against_schema(prepared, schema, command_name=command)
|
|
145
|
+
return await self._rpc.execute_command(
|
|
146
|
+
command,
|
|
147
|
+
prepared,
|
|
148
|
+
use_cmd_endpoint=use_cmd_endpoint,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def call_unified_validated(
|
|
152
|
+
self,
|
|
153
|
+
command: str,
|
|
154
|
+
params: Optional[Dict[str, Any]] = None,
|
|
155
|
+
*,
|
|
156
|
+
refresh_schema: bool = False,
|
|
157
|
+
use_cmd_endpoint: bool = False,
|
|
158
|
+
expect_queue: Optional[bool] = None,
|
|
159
|
+
auto_poll: bool = True,
|
|
160
|
+
poll_interval: float = 1.0,
|
|
161
|
+
timeout: Optional[float] = None,
|
|
162
|
+
status_hook: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
|
163
|
+
) -> Dict[str, Any]:
|
|
164
|
+
"""Same as :meth:`call_validated` but uses ``execute_command_unified``."""
|
|
165
|
+
schema = await self.get_command_schema(command, refresh=refresh_schema)
|
|
166
|
+
merged = dict(params or {})
|
|
167
|
+
prepared = prepare_params_for_schema(merged, schema)
|
|
168
|
+
validate_params_against_schema(prepared, schema, command_name=command)
|
|
169
|
+
return await self._rpc.execute_command_unified(
|
|
170
|
+
command,
|
|
171
|
+
prepared,
|
|
172
|
+
use_cmd_endpoint=use_cmd_endpoint,
|
|
173
|
+
expect_queue=expect_queue,
|
|
174
|
+
auto_poll=auto_poll,
|
|
175
|
+
poll_interval=poll_interval,
|
|
176
|
+
timeout=timeout,
|
|
177
|
+
status_hook=status_hook,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
async def call(
|
|
181
|
+
self,
|
|
182
|
+
command: str,
|
|
183
|
+
params: Optional[Dict[str, Any]] = None,
|
|
184
|
+
*,
|
|
185
|
+
use_cmd_endpoint: bool = False,
|
|
186
|
+
) -> Dict[str, Any]:
|
|
187
|
+
"""Run any registered server command (sync completion)."""
|
|
188
|
+
return await self._rpc.execute_command(
|
|
189
|
+
command,
|
|
190
|
+
params or {},
|
|
191
|
+
use_cmd_endpoint=use_cmd_endpoint,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
async def call_unified(
|
|
195
|
+
self,
|
|
196
|
+
command: str,
|
|
197
|
+
params: Optional[Dict[str, Any]] = None,
|
|
198
|
+
*,
|
|
199
|
+
use_cmd_endpoint: bool = False,
|
|
200
|
+
expect_queue: Optional[bool] = None,
|
|
201
|
+
auto_poll: bool = True,
|
|
202
|
+
poll_interval: float = 1.0,
|
|
203
|
+
timeout: Optional[float] = None,
|
|
204
|
+
status_hook: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
|
205
|
+
) -> Dict[str, Any]:
|
|
206
|
+
"""Run a command with optional queue detection and polling (adapter unified path)."""
|
|
207
|
+
return await self._rpc.execute_command_unified(
|
|
208
|
+
command,
|
|
209
|
+
params or {},
|
|
210
|
+
use_cmd_endpoint=use_cmd_endpoint,
|
|
211
|
+
expect_queue=expect_queue,
|
|
212
|
+
auto_poll=auto_poll,
|
|
213
|
+
poll_interval=poll_interval,
|
|
214
|
+
timeout=timeout,
|
|
215
|
+
status_hook=status_hook,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async def close(self) -> None:
|
|
219
|
+
await self._rpc.close()
|
|
220
|
+
|
|
221
|
+
async def __aenter__(self) -> CodeAnalysisAsyncClient:
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
225
|
+
await self.close()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dynamic command wrappers: schema from server ``help``, then local shallow validation.
|
|
3
|
+
|
|
4
|
+
Author: Vasiliy Zdanovskiy
|
|
5
|
+
email: vasilyvz@gmail.com
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Dict, Optional, cast
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from code_analysis_client.client import CodeAnalysisAsyncClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValidatedCommandsProxy:
|
|
17
|
+
"""``client.commands.<name>(**params)`` — fetch schema via ``help``, validate, ``execute_command``."""
|
|
18
|
+
|
|
19
|
+
__slots__ = ("_client",)
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: CodeAnalysisAsyncClient) -> None:
|
|
22
|
+
object.__setattr__(self, "_client", client)
|
|
23
|
+
|
|
24
|
+
def clear_schema_cache(self) -> None:
|
|
25
|
+
"""Forget cached schemas (e.g. after server ``reload``)."""
|
|
26
|
+
self._client.clear_command_schema_cache()
|
|
27
|
+
|
|
28
|
+
async def fetch_schema(
|
|
29
|
+
self, command: str, *, refresh: bool = False
|
|
30
|
+
) -> Dict[str, Any]:
|
|
31
|
+
"""Return input JSON schema for ``command`` (from server ``help``)."""
|
|
32
|
+
return cast(
|
|
33
|
+
Dict[str, Any],
|
|
34
|
+
await self._client.get_command_schema(command, refresh=refresh),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def invoke(
|
|
38
|
+
self,
|
|
39
|
+
command: str,
|
|
40
|
+
params: Optional[Dict[str, Any]] = None,
|
|
41
|
+
**kwargs: Any,
|
|
42
|
+
) -> Dict[str, Any]:
|
|
43
|
+
"""Merge ``params`` and ``kwargs``, validate against server schema, execute."""
|
|
44
|
+
merged: Dict[str, Any] = dict(params or {})
|
|
45
|
+
if kwargs:
|
|
46
|
+
merged.update(kwargs)
|
|
47
|
+
return cast(Dict[str, Any], await self._client.call_validated(command, merged))
|
|
48
|
+
|
|
49
|
+
def __getattr__(self, name: str) -> Any:
|
|
50
|
+
if name.startswith("_"):
|
|
51
|
+
raise AttributeError(name)
|
|
52
|
+
|
|
53
|
+
async def _bound(**kw: Any) -> Dict[str, Any]:
|
|
54
|
+
return cast(Dict[str, Any], await self._client.call_validated(name, kw))
|
|
55
|
+
|
|
56
|
+
_bound.__name__ = name
|
|
57
|
+
_bound.__doc__ = (
|
|
58
|
+
f"Validated call for command {name!r} (schema from server help)."
|
|
59
|
+
)
|
|
60
|
+
return _bound
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Map server / adapter-style settings dicts to JsonRpcClient constructor kwargs.
|
|
3
|
+
|
|
4
|
+
Author: Vasiliy Zdanovskiy
|
|
5
|
+
email: vasilyvz@gmail.com
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _expand_path(value: Any) -> str | None:
|
|
16
|
+
if value is None or value == "":
|
|
17
|
+
return None
|
|
18
|
+
return str(Path(str(value)).expanduser().resolve())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ssl_paths_from_section(ssl_section: Any) -> dict[str, str]:
|
|
22
|
+
if not isinstance(ssl_section, dict):
|
|
23
|
+
return {}
|
|
24
|
+
out: dict[str, str] = {}
|
|
25
|
+
for key in ("cert", "key", "ca", "cert_path", "key_path", "ca_path"):
|
|
26
|
+
raw = ssl_section.get(key)
|
|
27
|
+
expanded = _expand_path(raw) if raw else None
|
|
28
|
+
if expanded:
|
|
29
|
+
out[key] = expanded
|
|
30
|
+
# Normalize aliases to cert/key/ca for downstream comparison / JsonRpcClient
|
|
31
|
+
if "cert" not in out and out.get("cert_path"):
|
|
32
|
+
out["cert"] = out["cert_path"]
|
|
33
|
+
if "key" not in out and out.get("key_path"):
|
|
34
|
+
out["key"] = out["key_path"]
|
|
35
|
+
if "ca" not in out and out.get("ca_path"):
|
|
36
|
+
out["ca"] = out["ca_path"]
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _network_from_server_config(config: Mapping[str, Any]) -> dict[str, Any]:
|
|
41
|
+
server = config.get("server", {})
|
|
42
|
+
if not isinstance(server, dict):
|
|
43
|
+
server = {}
|
|
44
|
+
host = server.get("host", "127.0.0.1")
|
|
45
|
+
if host in ("0.0.0.0", "::", "[::]"):
|
|
46
|
+
host = "127.0.0.1"
|
|
47
|
+
return {
|
|
48
|
+
"host": host,
|
|
49
|
+
"port": int(server.get("port", 15001)),
|
|
50
|
+
"protocol": server.get("protocol", "http"),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _client_mtls_paths_from_server_config(config: Mapping[str, Any]) -> dict[str, str]:
|
|
55
|
+
"""Prefer client.ssl; fall back to server.ssl (same rules as pipeline)."""
|
|
56
|
+
client_section = config.get("client", {})
|
|
57
|
+
if isinstance(client_section, dict):
|
|
58
|
+
ssl_section = client_section.get("ssl", {})
|
|
59
|
+
if isinstance(ssl_section, dict) and any(
|
|
60
|
+
ssl_section.get(k) for k in ("cert", "cert_path", "key", "key_path")
|
|
61
|
+
):
|
|
62
|
+
return _ssl_paths_from_section(ssl_section)
|
|
63
|
+
server = config.get("server", {})
|
|
64
|
+
if not isinstance(server, dict):
|
|
65
|
+
return {}
|
|
66
|
+
return _ssl_paths_from_section(server.get("ssl", {}))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def adapter_settings_from_server_config(config: Mapping[str, Any]) -> dict[str, Any]:
|
|
70
|
+
"""Build adapter-style settings from a code-analysis server config dict."""
|
|
71
|
+
settings = dict(_network_from_server_config(config))
|
|
72
|
+
proto = str(settings.get("protocol", "")).lower()
|
|
73
|
+
if proto == "mtls":
|
|
74
|
+
settings["ssl"] = _client_mtls_paths_from_server_config(config)
|
|
75
|
+
elif proto == "https":
|
|
76
|
+
ssl_paths = _client_mtls_paths_from_server_config(config)
|
|
77
|
+
if ssl_paths.get("cert") and ssl_paths.get("key"):
|
|
78
|
+
settings["ssl"] = ssl_paths
|
|
79
|
+
return settings
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def adapter_settings_to_jsonrpc_kwargs(
|
|
83
|
+
settings: Mapping[str, Any],
|
|
84
|
+
*,
|
|
85
|
+
timeout: float | None = 60.0,
|
|
86
|
+
check_hostname: bool = False,
|
|
87
|
+
token_header: str | None = None,
|
|
88
|
+
token: str | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""Turn adapter-style settings into keyword args for JsonRpcClient."""
|
|
91
|
+
ssl_section = settings.get("ssl")
|
|
92
|
+
cert = key = ca = None
|
|
93
|
+
if isinstance(ssl_section, dict):
|
|
94
|
+
cert = ssl_section.get("cert") or ssl_section.get("cert_path")
|
|
95
|
+
key = ssl_section.get("key") or ssl_section.get("key_path")
|
|
96
|
+
ca = ssl_section.get("ca") or ssl_section.get("ca_path")
|
|
97
|
+
cert = _expand_path(cert) if cert else None
|
|
98
|
+
key = _expand_path(key) if key else None
|
|
99
|
+
ca = _expand_path(ca) if ca else None
|
|
100
|
+
|
|
101
|
+
th = token_header if token_header is not None else settings.get("token_header")
|
|
102
|
+
tok = token if token is not None else settings.get("token")
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"protocol": str(settings.get("protocol", "http")),
|
|
106
|
+
"host": str(settings.get("host", "127.0.0.1")),
|
|
107
|
+
"port": int(settings.get("port", 8080)),
|
|
108
|
+
"token_header": str(th) if th else None,
|
|
109
|
+
"token": str(tok) if tok else None,
|
|
110
|
+
"cert": cert,
|
|
111
|
+
"key": key,
|
|
112
|
+
"ca": ca,
|
|
113
|
+
"check_hostname": check_hostname,
|
|
114
|
+
"timeout": timeout,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_server_config(path: str | Path) -> dict[str, Any]:
|
|
119
|
+
"""Load server config.json into a dict."""
|
|
120
|
+
p = Path(path).expanduser()
|
|
121
|
+
with p.open("r", encoding="utf-8") as f:
|
|
122
|
+
data: Any = json.load(f)
|
|
123
|
+
if not isinstance(data, dict):
|
|
124
|
+
raise ValueError("Server config must be a JSON object")
|
|
125
|
+
# py3.10: return plain dict for mutability
|
|
126
|
+
return dict(data)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Client-side errors (no dependency on code_analysis server package)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClientValidationError(ValueError):
|
|
9
|
+
"""Parameters do not match the command JSON schema (from server ``help``)."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
field: Optional[str] = None,
|
|
16
|
+
details: Optional[Dict[str, Any]] = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.field = field
|
|
20
|
+
self.details = details or {}
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Load per-command JSON schema from the running server via ``help`` (cmdname).
|
|
3
|
+
|
|
4
|
+
Author: Vasiliy Zdanovskiy
|
|
5
|
+
email: vasilyvz@gmail.com
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict
|
|
11
|
+
|
|
12
|
+
from code_analysis_client.exceptions import ClientValidationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_schema_from_help_payload(
|
|
16
|
+
payload: Dict[str, Any], *, command_name: str
|
|
17
|
+
) -> Dict[str, Any]:
|
|
18
|
+
"""Extract ``get_schema()``-style dict from ``help`` JSON-RPC result."""
|
|
19
|
+
if not payload.get("success"):
|
|
20
|
+
raise ClientValidationError(
|
|
21
|
+
f"{command_name}: help returned success=false: {payload!r}",
|
|
22
|
+
field="help",
|
|
23
|
+
details={"payload": payload},
|
|
24
|
+
)
|
|
25
|
+
data = payload.get("data")
|
|
26
|
+
if not isinstance(data, dict):
|
|
27
|
+
raise ClientValidationError(
|
|
28
|
+
f"{command_name}: help response missing object 'data'",
|
|
29
|
+
field="help",
|
|
30
|
+
)
|
|
31
|
+
if data.get("error") and "schema" not in data:
|
|
32
|
+
raise ClientValidationError(
|
|
33
|
+
f"{command_name}: {data.get('error')}",
|
|
34
|
+
field="help",
|
|
35
|
+
details={"note": data.get("note"), "example": data.get("example")},
|
|
36
|
+
)
|
|
37
|
+
schema = data.get("schema")
|
|
38
|
+
if not isinstance(schema, dict):
|
|
39
|
+
raise ClientValidationError(
|
|
40
|
+
f"{command_name}: help response has no 'schema' object",
|
|
41
|
+
field="help",
|
|
42
|
+
)
|
|
43
|
+
return schema
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def fetch_command_schema_from_server(
|
|
47
|
+
rpc: Any,
|
|
48
|
+
command_name: str,
|
|
49
|
+
) -> Dict[str, Any]:
|
|
50
|
+
"""Call server ``help`` with ``cmdname`` and return that command's input schema."""
|
|
51
|
+
raw = await rpc.help(command_name)
|
|
52
|
+
if not isinstance(raw, dict):
|
|
53
|
+
raise ClientValidationError(
|
|
54
|
+
f"{command_name}: help returned non-dict: {type(raw).__name__}",
|
|
55
|
+
field="help",
|
|
56
|
+
)
|
|
57
|
+
return parse_schema_from_help_payload(raw, command_name=command_name)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shallow JSON-schema validation aligned with BaseMCPCommand.validate_params_against_schema.
|
|
3
|
+
|
|
4
|
+
Author: Vasiliy Zdanovskiy
|
|
5
|
+
email: vasilyvz@gmail.com
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict
|
|
11
|
+
|
|
12
|
+
from code_analysis_client.exceptions import ClientValidationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def prepare_params_for_schema(
|
|
16
|
+
params: Dict[str, Any], schema: Dict[str, Any]
|
|
17
|
+
) -> Dict[str, Any]:
|
|
18
|
+
"""Drop unknown keys when ``additionalProperties`` is false (same as server base)."""
|
|
19
|
+
props = schema.get("properties") or {}
|
|
20
|
+
if not schema.get("additionalProperties", True):
|
|
21
|
+
return {k: v for k, v in params.items() if k in props}
|
|
22
|
+
return dict(params)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_params_against_schema(
|
|
26
|
+
params: Dict[str, Any],
|
|
27
|
+
schema: Dict[str, Any],
|
|
28
|
+
command_name: str = "command",
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Validate params against a command ``get_schema()``-style object (subset)."""
|
|
31
|
+
if not isinstance(params, dict):
|
|
32
|
+
raise ClientValidationError(
|
|
33
|
+
f"{command_name}: params must be a dict, got {type(params).__name__}",
|
|
34
|
+
field="params",
|
|
35
|
+
)
|
|
36
|
+
props = schema.get("properties") or {}
|
|
37
|
+
additional_ok = schema.get("additionalProperties", True)
|
|
38
|
+
required_set = set(schema.get("required") or [])
|
|
39
|
+
for key, value in params.items():
|
|
40
|
+
if key not in props:
|
|
41
|
+
if not additional_ok:
|
|
42
|
+
raise ClientValidationError(
|
|
43
|
+
f"{command_name}: unknown parameter {key!r}. "
|
|
44
|
+
"Only schema-defined properties are allowed.",
|
|
45
|
+
field=key,
|
|
46
|
+
details={"allowed": list(props.keys())},
|
|
47
|
+
)
|
|
48
|
+
continue
|
|
49
|
+
if value is None:
|
|
50
|
+
continue
|
|
51
|
+
prop = props[key]
|
|
52
|
+
expected_type = prop.get("type")
|
|
53
|
+
if expected_type == "string":
|
|
54
|
+
if not isinstance(value, str):
|
|
55
|
+
raise ClientValidationError(
|
|
56
|
+
f"{command_name}: parameter {key!r} must be string, got {type(value).__name__}",
|
|
57
|
+
field=key,
|
|
58
|
+
)
|
|
59
|
+
elif expected_type == "integer":
|
|
60
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
61
|
+
raise ClientValidationError(
|
|
62
|
+
f"{command_name}: parameter {key!r} must be integer, got {type(value).__name__}",
|
|
63
|
+
field=key,
|
|
64
|
+
)
|
|
65
|
+
elif expected_type == "number":
|
|
66
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
67
|
+
raise ClientValidationError(
|
|
68
|
+
f"{command_name}: parameter {key!r} must be number, got {type(value).__name__}",
|
|
69
|
+
field=key,
|
|
70
|
+
)
|
|
71
|
+
elif expected_type == "boolean":
|
|
72
|
+
if not isinstance(value, bool):
|
|
73
|
+
raise ClientValidationError(
|
|
74
|
+
f"{command_name}: parameter {key!r} must be boolean, got {type(value).__name__}",
|
|
75
|
+
field=key,
|
|
76
|
+
)
|
|
77
|
+
elif expected_type == "array":
|
|
78
|
+
if not isinstance(value, list):
|
|
79
|
+
raise ClientValidationError(
|
|
80
|
+
f"{command_name}: parameter {key!r} must be array, got {type(value).__name__}",
|
|
81
|
+
field=key,
|
|
82
|
+
)
|
|
83
|
+
elif expected_type == "object":
|
|
84
|
+
if not isinstance(value, dict):
|
|
85
|
+
raise ClientValidationError(
|
|
86
|
+
f"{command_name}: parameter {key!r} must be object, got {type(value).__name__}",
|
|
87
|
+
field=key,
|
|
88
|
+
)
|
|
89
|
+
if "enum" in prop and value is not None:
|
|
90
|
+
if value not in prop["enum"]:
|
|
91
|
+
raise ClientValidationError(
|
|
92
|
+
f"{command_name}: parameter {key!r} must be one of {prop['enum']!r}, got {value!r}",
|
|
93
|
+
field=key,
|
|
94
|
+
details={"enum": prop["enum"]},
|
|
95
|
+
)
|
|
96
|
+
for key in required_set:
|
|
97
|
+
if key not in params or params[key] is None:
|
|
98
|
+
raise ClientValidationError(
|
|
99
|
+
f"{command_name}: required parameter {key!r} is missing",
|
|
100
|
+
field=key,
|
|
101
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.0
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: code-analysis-client
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Async JSON-RPC client for the code-analysis MCP server (mcp-proxy-adapter JsonRpcClient)
|
|
5
|
+
Author-email: Vasiliy Zdanovskiy <vasilyvz@gmail.com>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: mcp-proxy-adapter>=8.10.12
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.25.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# code-analysis-client
|
|
14
|
+
|
|
15
|
+
Async Python client for the **code-analysis** server. It wraps `mcp-proxy-adapter`’s `JsonRpcClient`, so you get the adapter’s built-in methods (queue, transfer, `help`, `health`, …) plus thin helpers to run any registered server command.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install code-analysis-client
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import asyncio
|
|
27
|
+
from code_analysis_client import CodeAnalysisAsyncClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def main() -> None:
|
|
31
|
+
client = CodeAnalysisAsyncClient(
|
|
32
|
+
protocol="https",
|
|
33
|
+
host="127.0.0.1",
|
|
34
|
+
port=15001,
|
|
35
|
+
cert="/path/client.crt",
|
|
36
|
+
key="/path/client.key",
|
|
37
|
+
ca="/path/ca.crt",
|
|
38
|
+
timeout=120.0,
|
|
39
|
+
)
|
|
40
|
+
async with client:
|
|
41
|
+
h = await client.rpc.help()
|
|
42
|
+
r = await client.call("list_projects", {"include_deleted": False})
|
|
43
|
+
print(h, r)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Build client settings from the same JSON shape as the pipeline adapter settings (`host`, `port`, `protocol`, optional `ssl` with `cert` / `key` / `ca` or `*_path` aliases), or from a full server `config.json` object.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from code_analysis_client import CodeAnalysisAsyncClient
|
|
53
|
+
|
|
54
|
+
client = CodeAnalysisAsyncClient.from_server_config(config_dict, timeout=60.0)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Queued/long commands: use `client.call_unified(..., expect_queue=True, auto_poll=True)` or the underlying `client.rpc.execute_command_unified(...)`.
|
|
58
|
+
|
|
59
|
+
## Validation using the server schema
|
|
60
|
+
|
|
61
|
+
The authoritative input schema is whatever the running server returns from **`help`** with `cmdname` set to the command. The client calls that, optionally caches the result, performs the same shallow checks as the server’s `BaseMCPCommand` (types, `required`, `enum`, `additionalProperties`), then runs the command.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
async with CodeAnalysisAsyncClient(host="127.0.0.1", port=15001) as client:
|
|
65
|
+
# Explicit
|
|
66
|
+
out = await client.call_validated(
|
|
67
|
+
"list_projects",
|
|
68
|
+
{"include_deleted": False},
|
|
69
|
+
)
|
|
70
|
+
# Dynamic wrapper: same as call_validated("list_projects", {...})
|
|
71
|
+
out = await client.commands.list_projects(include_deleted=False)
|
|
72
|
+
# After server reload
|
|
73
|
+
client.clear_command_schema_cache()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Use `call_unified_validated` when you need queue polling. Pass `refresh_schema=True` on a single call to bypass the in-memory schema cache.
|
|
77
|
+
|
|
78
|
+
## Examples (this repository)
|
|
79
|
+
|
|
80
|
+
Runnable scripts live under `client/examples/`. **Long-form “man page” style
|
|
81
|
+
documentation** is embedded in the **module docstrings** of those Python files
|
|
82
|
+
(see `client/examples/README.md` for how to read them). Full API walkthrough:
|
|
83
|
+
`python client/examples/run_all_examples.py` with the daemon up and
|
|
84
|
+
`CODE_ANALYSIS_CONFIG` or default `config.json` at the repo root.
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
From the repository root:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install -e ./client
|
|
92
|
+
pytest tests/test_code_analysis_client.py
|
|
93
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
code_analysis_client/__init__.py,sha256=E2j3dr9FzUrPsMm5cp6_EQ4UKPh3vQ2ycd7HSO9jjkg,1517
|
|
2
|
+
code_analysis_client/client.py,sha256=TVOIuV3ttQ8VnsE8xqLA-kA_IbZO3rvc-2QSkBUZVBg,8025
|
|
3
|
+
code_analysis_client/commands_proxy.py,sha256=VbmM-01tvt8b5NDgk5JWd6IVCOxgyDkpehUXl2BvL1o,1969
|
|
4
|
+
code_analysis_client/config.py,sha256=WTV1eAMA4aD8i5b7Ss6piYBqKwtGSyrDcdP-TgFG9TY,4514
|
|
5
|
+
code_analysis_client/exceptions.py,sha256=if-sO2z_qGDeqbvNUnFiXavW0VOzh571zQRTavjpwEo,541
|
|
6
|
+
code_analysis_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
code_analysis_client/server_schema.py,sha256=6auc4WQFiA_2-XQDNVX-8edPtkYCZP4IOzVlePMfYj0,1862
|
|
8
|
+
code_analysis_client/validation.py,sha256=TGgVIwKJ_-5cXnC3fxkP_knokVlrrlL2dlIPxe7tq3U,4079
|
|
9
|
+
code_analysis_client/version.txt,sha256=WYVJhIUxBN9cNT4vaBoV_HkkdC-aLkaMKa8kjc5FzgM,6
|
|
10
|
+
code_analysis_client-1.0.0.dist-info/METADATA,sha256=Vsz_7OGB7ooQgfDN-okbFIQkeFb5SVTbNuCk6pVb-0k,3257
|
|
11
|
+
code_analysis_client-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
code_analysis_client-1.0.0.dist-info/top_level.txt,sha256=NXzwlTVdqyKm43HrnRqBc-DlAeSVkHifJihWZ9JZ2xs,21
|
|
13
|
+
code_analysis_client-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
code_analysis_client
|