pi-codemcp 0.1.0
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.
- package/.python-version +1 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/extensions/index.ts +188 -0
- package/package.json +91 -0
- package/sidecar/__init__.py +1 -0
- package/sidecar/catalog_cache.py +89 -0
- package/sidecar/chains.py +316 -0
- package/sidecar/executor.py +591 -0
- package/sidecar/gateway.py +893 -0
- package/sidecar/json_types.py +11 -0
- package/sidecar/mcp_config.py +278 -0
- package/sidecar/models.py +92 -0
- package/sidecar/pyproject.toml +144 -0
- package/sidecar/settings.py +59 -0
- package/sidecar/tool_catalog.py +838 -0
- package/sidecar/uv.lock +1775 -0
- package/src/chains.ts +452 -0
- package/src/config.ts +58 -0
- package/src/errors.ts +9 -0
- package/src/execution-rendering.ts +183 -0
- package/src/json-file.ts +54 -0
- package/src/lifecycle.ts +59 -0
- package/src/mcp-client.ts +303 -0
- package/src/modal.ts +1233 -0
- package/src/output.ts +52 -0
- package/src/settings.ts +144 -0
- package/src/tools.ts +332 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import TypeAdapter
|
|
4
|
+
|
|
5
|
+
type JsonScalar = bool | int | float | str | None
|
|
6
|
+
type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue]
|
|
7
|
+
type JsonObject = dict[str, JsonValue]
|
|
8
|
+
type JsonSchema = JsonObject | bool
|
|
9
|
+
|
|
10
|
+
JSON_VALUE_ADAPTER: TypeAdapter[JsonValue] = TypeAdapter(JsonValue)
|
|
11
|
+
JSON_OBJECT_ADAPTER: TypeAdapter[JsonObject] = TypeAdapter(JsonObject)
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from fastmcp.client.auth import OAuth
|
|
10
|
+
from fastmcp.mcp_config import (
|
|
11
|
+
MCPConfig,
|
|
12
|
+
RemoteMCPServer,
|
|
13
|
+
StdioMCPServer,
|
|
14
|
+
infer_transport_type_from_url,
|
|
15
|
+
)
|
|
16
|
+
from key_value.aio.stores.filetree import FileTreeStore
|
|
17
|
+
from key_value.aio.stores.filetree.store import (
|
|
18
|
+
FileTreeV1CollectionSanitizationStrategy,
|
|
19
|
+
FileTreeV1KeySanitizationStrategy,
|
|
20
|
+
)
|
|
21
|
+
from pydantic import BaseModel, ConfigDict
|
|
22
|
+
|
|
23
|
+
from .json_types import JSON_VALUE_ADAPTER, JsonObject, JsonValue
|
|
24
|
+
from .models import NormalizedServerInfo, ServerAuth
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
PI_ONLY_FIELDS = {"directTools", "lifecycle", "idleTimeout", "disabled", "enabled"}
|
|
32
|
+
REMOTE_TRANSPORTS = {"http", "streamable-http", "sse"}
|
|
33
|
+
BASE_CHILD_ENV_KEYS = {
|
|
34
|
+
"CI",
|
|
35
|
+
"COLORTERM",
|
|
36
|
+
"FORCE_COLOR",
|
|
37
|
+
"HOME",
|
|
38
|
+
"LANG",
|
|
39
|
+
"LOGNAME",
|
|
40
|
+
"NO_COLOR",
|
|
41
|
+
"PATH",
|
|
42
|
+
"PI_CODING_AGENT_DIR",
|
|
43
|
+
"SHELL",
|
|
44
|
+
"TEMP",
|
|
45
|
+
"TERM",
|
|
46
|
+
"TMP",
|
|
47
|
+
"TMPDIR",
|
|
48
|
+
"USER",
|
|
49
|
+
}
|
|
50
|
+
ENV_ALLOWLIST_KEYS = ("MY_PI_CHILD_ENV_ALLOWLIST", "MY_PI_MCP_ENV_ALLOWLIST")
|
|
51
|
+
ENV_REFERENCE_PATTERN = re.compile(r"\$\{([^}]+)\}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NormalizedConfig(BaseModel):
|
|
55
|
+
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid", strict=True)
|
|
56
|
+
|
|
57
|
+
config: MCPConfig
|
|
58
|
+
servers: list[NormalizedServerInfo]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_mcp_json(path: Path) -> JsonObject:
|
|
62
|
+
if not path.exists():
|
|
63
|
+
raise FileNotFoundError(f"MCP config not found: {path}")
|
|
64
|
+
raw = path.read_text(encoding="utf-8").strip()
|
|
65
|
+
if not raw:
|
|
66
|
+
raise ValueError(f"MCP config is empty: {path}")
|
|
67
|
+
parsed = JSON_VALUE_ADAPTER.validate_json(raw)
|
|
68
|
+
if not isinstance(parsed, dict):
|
|
69
|
+
raise TypeError("mcp.json root must be an object")
|
|
70
|
+
return parsed
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _string_record(
|
|
74
|
+
value: JsonValue | None,
|
|
75
|
+
*,
|
|
76
|
+
label: str,
|
|
77
|
+
server_name: str,
|
|
78
|
+
) -> JsonObject:
|
|
79
|
+
if value is None:
|
|
80
|
+
return {}
|
|
81
|
+
if not isinstance(value, dict):
|
|
82
|
+
raise TypeError(f"MCP server {server_name!r} {label} must be an object")
|
|
83
|
+
result: JsonObject = {}
|
|
84
|
+
for key, entry in value.items():
|
|
85
|
+
if not isinstance(entry, str):
|
|
86
|
+
raise TypeError(f"MCP server {server_name!r} {label}.{key} must be a string")
|
|
87
|
+
result[key] = entry
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _child_process_environment(
|
|
92
|
+
explicit_value: JsonValue | None,
|
|
93
|
+
*,
|
|
94
|
+
server_name: str,
|
|
95
|
+
) -> JsonObject:
|
|
96
|
+
allowed_keys = set(BASE_CHILD_ENV_KEYS)
|
|
97
|
+
allowed_keys.update(key for key in os.environ if key.startswith("LC_"))
|
|
98
|
+
for allowlist_key in ENV_ALLOWLIST_KEYS:
|
|
99
|
+
allowed_keys.update(
|
|
100
|
+
key.strip() for key in os.environ.get(allowlist_key, "").split(",") if key.strip()
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
environment: JsonObject = {key: os.environ[key] for key in allowed_keys if key in os.environ}
|
|
104
|
+
environment.update(_string_record(explicit_value, label="env", server_name=server_name))
|
|
105
|
+
return environment
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _expanded_headers(value: JsonValue, *, server_name: str) -> JsonObject:
|
|
109
|
+
headers = _string_record(value, label="headers", server_name=server_name)
|
|
110
|
+
environment = _child_process_environment(None, server_name=server_name)
|
|
111
|
+
|
|
112
|
+
def replace(match: re.Match[str]) -> str:
|
|
113
|
+
replacement = environment.get(match.group(1))
|
|
114
|
+
return replacement if isinstance(replacement, str) else ""
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
key: ENV_REFERENCE_PATTERN.sub(replace, header)
|
|
118
|
+
for key, header in headers.items()
|
|
119
|
+
if isinstance(header, str)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _required_string(value: JsonValue | None, *, label: str, server_name: str) -> str:
|
|
124
|
+
if not isinstance(value, str) or not value:
|
|
125
|
+
raise TypeError(f"MCP server {server_name!r} {label} must be a non-empty string")
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _disabled_server_info(
|
|
130
|
+
name: str,
|
|
131
|
+
config: JsonObject,
|
|
132
|
+
config_fingerprint: str,
|
|
133
|
+
) -> NormalizedServerInfo:
|
|
134
|
+
if "command" in config:
|
|
135
|
+
_required_string(config.get("command"), label="command", server_name=name)
|
|
136
|
+
return NormalizedServerInfo(
|
|
137
|
+
name=name,
|
|
138
|
+
transport="stdio",
|
|
139
|
+
config_fingerprint=config_fingerprint,
|
|
140
|
+
enabled=False,
|
|
141
|
+
)
|
|
142
|
+
if "url" in config:
|
|
143
|
+
url = _required_string(config.get("url"), label="url", server_name=name)
|
|
144
|
+
transport = config.get("transport") or config.get("type")
|
|
145
|
+
if transport is None:
|
|
146
|
+
transport = infer_transport_type_from_url(url)
|
|
147
|
+
if not isinstance(transport, str) or transport not in REMOTE_TRANSPORTS:
|
|
148
|
+
raise ValueError(f"Unsupported MCP transport for {name}: {transport}")
|
|
149
|
+
raw_auth = config.get("auth")
|
|
150
|
+
auth_kind: ServerAuth | None = (
|
|
151
|
+
"oauth"
|
|
152
|
+
if raw_auth == "oauth"
|
|
153
|
+
else "bearer"
|
|
154
|
+
if isinstance(raw_auth, str) and raw_auth
|
|
155
|
+
else None
|
|
156
|
+
)
|
|
157
|
+
return NormalizedServerInfo(
|
|
158
|
+
name=name,
|
|
159
|
+
transport="sse" if transport == "sse" else "http",
|
|
160
|
+
config_fingerprint=config_fingerprint,
|
|
161
|
+
enabled=False,
|
|
162
|
+
auth=auth_kind,
|
|
163
|
+
)
|
|
164
|
+
raise ValueError(f"MCP server {name!r} must define either command or url")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def normalize_mcp_config(
|
|
168
|
+
raw_config: JsonObject,
|
|
169
|
+
*,
|
|
170
|
+
oauth_storage_dir: Path,
|
|
171
|
+
oauth_client_name: str = "pi-codemcp",
|
|
172
|
+
) -> NormalizedConfig:
|
|
173
|
+
server_block = raw_config.get("mcpServers", raw_config)
|
|
174
|
+
if not isinstance(server_block, dict):
|
|
175
|
+
raise TypeError("mcp.json must contain an object at the root or under mcpServers")
|
|
176
|
+
|
|
177
|
+
oauth_storage_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
oauth_storage = FileTreeStore(
|
|
179
|
+
data_directory=oauth_storage_dir,
|
|
180
|
+
key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(oauth_storage_dir),
|
|
181
|
+
collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(
|
|
182
|
+
oauth_storage_dir
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
normalized_servers: dict[str, StdioMCPServer | RemoteMCPServer] = {}
|
|
186
|
+
server_infos: list[NormalizedServerInfo] = []
|
|
187
|
+
|
|
188
|
+
for name, value in server_block.items():
|
|
189
|
+
if not isinstance(value, dict):
|
|
190
|
+
raise TypeError(f"MCP server {name!r} must be an object")
|
|
191
|
+
cleaned: JsonObject = {
|
|
192
|
+
key: item for key, item in value.items() if key not in PI_ONLY_FIELDS
|
|
193
|
+
}
|
|
194
|
+
config_fingerprint = _server_config_fingerprint(name, cleaned)
|
|
195
|
+
if value.get("disabled") is True or value.get("enabled") is False:
|
|
196
|
+
server_infos.append(_disabled_server_info(name, cleaned, config_fingerprint))
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
if "command" in cleaned:
|
|
200
|
+
cleaned["env"] = _child_process_environment(
|
|
201
|
+
cleaned.get("env"),
|
|
202
|
+
server_name=name,
|
|
203
|
+
)
|
|
204
|
+
stdio_server = StdioMCPServer.model_validate({
|
|
205
|
+
**cleaned,
|
|
206
|
+
"transport": "stdio",
|
|
207
|
+
"type": "stdio",
|
|
208
|
+
})
|
|
209
|
+
normalized_servers[name] = stdio_server
|
|
210
|
+
server_infos.append(
|
|
211
|
+
NormalizedServerInfo(
|
|
212
|
+
name=name,
|
|
213
|
+
transport="stdio",
|
|
214
|
+
config_fingerprint=config_fingerprint,
|
|
215
|
+
description=stdio_server.description,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
if "url" in cleaned:
|
|
221
|
+
url = _required_string(cleaned.get("url"), label="url", server_name=name)
|
|
222
|
+
transport = cleaned.get("transport") or cleaned.get("type")
|
|
223
|
+
if transport is None:
|
|
224
|
+
transport = infer_transport_type_from_url(url)
|
|
225
|
+
if not isinstance(transport, str) or transport not in REMOTE_TRANSPORTS:
|
|
226
|
+
raise ValueError(f"Unsupported MCP transport for {name}: {transport}")
|
|
227
|
+
raw_headers = cleaned.get("headers")
|
|
228
|
+
if raw_headers is not None:
|
|
229
|
+
cleaned["headers"] = _expanded_headers(raw_headers, server_name=name)
|
|
230
|
+
raw_auth = cleaned.get("auth")
|
|
231
|
+
auth: str | httpx.Auth | None
|
|
232
|
+
auth_kind: ServerAuth | None = None
|
|
233
|
+
if raw_auth == "oauth":
|
|
234
|
+
auth = OAuth(
|
|
235
|
+
mcp_url=url,
|
|
236
|
+
client_name=oauth_client_name,
|
|
237
|
+
token_storage=oauth_storage,
|
|
238
|
+
)
|
|
239
|
+
auth_kind = "oauth"
|
|
240
|
+
elif isinstance(raw_auth, str):
|
|
241
|
+
auth = raw_auth or None
|
|
242
|
+
auth_kind = "bearer" if raw_auth else None
|
|
243
|
+
elif raw_auth is None:
|
|
244
|
+
auth = None
|
|
245
|
+
else:
|
|
246
|
+
raise TypeError(f"MCP server {name!r} auth must be a string")
|
|
247
|
+
remote_server = RemoteMCPServer.model_validate({
|
|
248
|
+
**cleaned,
|
|
249
|
+
"transport": transport,
|
|
250
|
+
"auth": auth,
|
|
251
|
+
})
|
|
252
|
+
normalized_servers[name] = remote_server
|
|
253
|
+
server_infos.append(
|
|
254
|
+
NormalizedServerInfo(
|
|
255
|
+
name=name,
|
|
256
|
+
transport="sse" if transport == "sse" else "http",
|
|
257
|
+
config_fingerprint=config_fingerprint,
|
|
258
|
+
auth=auth_kind,
|
|
259
|
+
description=remote_server.description,
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
raise ValueError(f"MCP server {name!r} must define either command or url")
|
|
265
|
+
|
|
266
|
+
return NormalizedConfig(
|
|
267
|
+
config=MCPConfig(mcpServers=normalized_servers),
|
|
268
|
+
servers=server_infos,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _server_config_fingerprint(name: str, config: JsonObject) -> str:
|
|
273
|
+
payload = json.dumps(
|
|
274
|
+
{"name": name, "config": config},
|
|
275
|
+
sort_keys=True,
|
|
276
|
+
separators=(",", ":"),
|
|
277
|
+
)
|
|
278
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, model_serializer
|
|
6
|
+
|
|
7
|
+
type ServerTransport = Literal["stdio", "http", "sse"]
|
|
8
|
+
type ServerAuth = Literal["oauth", "bearer"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NormalizedServerInfo(BaseModel):
|
|
12
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
transport: ServerTransport
|
|
16
|
+
config_fingerprint: str
|
|
17
|
+
enabled: bool = True
|
|
18
|
+
auth: ServerAuth | None = None
|
|
19
|
+
description: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolSchemaView(BaseModel):
|
|
23
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
call: str
|
|
27
|
+
source: Literal["mcp_tool", "saved_chain"]
|
|
28
|
+
server: str | None = None
|
|
29
|
+
description: str | None = None
|
|
30
|
+
signature: str
|
|
31
|
+
stub: str
|
|
32
|
+
|
|
33
|
+
@model_serializer(mode="plain")
|
|
34
|
+
def serialize_compact(self) -> dict[str, object]:
|
|
35
|
+
values: dict[str, object] = {
|
|
36
|
+
"name": self.name,
|
|
37
|
+
"call": self.call,
|
|
38
|
+
"source": self.source,
|
|
39
|
+
"signature": self.signature,
|
|
40
|
+
"stub": self.stub,
|
|
41
|
+
}
|
|
42
|
+
if self.server is not None:
|
|
43
|
+
values["server"] = self.server
|
|
44
|
+
if self.description is not None:
|
|
45
|
+
values["description"] = self.description
|
|
46
|
+
return values
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ServerToolSummary(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
tool_count: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SearchResponse(BaseModel):
|
|
57
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
58
|
+
|
|
59
|
+
total_tool_count: int
|
|
60
|
+
servers: list[ServerToolSummary]
|
|
61
|
+
results: list[ToolSchemaView]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class UpstreamToolStatus(BaseModel):
|
|
65
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
enabled: bool = True
|
|
69
|
+
description: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class UpstreamStatus(BaseModel):
|
|
73
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
transport: ServerTransport
|
|
77
|
+
enabled: bool = True
|
|
78
|
+
connected: bool = False
|
|
79
|
+
discovered: bool = False
|
|
80
|
+
auth: ServerAuth | None = None
|
|
81
|
+
tool_count: int = 0
|
|
82
|
+
total_tool_count: int = 0
|
|
83
|
+
tools: list[UpstreamToolStatus] = Field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class StatusResponse(BaseModel):
|
|
87
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
88
|
+
|
|
89
|
+
connected: bool
|
|
90
|
+
config_path: str
|
|
91
|
+
tool_count: int = 0
|
|
92
|
+
upstreams: list[UpstreamStatus] = Field(default_factory=list)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pi-codemcp-sidecar"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python sidecar for pi-codemcp"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"fastmcp==3.4.4",
|
|
8
|
+
"httpx==0.28.1",
|
|
9
|
+
"pydantic==2.13.4",
|
|
10
|
+
"pydantic-monty==0.0.18",
|
|
11
|
+
"py-key-value-aio[filetree,pydantic]==0.4.5",
|
|
12
|
+
"rapidfuzz==3.14.5",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = [
|
|
17
|
+
"mypy==2.2.0",
|
|
18
|
+
"prek==0.4.8",
|
|
19
|
+
"pytest==8.4.2",
|
|
20
|
+
"pytest-asyncio==1.4.0",
|
|
21
|
+
"ruff==0.15.21",
|
|
22
|
+
"ty==0.0.58",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
asyncio_mode = "auto"
|
|
27
|
+
pythonpath = [".."]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
required-version = "==0.15.21"
|
|
31
|
+
target-version = "py312"
|
|
32
|
+
line-length = 100
|
|
33
|
+
src = ["sidecar"]
|
|
34
|
+
preview = true
|
|
35
|
+
force-exclude = true
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint]
|
|
38
|
+
select = ["ALL"]
|
|
39
|
+
ignore = [
|
|
40
|
+
"COM812", # Conflicts with the formatter.
|
|
41
|
+
"CPY001", # Legal copyright text is not configured.
|
|
42
|
+
"D100", # Internal modules do not need module docstrings.
|
|
43
|
+
"D101", # Pydantic catalog models are self-describing.
|
|
44
|
+
"D102", # Internal methods are covered by types and focused class documentation.
|
|
45
|
+
"D103", # Internal functions are covered by types and focused module documentation.
|
|
46
|
+
"D104", # Internal packages do not need package docstrings beyond sidecar/__init__.py.
|
|
47
|
+
"D105", # Magic methods do not need docstrings.
|
|
48
|
+
"D107", # Constructor contracts are expressed by typed fields.
|
|
49
|
+
"D203", # Incompatible with D211.
|
|
50
|
+
"D213", # Incompatible with D212.
|
|
51
|
+
"DOC201", # Return types are explicit and checked.
|
|
52
|
+
"DOC501", # Repeating raised exception types in docstrings reduces readability.
|
|
53
|
+
"EM101", # Inline exception messages are clearer at the failure site.
|
|
54
|
+
"EM102", # Inline contextual exception messages are clearer at the failure site.
|
|
55
|
+
"FBT001", # JSON Schema itself may be boolean; these are not boolean control arguments.
|
|
56
|
+
"TRY003", # Custom exception wrappers would obscure concise boundary failures.
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint.isort]
|
|
60
|
+
known-first-party = ["sidecar"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint.mccabe]
|
|
63
|
+
# JSON Schema compilation and execution-state handling are branch-heavy by nature.
|
|
64
|
+
max-complexity = 20
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.pylint]
|
|
67
|
+
max-args = 8
|
|
68
|
+
max-bool-expr = 5
|
|
69
|
+
max-branches = 20
|
|
70
|
+
max-locals = 25
|
|
71
|
+
max-nested-blocks = 4
|
|
72
|
+
max-positional-args = 6
|
|
73
|
+
max-public-methods = 10
|
|
74
|
+
max-returns = 16
|
|
75
|
+
max-statements = 50
|
|
76
|
+
max-statements-in-try = 4
|
|
77
|
+
|
|
78
|
+
[tool.ruff.format]
|
|
79
|
+
docstring-code-format = true
|
|
80
|
+
docstring-code-line-length = 100
|
|
81
|
+
|
|
82
|
+
[tool.ruff.lint.per-file-ignores]
|
|
83
|
+
"tests/**/*.py" = [
|
|
84
|
+
"ANN",
|
|
85
|
+
"D",
|
|
86
|
+
"PLR2004",
|
|
87
|
+
"S101",
|
|
88
|
+
"S105",
|
|
89
|
+
"S106",
|
|
90
|
+
"SLF001",
|
|
91
|
+
]
|
|
92
|
+
"tests/fixtures/**/*.py" = ["T201"]
|
|
93
|
+
|
|
94
|
+
[tool.mypy]
|
|
95
|
+
python_version = "3.12"
|
|
96
|
+
plugins = ["pydantic.mypy"]
|
|
97
|
+
strict = true
|
|
98
|
+
files = [
|
|
99
|
+
"sidecar",
|
|
100
|
+
"tests/python/test_executor.py",
|
|
101
|
+
"tests/python/test_settings.py",
|
|
102
|
+
]
|
|
103
|
+
pretty = true
|
|
104
|
+
show_error_codes = true
|
|
105
|
+
show_error_code_links = true
|
|
106
|
+
show_error_context = true
|
|
107
|
+
show_error_end = true
|
|
108
|
+
strict_bytes = true
|
|
109
|
+
strict_equality_for_none = true
|
|
110
|
+
warn_unreachable = true
|
|
111
|
+
warn_unused_configs = true
|
|
112
|
+
enable_error_code = [
|
|
113
|
+
"deprecated",
|
|
114
|
+
"exhaustive-match",
|
|
115
|
+
"explicit-override",
|
|
116
|
+
"ignore-without-code",
|
|
117
|
+
"mutable-override",
|
|
118
|
+
"possibly-undefined",
|
|
119
|
+
"redundant-expr",
|
|
120
|
+
"truthy-bool",
|
|
121
|
+
"truthy-iterable",
|
|
122
|
+
"unimported-reveal",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
[tool.pydantic-mypy]
|
|
126
|
+
init_forbid_extra = true
|
|
127
|
+
init_typed = true
|
|
128
|
+
warn_required_dynamic_aliases = true
|
|
129
|
+
warn_untyped_fields = true
|
|
130
|
+
|
|
131
|
+
[tool.ty.environment]
|
|
132
|
+
python-version = "3.12"
|
|
133
|
+
|
|
134
|
+
[tool.ty.rules]
|
|
135
|
+
all = "error"
|
|
136
|
+
|
|
137
|
+
[tool.ty.analysis]
|
|
138
|
+
respect-type-ignore-comments = false
|
|
139
|
+
|
|
140
|
+
[tool.ty.terminal]
|
|
141
|
+
error-on-warning = true
|
|
142
|
+
|
|
143
|
+
[tool.uv]
|
|
144
|
+
package = false
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from .executor import ExecutionSettings
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _camel_case(name: str) -> str:
|
|
14
|
+
head, *tail = name.split("_")
|
|
15
|
+
alias = head + "".join(part.capitalize() for part in tail)
|
|
16
|
+
return f"{alias[:-3]}KiB" if alias.endswith("Kib") else alias
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CodeMcpSettings(BaseModel):
|
|
20
|
+
model_config = ConfigDict(
|
|
21
|
+
alias_generator=_camel_case,
|
|
22
|
+
populate_by_name=True,
|
|
23
|
+
extra="forbid",
|
|
24
|
+
strict=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
version: Literal[1] = 1
|
|
28
|
+
background_warmup: bool = True
|
|
29
|
+
cache_ttl_hours: int = Field(default=24, ge=0, le=720)
|
|
30
|
+
execution_timeout_seconds: int = Field(default=30, ge=1, le=300)
|
|
31
|
+
tool_timeout_seconds: int = Field(default=30, ge=1, le=300)
|
|
32
|
+
max_calls: int = Field(default=50, ge=1, le=200)
|
|
33
|
+
result_limit_kib: int = Field(default=16, ge=1, le=1024)
|
|
34
|
+
output_limit_kib: int = Field(default=50, ge=1, le=1024)
|
|
35
|
+
output_line_limit: int = Field(default=2000, ge=1, le=10000)
|
|
36
|
+
disabled_tools: dict[str, list[str]] = Field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
def tool_enabled(self, server: str, tool: str) -> bool:
|
|
39
|
+
return tool not in self.disabled_tools.get(server, ())
|
|
40
|
+
|
|
41
|
+
def execution_settings(self) -> ExecutionSettings:
|
|
42
|
+
return ExecutionSettings(
|
|
43
|
+
timeout_seconds=self.execution_timeout_seconds,
|
|
44
|
+
max_calls=self.max_calls,
|
|
45
|
+
tool_timeout_seconds=self.tool_timeout_seconds,
|
|
46
|
+
result_byte_limit=self.result_limit_kib * 1024,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def cache_ttl_seconds(self) -> int:
|
|
51
|
+
return self.cache_ttl_hours * 60 * 60
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_settings(path: Path) -> CodeMcpSettings:
|
|
55
|
+
try:
|
|
56
|
+
raw = path.read_text(encoding="utf-8")
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
return CodeMcpSettings()
|
|
59
|
+
return CodeMcpSettings.model_validate_json(raw)
|