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,316 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import time
8
+ from contextlib import contextmanager, suppress
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Literal, NamedTuple
11
+ from uuid import uuid4
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
14
+
15
+ from .json_types import JSON_OBJECT_ADAPTER, JsonObject
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Iterator
19
+
20
+ CHAIN_NAME_PATTERN = r"^[a-z][a-z0-9_]{0,63}$"
21
+ CHAIN_STORE_VERSION: Literal[1] = 1
22
+ type ChainScope = Literal["global", "project"]
23
+
24
+
25
+ class ChainDependency(BaseModel):
26
+ model_config = ConfigDict(extra="forbid", strict=True)
27
+
28
+ kind: Literal["mcp_tool", "saved_chain"]
29
+ name: str
30
+ call: str
31
+ server: str
32
+ schema_fingerprint: str
33
+
34
+
35
+ class SavedChainManifest(BaseModel):
36
+ model_config = ConfigDict(extra="forbid", strict=True)
37
+
38
+ version: Literal[1] = CHAIN_STORE_VERSION
39
+ id: str
40
+ name: str = Field(pattern=CHAIN_NAME_PATTERN)
41
+ description: str = Field(min_length=1, max_length=1000)
42
+ code: str = Field(min_length=1)
43
+ input_schema: JsonObject
44
+ output_schema: JsonObject
45
+ enabled: bool = True
46
+ dependencies: list[ChainDependency] = Field(default_factory=list)
47
+ schema_fingerprint: str
48
+ created_at: float
49
+ updated_at: float
50
+ validated_at: float
51
+
52
+ @field_validator("code")
53
+ @classmethod
54
+ def code_must_not_be_blank(cls, value: str) -> str:
55
+ if not value.strip():
56
+ raise ValueError("code must not be blank")
57
+ return value
58
+
59
+ @field_validator("input_schema")
60
+ @classmethod
61
+ def input_schema_must_be_object(cls, value: JsonObject) -> JsonObject:
62
+ if value.get("type") != "object":
63
+ raise ValueError("input_schema.type must be object")
64
+ return value
65
+
66
+ @property
67
+ def public_name(self) -> str:
68
+ return f"chain_{self.name}"
69
+
70
+ @property
71
+ def call(self) -> str:
72
+ return f"chains.{self.name}"
73
+
74
+ @property
75
+ def native_tool(self) -> str:
76
+ return f"mcp_chain_{self.name}"
77
+
78
+
79
+ class ChainEnabledChange(BaseModel):
80
+ model_config = ConfigDict(extra="forbid", strict=True)
81
+
82
+ name: str = Field(pattern=CHAIN_NAME_PATTERN)
83
+ scope: ChainScope
84
+ enabled: bool
85
+
86
+
87
+ class ChainStatusView(BaseModel):
88
+ model_config = ConfigDict(extra="forbid", strict=True)
89
+
90
+ chain: SavedChainManifest
91
+ scope: ChainScope
92
+ status: Literal["ready", "disabled", "stale", "shadowed"]
93
+ stale_dependencies: list[str] = Field(default_factory=list)
94
+ called_by: list[str] = Field(default_factory=list)
95
+
96
+
97
+ class ChainListResponse(BaseModel):
98
+ model_config = ConfigDict(extra="forbid", strict=True)
99
+
100
+ chains: list[ChainStatusView]
101
+
102
+
103
+ class SaveChainResponse(BaseModel):
104
+ model_config = ConfigDict(extra="forbid", strict=True)
105
+
106
+ chain: ChainStatusView
107
+ created: bool
108
+
109
+
110
+ class ChainStore:
111
+ def __init__(self, directory: Path) -> None:
112
+ self.directory = Path(directory)
113
+
114
+ def load_all(self) -> list[SavedChainManifest]:
115
+ if not self.directory.exists():
116
+ return []
117
+ manifests: list[SavedChainManifest] = []
118
+ for path in sorted(self.directory.glob("*.json")):
119
+ try:
120
+ manifests.append(
121
+ SavedChainManifest.model_validate_json(path.read_text(encoding="utf-8"))
122
+ )
123
+ except (OSError, ValueError) as error:
124
+ raise ValueError(f"Invalid saved chain manifest {path}: {error}") from error
125
+ return manifests
126
+
127
+ def enabled(self) -> list[SavedChainManifest]:
128
+ return [chain for chain in self.load_all() if chain.enabled]
129
+
130
+ def contains(self, name: str) -> bool:
131
+ self._validate_name(name)
132
+ return self._path(name).is_file()
133
+
134
+ def get(self, name: str) -> SavedChainManifest:
135
+ self._validate_name(name)
136
+ path = self._path(name)
137
+ try:
138
+ return SavedChainManifest.model_validate_json(path.read_text(encoding="utf-8"))
139
+ except FileNotFoundError as error:
140
+ raise ValueError(f"Unknown saved chain: {name}") from error
141
+ except (OSError, ValueError) as error:
142
+ raise ValueError(f"Invalid saved chain manifest {path}: {error}") from error
143
+
144
+ @staticmethod
145
+ def build(
146
+ *,
147
+ name: str,
148
+ description: str,
149
+ code: str,
150
+ input_schema: JsonObject,
151
+ output_schema: JsonObject,
152
+ dependencies: list[ChainDependency],
153
+ previous: SavedChainManifest | None = None,
154
+ ) -> SavedChainManifest:
155
+ now = time.time()
156
+ validated_input_schema = JSON_OBJECT_ADAPTER.validate_python(input_schema)
157
+ validated_output_schema = JSON_OBJECT_ADAPTER.validate_python(output_schema)
158
+ schema_fingerprint = _fingerprint({
159
+ "input_schema": validated_input_schema,
160
+ "output_schema": validated_output_schema,
161
+ })
162
+ return SavedChainManifest(
163
+ id=previous.id if previous is not None else uuid4().hex,
164
+ name=name,
165
+ description=description,
166
+ code=code,
167
+ input_schema=validated_input_schema,
168
+ output_schema=validated_output_schema,
169
+ enabled=previous.enabled if previous is not None else True,
170
+ dependencies=dependencies,
171
+ schema_fingerprint=schema_fingerprint,
172
+ created_at=previous.created_at if previous is not None else now,
173
+ updated_at=now,
174
+ validated_at=now,
175
+ )
176
+
177
+ def save(self, chain: SavedChainManifest) -> None:
178
+ self.directory.mkdir(parents=True, exist_ok=True)
179
+ with suppress(OSError):
180
+ self.directory.chmod(0o700)
181
+ path = self._path(chain.name)
182
+ temporary = self.directory / f".{chain.name}.{os.getpid()}.{time.time_ns()}.tmp"
183
+ temporary.write_text(
184
+ f"{chain.model_dump_json(indent=2)}\n",
185
+ encoding="utf-8",
186
+ )
187
+ temporary.chmod(0o600)
188
+ temporary.replace(path)
189
+
190
+ def delete(self, name: str) -> None:
191
+ self._validate_name(name)
192
+ try:
193
+ self._path(name).unlink()
194
+ except FileNotFoundError as error:
195
+ raise ValueError(f"Unknown saved chain: {name}") from error
196
+
197
+ def _path(self, name: str) -> Path:
198
+ self._validate_name(name)
199
+ return self.directory / f"{name}.json"
200
+
201
+ @staticmethod
202
+ def _validate_name(name: str) -> None:
203
+ if re.fullmatch(CHAIN_NAME_PATTERN, name) is None:
204
+ raise ValueError(
205
+ "Saved chain name must start with a lowercase letter and contain only "
206
+ "lowercase letters, digits, and underscores (maximum 64 characters)"
207
+ )
208
+
209
+
210
+ class ScopedChain(NamedTuple):
211
+ scope: ChainScope
212
+ chain: SavedChainManifest
213
+
214
+
215
+ class ScopedChainStore:
216
+ def __init__(self, global_directory: Path, project_directory: Path | None) -> None:
217
+ self.global_store = ChainStore(global_directory)
218
+ self.project_store = (
219
+ ChainStore(project_directory) if project_directory is not None else None
220
+ )
221
+
222
+ def load_all(self) -> list[ScopedChain]:
223
+ chains = [
224
+ ScopedChain(scope="global", chain=chain) for chain in self.global_store.load_all()
225
+ ]
226
+ if self.project_store is not None:
227
+ chains.extend(
228
+ ScopedChain(scope="project", chain=chain) for chain in self.project_store.load_all()
229
+ )
230
+ return sorted(
231
+ chains,
232
+ key=lambda item: (item.chain.name, item.scope != "project"),
233
+ )
234
+
235
+ def effective(self) -> list[ScopedChain]:
236
+ effective: dict[str, ScopedChain] = {}
237
+ for item in self.load_all():
238
+ current = effective.get(item.chain.name)
239
+ if current is None or item.scope == "project":
240
+ effective[item.chain.name] = item
241
+ return [effective[name] for name in sorted(effective)]
242
+
243
+ def enabled(self) -> list[SavedChainManifest]:
244
+ return [item.chain for item in self.effective() if item.chain.enabled]
245
+
246
+ def get(self, name: str, scope: ChainScope | None = None) -> ScopedChain:
247
+ if scope is not None:
248
+ return ScopedChain(scope=scope, chain=self._store(scope).get(name))
249
+ if self.project_store is not None and self.project_store.contains(name):
250
+ return ScopedChain(scope="project", chain=self.project_store.get(name))
251
+ return ScopedChain(scope="global", chain=self.global_store.get(name))
252
+
253
+ def contains(self, scope: ChainScope, name: str) -> bool:
254
+ return self._store(scope).contains(name)
255
+
256
+ def save(self, scope: ChainScope, chain: SavedChainManifest) -> None:
257
+ self._store(scope).save(chain)
258
+
259
+ @contextmanager
260
+ def enabled_transaction(self, changes: list[ChainEnabledChange]) -> Iterator[None]:
261
+ keys = [(change.scope, change.name) for change in changes]
262
+ if len(keys) != len(set(keys)):
263
+ raise ValueError("Duplicate scoped saved-chain enable change")
264
+ previous = [self.get(change.name, change.scope) for change in changes]
265
+ applied: list[ScopedChain] = []
266
+ try:
267
+ self._apply_enabled_changes(changes, previous, applied)
268
+ yield
269
+ except BaseException:
270
+ self._restore(applied)
271
+ raise
272
+
273
+ def _apply_enabled_changes(
274
+ self,
275
+ changes: list[ChainEnabledChange],
276
+ previous: list[ScopedChain],
277
+ applied: list[ScopedChain],
278
+ ) -> None:
279
+ for change, current in zip(changes, previous, strict=True):
280
+ if current.chain.enabled == change.enabled:
281
+ continue
282
+ updated = current.chain.model_copy(
283
+ update={"enabled": change.enabled, "updated_at": time.time()}
284
+ )
285
+ self.save(change.scope, updated)
286
+ applied.append(current)
287
+
288
+ def _restore(self, chains: list[ScopedChain]) -> None:
289
+ for item in reversed(chains):
290
+ self.save(item.scope, item.chain)
291
+
292
+ def delete(self, scope: ChainScope, name: str) -> None:
293
+ self._store(scope).delete(name)
294
+
295
+ def is_shadowed(self, item: ScopedChain) -> bool:
296
+ return (
297
+ item.scope == "global"
298
+ and self.project_store is not None
299
+ and self.project_store.contains(item.chain.name)
300
+ )
301
+
302
+ def _store(self, scope: ChainScope) -> ChainStore:
303
+ if scope == "global":
304
+ return self.global_store
305
+ if self.project_store is None:
306
+ raise ValueError("Project saved-chain scope is unavailable for this session")
307
+ return self.project_store
308
+
309
+
310
+ def schema_fingerprint(input_schema: JsonObject, output_schema: JsonObject) -> str:
311
+ return _fingerprint({"input_schema": input_schema, "output_schema": output_schema})
312
+
313
+
314
+ def _fingerprint(value: JsonObject) -> str:
315
+ encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
316
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()