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.
@@ -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)