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,893 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import textwrap
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Literal, NamedTuple, Protocol
|
|
11
|
+
|
|
12
|
+
import pydantic_monty
|
|
13
|
+
from fastmcp import Client, FastMCP
|
|
14
|
+
from fastmcp.mcp_config import RemoteMCPServer, StdioMCPServer
|
|
15
|
+
from pydantic import BaseModel, ConfigDict
|
|
16
|
+
|
|
17
|
+
from . import json_types
|
|
18
|
+
from .catalog_cache import CatalogCache
|
|
19
|
+
from .chains import (
|
|
20
|
+
ChainDependency,
|
|
21
|
+
ChainEnabledChange,
|
|
22
|
+
ChainListResponse,
|
|
23
|
+
ChainScope,
|
|
24
|
+
ChainStatusView,
|
|
25
|
+
ChainStore,
|
|
26
|
+
SaveChainResponse,
|
|
27
|
+
SavedChainManifest,
|
|
28
|
+
ScopedChainStore,
|
|
29
|
+
)
|
|
30
|
+
from .executor import ExecutionContext, ExecutionResponse, MontyExecutor
|
|
31
|
+
from .mcp_config import NormalizedConfig, load_mcp_json, normalize_mcp_config
|
|
32
|
+
from .models import (
|
|
33
|
+
NormalizedServerInfo,
|
|
34
|
+
SearchResponse,
|
|
35
|
+
ServerToolSummary,
|
|
36
|
+
StatusResponse,
|
|
37
|
+
UpstreamStatus,
|
|
38
|
+
UpstreamToolStatus,
|
|
39
|
+
)
|
|
40
|
+
from .settings import CodeMcpSettings, load_settings
|
|
41
|
+
from .tool_catalog import ToolCatalog
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
|
|
45
|
+
|
|
46
|
+
from fastmcp.client.transports import ClientTransport
|
|
47
|
+
from mcp import types as mcp_types
|
|
48
|
+
|
|
49
|
+
DEFAULT_AGENT_DIR = Path.home() / ".pi" / "agent"
|
|
50
|
+
CODEMCP_AGENT_DIR_ENV = "PI_CODEMCP_AGENT_DIR"
|
|
51
|
+
CODEMCP_PROJECT_CHAINS_DIR_ENV = "PI_CODEMCP_PROJECT_CHAINS_DIR"
|
|
52
|
+
PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"
|
|
53
|
+
type ServerConfig = StdioMCPServer | RemoteMCPServer
|
|
54
|
+
type JsonObject = json_types.JsonObject
|
|
55
|
+
type JsonValue = json_types.JsonValue
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ManagerApplyResponse(BaseModel):
|
|
59
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
60
|
+
|
|
61
|
+
status: StatusResponse
|
|
62
|
+
chains: list[ChainStatusView]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ServerHandle:
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
info: NormalizedServerInfo,
|
|
69
|
+
server_config: ServerConfig,
|
|
70
|
+
cache: CatalogCache,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.info = info
|
|
73
|
+
self.server_config = server_config
|
|
74
|
+
self.cache = cache
|
|
75
|
+
self._tools: list[mcp_types.Tool] | None = None
|
|
76
|
+
self._client: Client[ClientTransport] | None = None
|
|
77
|
+
self._exit_stack: AsyncExitStack | None = None
|
|
78
|
+
self._lock = asyncio.Lock()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def tools(self) -> list[mcp_types.Tool] | None:
|
|
82
|
+
return self._tools
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def client(self) -> Client[ClientTransport] | None:
|
|
86
|
+
return self._client
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def create(
|
|
90
|
+
cls,
|
|
91
|
+
info: NormalizedServerInfo,
|
|
92
|
+
server_config: ServerConfig,
|
|
93
|
+
cache: CatalogCache,
|
|
94
|
+
) -> ServerHandle:
|
|
95
|
+
handle = cls(
|
|
96
|
+
info=info,
|
|
97
|
+
server_config=server_config,
|
|
98
|
+
cache=cache,
|
|
99
|
+
)
|
|
100
|
+
handle._tools = cache.load(info.name, info.config_fingerprint)
|
|
101
|
+
return handle
|
|
102
|
+
|
|
103
|
+
async def discover(self, *, force: bool = False) -> list[mcp_types.Tool]:
|
|
104
|
+
async with self._lock:
|
|
105
|
+
if self._tools is not None and not force:
|
|
106
|
+
return self._tools
|
|
107
|
+
was_connected = self._client is not None
|
|
108
|
+
client = await self._connect_locked()
|
|
109
|
+
try:
|
|
110
|
+
tools = await client.list_tools()
|
|
111
|
+
self._tools = tools
|
|
112
|
+
await asyncio.to_thread(
|
|
113
|
+
self.cache.save,
|
|
114
|
+
self.info.name,
|
|
115
|
+
self.info.config_fingerprint,
|
|
116
|
+
tools,
|
|
117
|
+
)
|
|
118
|
+
return tools
|
|
119
|
+
finally:
|
|
120
|
+
if not was_connected:
|
|
121
|
+
await self._disconnect_locked()
|
|
122
|
+
|
|
123
|
+
async def call_tool(
|
|
124
|
+
self,
|
|
125
|
+
name: str,
|
|
126
|
+
arguments: JsonObject,
|
|
127
|
+
*,
|
|
128
|
+
timeout_seconds: float,
|
|
129
|
+
) -> mcp_types.CallToolResult:
|
|
130
|
+
async with self._lock:
|
|
131
|
+
client = await self._connect_locked()
|
|
132
|
+
return await client.call_tool_mcp(name, arguments, timeout=timeout_seconds)
|
|
133
|
+
|
|
134
|
+
async def close(self) -> None:
|
|
135
|
+
async with self._lock:
|
|
136
|
+
await self._disconnect_locked()
|
|
137
|
+
|
|
138
|
+
async def _connect_locked(self) -> Client[ClientTransport]:
|
|
139
|
+
if self._client is not None:
|
|
140
|
+
return self._client
|
|
141
|
+
exit_stack = AsyncExitStack()
|
|
142
|
+
try:
|
|
143
|
+
client = await exit_stack.enter_async_context(
|
|
144
|
+
Client(
|
|
145
|
+
self.server_config.to_transport(),
|
|
146
|
+
name=f"pi-codemcp-{self.info.name}",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
except BaseException:
|
|
150
|
+
await exit_stack.aclose()
|
|
151
|
+
raise
|
|
152
|
+
self._exit_stack = exit_stack
|
|
153
|
+
self._client = client
|
|
154
|
+
return client
|
|
155
|
+
|
|
156
|
+
async def _disconnect_locked(self) -> None:
|
|
157
|
+
exit_stack, self._exit_stack = self._exit_stack, None
|
|
158
|
+
self._client = None
|
|
159
|
+
if exit_stack is not None:
|
|
160
|
+
await exit_stack.aclose()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class SaveChainHandler(Protocol):
|
|
164
|
+
async def __call__(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
scope: ChainScope,
|
|
168
|
+
name: str,
|
|
169
|
+
description: str,
|
|
170
|
+
code: str,
|
|
171
|
+
input_schema: JsonObject,
|
|
172
|
+
output_schema: JsonObject,
|
|
173
|
+
) -> SaveChainResponse: ...
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class SavedChainHandlers(NamedTuple):
|
|
177
|
+
execute: Callable[[str, JsonObject], Awaitable[ExecutionResponse]]
|
|
178
|
+
save: SaveChainHandler
|
|
179
|
+
list: Callable[[], ChainListResponse]
|
|
180
|
+
revalidate: Callable[[str, ChainScope], Awaitable[ChainStatusView]]
|
|
181
|
+
delete: Callable[[str, ChainScope], Awaitable[ChainListResponse]]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class SavedChainRuntime:
|
|
185
|
+
def __init__(self, handlers: SavedChainHandlers) -> None:
|
|
186
|
+
self.handlers = handlers
|
|
187
|
+
|
|
188
|
+
async def execute(self, name: str, arguments: JsonObject) -> ExecutionResponse:
|
|
189
|
+
return await self.handlers.execute(name, arguments)
|
|
190
|
+
|
|
191
|
+
async def save(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
scope: ChainScope,
|
|
195
|
+
name: str,
|
|
196
|
+
description: str,
|
|
197
|
+
code: str,
|
|
198
|
+
input_schema: JsonObject,
|
|
199
|
+
output_schema: JsonObject,
|
|
200
|
+
) -> SaveChainResponse:
|
|
201
|
+
return await self.handlers.save(
|
|
202
|
+
scope=scope,
|
|
203
|
+
name=name,
|
|
204
|
+
description=description,
|
|
205
|
+
code=code,
|
|
206
|
+
input_schema=input_schema,
|
|
207
|
+
output_schema=output_schema,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def list(self) -> ChainListResponse:
|
|
211
|
+
return self.handlers.list()
|
|
212
|
+
|
|
213
|
+
async def revalidate(self, name: str, scope: ChainScope) -> ChainStatusView:
|
|
214
|
+
return await self.handlers.revalidate(name, scope)
|
|
215
|
+
|
|
216
|
+
async def delete(self, name: str, scope: ChainScope) -> ChainListResponse:
|
|
217
|
+
return await self.handlers.delete(name, scope)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class GatewayRuntime:
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
*,
|
|
224
|
+
config_path: Path,
|
|
225
|
+
settings_path: Path,
|
|
226
|
+
settings: CodeMcpSettings,
|
|
227
|
+
normalized: NormalizedConfig,
|
|
228
|
+
handles: dict[str, ServerHandle],
|
|
229
|
+
chain_store: ScopedChainStore,
|
|
230
|
+
catalog: ToolCatalog,
|
|
231
|
+
executor: MontyExecutor,
|
|
232
|
+
) -> None:
|
|
233
|
+
self.config_path = config_path
|
|
234
|
+
self.settings_path = settings_path
|
|
235
|
+
self.settings = settings
|
|
236
|
+
self.normalized = normalized
|
|
237
|
+
self.handles = handles
|
|
238
|
+
self.chain_store = chain_store
|
|
239
|
+
self.catalog = catalog
|
|
240
|
+
self.executor = executor
|
|
241
|
+
self.chains = SavedChainRuntime(
|
|
242
|
+
SavedChainHandlers(
|
|
243
|
+
execute=self._execute_chain,
|
|
244
|
+
save=self._save_chain,
|
|
245
|
+
list=self._list_chains,
|
|
246
|
+
revalidate=self._revalidate_chain,
|
|
247
|
+
delete=self._delete_chain,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
self._catalog_lock = asyncio.Lock()
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
def create(
|
|
254
|
+
cls,
|
|
255
|
+
config_path: Path,
|
|
256
|
+
oauth_storage_dir: Path,
|
|
257
|
+
catalog_cache_dir: Path,
|
|
258
|
+
settings_path: Path | None = None,
|
|
259
|
+
global_chain_dir: Path | None = None,
|
|
260
|
+
project_chain_dir: Path | None = None,
|
|
261
|
+
) -> GatewayRuntime:
|
|
262
|
+
resolved_settings_path = settings_path or catalog_cache_dir.parent / "settings.json"
|
|
263
|
+
settings = load_settings(resolved_settings_path)
|
|
264
|
+
raw = load_mcp_json(config_path)
|
|
265
|
+
normalized = normalize_mcp_config(
|
|
266
|
+
raw,
|
|
267
|
+
oauth_storage_dir=oauth_storage_dir,
|
|
268
|
+
)
|
|
269
|
+
cache = CatalogCache(
|
|
270
|
+
catalog_cache_dir,
|
|
271
|
+
max_age_seconds=settings.cache_ttl_seconds,
|
|
272
|
+
)
|
|
273
|
+
info_by_name = {server.name: server for server in normalized.servers}
|
|
274
|
+
handles = {
|
|
275
|
+
name: ServerHandle.create(info_by_name[name], server_config, cache)
|
|
276
|
+
for name, server_config in normalized.config.mcpServers.items()
|
|
277
|
+
}
|
|
278
|
+
chain_store = ScopedChainStore(
|
|
279
|
+
global_chain_dir or catalog_cache_dir.parent / "chains",
|
|
280
|
+
project_chain_dir,
|
|
281
|
+
)
|
|
282
|
+
catalog = ToolCatalog.from_server_tools(
|
|
283
|
+
{
|
|
284
|
+
name: [
|
|
285
|
+
tool for tool in handle.tools or [] if settings.tool_enabled(name, tool.name)
|
|
286
|
+
]
|
|
287
|
+
for name, handle in handles.items()
|
|
288
|
+
},
|
|
289
|
+
handles.keys(),
|
|
290
|
+
chain_store.enabled(),
|
|
291
|
+
)
|
|
292
|
+
return cls(
|
|
293
|
+
config_path=config_path,
|
|
294
|
+
settings_path=resolved_settings_path,
|
|
295
|
+
settings=settings,
|
|
296
|
+
normalized=normalized,
|
|
297
|
+
handles=handles,
|
|
298
|
+
chain_store=chain_store,
|
|
299
|
+
catalog=catalog,
|
|
300
|
+
executor=MontyExecutor(catalog, settings=settings.execution_settings()),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def close(self) -> None:
|
|
304
|
+
await asyncio.gather(
|
|
305
|
+
*(handle.close() for handle in self.handles.values()),
|
|
306
|
+
return_exceptions=True,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
async def search(
|
|
310
|
+
self,
|
|
311
|
+
query: str,
|
|
312
|
+
limit: int = 5,
|
|
313
|
+
server: str | None = None,
|
|
314
|
+
) -> SearchResponse:
|
|
315
|
+
clean_query = query.strip()
|
|
316
|
+
if not clean_query:
|
|
317
|
+
raise ValueError("query must not be empty")
|
|
318
|
+
await self._ensure_catalog_complete()
|
|
319
|
+
bounded_limit = min(max(limit, 1), 20)
|
|
320
|
+
counts = self.catalog.counts_by_server()
|
|
321
|
+
servers = [
|
|
322
|
+
ServerToolSummary(name=server.name, tool_count=counts[server.name])
|
|
323
|
+
for server in self.normalized.servers
|
|
324
|
+
if counts.get(server.name, 0) > 0
|
|
325
|
+
]
|
|
326
|
+
if counts.get("chains", 0) > 0:
|
|
327
|
+
servers.append(ServerToolSummary(name="chains", tool_count=counts["chains"]))
|
|
328
|
+
return SearchResponse(
|
|
329
|
+
total_tool_count=len(self.catalog.tools),
|
|
330
|
+
servers=servers,
|
|
331
|
+
results=self.catalog.search(clean_query, bounded_limit, server=server),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
async def execute(self, code: str) -> ExecutionResponse:
|
|
335
|
+
await self._ensure_servers_discovered(self._required_servers_for_code(code))
|
|
336
|
+
self.executor.update_catalog(self.catalog)
|
|
337
|
+
return await self.executor.execute_graph(code, self._dispatch)
|
|
338
|
+
|
|
339
|
+
async def _execute_chain(self, name: str, arguments: JsonObject) -> ExecutionResponse:
|
|
340
|
+
chain = self.chain_store.get(name).chain
|
|
341
|
+
if not chain.enabled:
|
|
342
|
+
return ExecutionResponse(
|
|
343
|
+
ok=False,
|
|
344
|
+
failure_stage="preflight",
|
|
345
|
+
error=f"Saved chain is disabled: {name}",
|
|
346
|
+
)
|
|
347
|
+
await self._ensure_servers_discovered(self._required_servers_for_chain(chain))
|
|
348
|
+
self.executor.update_catalog(self.catalog)
|
|
349
|
+
return await self.executor.execute_saved_chain(chain, arguments, self._dispatch)
|
|
350
|
+
|
|
351
|
+
async def _save_chain(
|
|
352
|
+
self,
|
|
353
|
+
*,
|
|
354
|
+
scope: ChainScope,
|
|
355
|
+
name: str,
|
|
356
|
+
description: str,
|
|
357
|
+
code: str,
|
|
358
|
+
input_schema: JsonObject,
|
|
359
|
+
output_schema: JsonObject,
|
|
360
|
+
) -> SaveChainResponse:
|
|
361
|
+
previous = (
|
|
362
|
+
self.chain_store.get(name, scope).chain
|
|
363
|
+
if self.chain_store.contains(scope, name)
|
|
364
|
+
else None
|
|
365
|
+
)
|
|
366
|
+
candidate = ChainStore.build(
|
|
367
|
+
name=name,
|
|
368
|
+
description=description,
|
|
369
|
+
code=code,
|
|
370
|
+
input_schema=input_schema,
|
|
371
|
+
output_schema=output_schema,
|
|
372
|
+
dependencies=[],
|
|
373
|
+
previous=previous,
|
|
374
|
+
).model_copy(update={"enabled": True})
|
|
375
|
+
await self._ensure_servers_discovered(self._referenced_servers(code))
|
|
376
|
+
chains = [chain for chain in self.chain_store.enabled() if chain.name != name]
|
|
377
|
+
chains.append(candidate)
|
|
378
|
+
candidate_catalog = self._build_catalog(chains)
|
|
379
|
+
spec = candidate_catalog.tools[candidate.public_name]
|
|
380
|
+
try:
|
|
381
|
+
await self.executor.validate_saved_chain(code, candidate_catalog, spec)
|
|
382
|
+
except (pydantic_monty.MontyTypingError, pydantic_monty.MontySyntaxError) as error:
|
|
383
|
+
if isinstance(error, pydantic_monty.MontyTypingError):
|
|
384
|
+
message = error.display("concise", color=False).strip()
|
|
385
|
+
else:
|
|
386
|
+
message = error.display("type-msg").strip()
|
|
387
|
+
raise ValueError(f"Saved chain failed preflight: {message}") from error
|
|
388
|
+
dependencies = self._chain_dependencies(code, candidate_catalog)
|
|
389
|
+
saved = ChainStore.build(
|
|
390
|
+
name=name,
|
|
391
|
+
description=description,
|
|
392
|
+
code=code,
|
|
393
|
+
input_schema=input_schema,
|
|
394
|
+
output_schema=output_schema,
|
|
395
|
+
dependencies=dependencies,
|
|
396
|
+
previous=previous,
|
|
397
|
+
).model_copy(update={"enabled": True})
|
|
398
|
+
self.chain_store.save(scope, saved)
|
|
399
|
+
await self._rebuild_catalog()
|
|
400
|
+
return SaveChainResponse(
|
|
401
|
+
chain=self._chain_view(saved.name, scope),
|
|
402
|
+
created=previous is None,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def _list_chains(self) -> ChainListResponse:
|
|
406
|
+
return ChainListResponse(chains=self._chain_views())
|
|
407
|
+
|
|
408
|
+
async def _revalidate_chain(self, name: str, scope: ChainScope) -> ChainStatusView:
|
|
409
|
+
current = self.chain_store.get(name, scope).chain
|
|
410
|
+
await self._ensure_servers_discovered(self._referenced_servers(current.code))
|
|
411
|
+
chains = [chain for chain in self.chain_store.enabled() if chain.name != name]
|
|
412
|
+
chains.append(current)
|
|
413
|
+
candidate_catalog = self._build_catalog(chains)
|
|
414
|
+
spec = candidate_catalog.tools[current.public_name]
|
|
415
|
+
try:
|
|
416
|
+
await self.executor.validate_saved_chain(current.code, candidate_catalog, spec)
|
|
417
|
+
except (pydantic_monty.MontyTypingError, pydantic_monty.MontySyntaxError) as error:
|
|
418
|
+
if isinstance(error, pydantic_monty.MontyTypingError):
|
|
419
|
+
message = error.display("concise", color=False).strip()
|
|
420
|
+
else:
|
|
421
|
+
message = error.display("type-msg").strip()
|
|
422
|
+
raise ValueError(f"Saved chain failed preflight: {message}") from error
|
|
423
|
+
updated = current.model_copy(
|
|
424
|
+
update={
|
|
425
|
+
"dependencies": self._chain_dependencies(current.code, candidate_catalog),
|
|
426
|
+
"validated_at": time.time(),
|
|
427
|
+
}
|
|
428
|
+
)
|
|
429
|
+
self.chain_store.save(scope, updated)
|
|
430
|
+
await self._rebuild_catalog()
|
|
431
|
+
return self._chain_view(name, scope)
|
|
432
|
+
|
|
433
|
+
async def _delete_chain(self, name: str, scope: ChainScope) -> ChainListResponse:
|
|
434
|
+
effective = self.chain_store.get(name)
|
|
435
|
+
called_by = self._called_by().get(name, []) if effective.scope == scope else []
|
|
436
|
+
if called_by:
|
|
437
|
+
raise ValueError(
|
|
438
|
+
f"Cannot delete saved chain {name}; it is used by: {', '.join(called_by)}"
|
|
439
|
+
)
|
|
440
|
+
self.chain_store.delete(scope, name)
|
|
441
|
+
await self._rebuild_catalog()
|
|
442
|
+
return self._list_chains()
|
|
443
|
+
|
|
444
|
+
async def _dispatch(
|
|
445
|
+
self,
|
|
446
|
+
public_name: str,
|
|
447
|
+
arguments: JsonObject,
|
|
448
|
+
context: ExecutionContext,
|
|
449
|
+
) -> JsonValue:
|
|
450
|
+
spec = context.catalog.tools[public_name]
|
|
451
|
+
if spec.kind == "saved_chain":
|
|
452
|
+
chain = self.chain_store.get(spec.backend_name).chain
|
|
453
|
+
return await self.executor.execute_nested_chain(chain, arguments, context)
|
|
454
|
+
|
|
455
|
+
handle = self.handles[spec.server]
|
|
456
|
+
result = await handle.call_tool(
|
|
457
|
+
spec.backend_name,
|
|
458
|
+
arguments,
|
|
459
|
+
timeout_seconds=min(
|
|
460
|
+
self.executor.settings.tool_timeout_seconds,
|
|
461
|
+
max(0.001, context.remaining_seconds()),
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
return context.catalog.normalize_result(public_name, result)
|
|
465
|
+
|
|
466
|
+
async def discover(self, server: str) -> StatusResponse:
|
|
467
|
+
handle = self.handles.get(server)
|
|
468
|
+
if handle is None:
|
|
469
|
+
raise ValueError(f"Unknown or disabled MCP server: {server}")
|
|
470
|
+
await handle.discover(force=True)
|
|
471
|
+
await self._rebuild_catalog()
|
|
472
|
+
return self.status()
|
|
473
|
+
|
|
474
|
+
async def reload_settings(self) -> StatusResponse:
|
|
475
|
+
self._load_settings()
|
|
476
|
+
await self._rebuild_catalog()
|
|
477
|
+
return self.status()
|
|
478
|
+
|
|
479
|
+
async def apply_manager_changes(
|
|
480
|
+
self,
|
|
481
|
+
changes: list[ChainEnabledChange],
|
|
482
|
+
) -> ManagerApplyResponse:
|
|
483
|
+
previous_settings = self.settings
|
|
484
|
+
try:
|
|
485
|
+
with self.chain_store.enabled_transaction(changes):
|
|
486
|
+
self._load_settings()
|
|
487
|
+
await self._rebuild_catalog()
|
|
488
|
+
except BaseException:
|
|
489
|
+
self.settings = previous_settings
|
|
490
|
+
for handle in self.handles.values():
|
|
491
|
+
handle.cache.max_age_seconds = previous_settings.cache_ttl_seconds
|
|
492
|
+
self.executor.settings = previous_settings.execution_settings()
|
|
493
|
+
await self._rebuild_catalog()
|
|
494
|
+
raise
|
|
495
|
+
return ManagerApplyResponse(
|
|
496
|
+
status=self.status(),
|
|
497
|
+
chains=self._chain_views(),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def _load_settings(self) -> None:
|
|
501
|
+
self.settings = load_settings(self.settings_path)
|
|
502
|
+
for handle in self.handles.values():
|
|
503
|
+
handle.cache.max_age_seconds = self.settings.cache_ttl_seconds
|
|
504
|
+
self.executor.settings = self.settings.execution_settings()
|
|
505
|
+
|
|
506
|
+
def status(self) -> StatusResponse:
|
|
507
|
+
upstreams: list[UpstreamStatus] = []
|
|
508
|
+
for server in self.normalized.servers:
|
|
509
|
+
handle = self.handles.get(server.name)
|
|
510
|
+
all_tools = handle.tools if handle is not None and handle.tools is not None else []
|
|
511
|
+
tools = [
|
|
512
|
+
UpstreamToolStatus(
|
|
513
|
+
name=tool.name,
|
|
514
|
+
enabled=self.settings.tool_enabled(server.name, tool.name),
|
|
515
|
+
description=_compact_description(tool.description),
|
|
516
|
+
)
|
|
517
|
+
for tool in sorted(all_tools, key=lambda item: item.name)
|
|
518
|
+
]
|
|
519
|
+
upstreams.append(
|
|
520
|
+
UpstreamStatus(
|
|
521
|
+
name=server.name,
|
|
522
|
+
transport=server.transport,
|
|
523
|
+
enabled=server.enabled,
|
|
524
|
+
connected=handle is not None and handle.client is not None,
|
|
525
|
+
discovered=handle is not None and handle.tools is not None,
|
|
526
|
+
auth=server.auth,
|
|
527
|
+
tool_count=sum(tool.enabled for tool in tools),
|
|
528
|
+
total_tool_count=len(tools),
|
|
529
|
+
tools=tools,
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
return StatusResponse(
|
|
533
|
+
connected=True,
|
|
534
|
+
config_path=str(self.config_path),
|
|
535
|
+
tool_count=len(self.catalog.tools),
|
|
536
|
+
upstreams=upstreams,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
async def _ensure_catalog_complete(self) -> None:
|
|
540
|
+
await self._ensure_servers_discovered(self.handles.keys())
|
|
541
|
+
|
|
542
|
+
async def _ensure_servers_discovered(self, server_names: Iterable[str]) -> None:
|
|
543
|
+
requested = set(server_names)
|
|
544
|
+
missing = [
|
|
545
|
+
handle
|
|
546
|
+
for name, handle in self.handles.items()
|
|
547
|
+
if name in requested and handle.tools is None
|
|
548
|
+
]
|
|
549
|
+
if missing:
|
|
550
|
+
await asyncio.gather(*(handle.discover() for handle in missing))
|
|
551
|
+
if not missing:
|
|
552
|
+
return
|
|
553
|
+
await self._rebuild_catalog()
|
|
554
|
+
|
|
555
|
+
async def _rebuild_catalog(self) -> None:
|
|
556
|
+
async with self._catalog_lock:
|
|
557
|
+
self.catalog = self._build_catalog(self.chain_store.enabled())
|
|
558
|
+
self.executor.update_catalog(self.catalog)
|
|
559
|
+
|
|
560
|
+
def _build_catalog(self, chains: Iterable[SavedChainManifest]) -> ToolCatalog:
|
|
561
|
+
return ToolCatalog.from_server_tools(
|
|
562
|
+
{
|
|
563
|
+
name: [
|
|
564
|
+
tool
|
|
565
|
+
for tool in handle.tools or []
|
|
566
|
+
if self.settings.tool_enabled(name, tool.name)
|
|
567
|
+
]
|
|
568
|
+
for name, handle in self.handles.items()
|
|
569
|
+
},
|
|
570
|
+
self.handles.keys(),
|
|
571
|
+
chains,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
def _chain_dependencies(
|
|
575
|
+
self,
|
|
576
|
+
code: str,
|
|
577
|
+
catalog: ToolCatalog,
|
|
578
|
+
) -> list[ChainDependency]:
|
|
579
|
+
dependencies: dict[str, ChainDependency] = {}
|
|
580
|
+
for public_name in self._referenced_callables(code, catalog):
|
|
581
|
+
spec = catalog.tools[public_name]
|
|
582
|
+
dependencies[public_name] = ChainDependency(
|
|
583
|
+
kind=spec.kind,
|
|
584
|
+
name=spec.name,
|
|
585
|
+
call=spec.call,
|
|
586
|
+
server=spec.server,
|
|
587
|
+
schema_fingerprint=spec.schema_fingerprint,
|
|
588
|
+
)
|
|
589
|
+
return [dependencies[name] for name in sorted(dependencies)]
|
|
590
|
+
|
|
591
|
+
def _chain_views(self) -> list[ChainStatusView]:
|
|
592
|
+
called_by = self._called_by()
|
|
593
|
+
views: list[ChainStatusView] = []
|
|
594
|
+
for item in self.chain_store.load_all():
|
|
595
|
+
chain = item.chain
|
|
596
|
+
stale = [
|
|
597
|
+
dependency.call
|
|
598
|
+
for dependency in chain.dependencies
|
|
599
|
+
if self._dependency_is_stale(dependency)
|
|
600
|
+
]
|
|
601
|
+
status: Literal["ready", "disabled", "stale", "shadowed"]
|
|
602
|
+
if self.chain_store.is_shadowed(item):
|
|
603
|
+
status = "shadowed"
|
|
604
|
+
elif not chain.enabled:
|
|
605
|
+
status = "disabled"
|
|
606
|
+
elif stale:
|
|
607
|
+
status = "stale"
|
|
608
|
+
else:
|
|
609
|
+
status = "ready"
|
|
610
|
+
views.append(
|
|
611
|
+
ChainStatusView(
|
|
612
|
+
chain=chain,
|
|
613
|
+
scope=item.scope,
|
|
614
|
+
status=status,
|
|
615
|
+
stale_dependencies=stale,
|
|
616
|
+
called_by=called_by.get(chain.name, []) if status != "shadowed" else [],
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
return views
|
|
620
|
+
|
|
621
|
+
def _dependency_is_stale(self, dependency: ChainDependency) -> bool:
|
|
622
|
+
spec = self.catalog.tools.get(dependency.name)
|
|
623
|
+
if spec is not None:
|
|
624
|
+
return spec.schema_fingerprint != dependency.schema_fingerprint
|
|
625
|
+
if dependency.kind == "mcp_tool":
|
|
626
|
+
handle = self.handles.get(dependency.server)
|
|
627
|
+
if handle is not None and handle.tools is None:
|
|
628
|
+
return False
|
|
629
|
+
return True
|
|
630
|
+
|
|
631
|
+
def _chain_view(self, name: str, scope: ChainScope) -> ChainStatusView:
|
|
632
|
+
view = next(
|
|
633
|
+
(
|
|
634
|
+
view
|
|
635
|
+
for view in self._chain_views()
|
|
636
|
+
if view.chain.name == name and view.scope == scope
|
|
637
|
+
),
|
|
638
|
+
None,
|
|
639
|
+
)
|
|
640
|
+
if view is None:
|
|
641
|
+
raise ValueError(f"Unknown {scope} saved chain: {name}")
|
|
642
|
+
return view
|
|
643
|
+
|
|
644
|
+
def _called_by(self) -> dict[str, list[str]]:
|
|
645
|
+
result: dict[str, list[str]] = {}
|
|
646
|
+
for item in self.chain_store.effective():
|
|
647
|
+
chain = item.chain
|
|
648
|
+
for dependency in chain.dependencies:
|
|
649
|
+
if dependency.kind != "saved_chain":
|
|
650
|
+
continue
|
|
651
|
+
target = dependency.name.removeprefix("chain_")
|
|
652
|
+
if target == chain.name:
|
|
653
|
+
continue
|
|
654
|
+
result.setdefault(target, []).append(chain.name)
|
|
655
|
+
return {name: sorted(set(callers)) for name, callers in result.items()}
|
|
656
|
+
|
|
657
|
+
def _required_servers_for_code(self, code: str) -> set[str]:
|
|
658
|
+
required = self._referenced_servers(code)
|
|
659
|
+
for public_name in self._referenced_callables(code, self.catalog):
|
|
660
|
+
spec = self.catalog.tools[public_name]
|
|
661
|
+
if spec.kind == "saved_chain":
|
|
662
|
+
required.update(
|
|
663
|
+
self._required_servers_for_chain(self.chain_store.get(spec.backend_name).chain)
|
|
664
|
+
)
|
|
665
|
+
return required
|
|
666
|
+
|
|
667
|
+
def _required_servers_for_chain(
|
|
668
|
+
self,
|
|
669
|
+
chain: SavedChainManifest,
|
|
670
|
+
visited: set[str] | None = None,
|
|
671
|
+
) -> set[str]:
|
|
672
|
+
seen = set() if visited is None else visited
|
|
673
|
+
if chain.name in seen:
|
|
674
|
+
return set()
|
|
675
|
+
seen.add(chain.name)
|
|
676
|
+
required: set[str] = set()
|
|
677
|
+
manifests = {item.chain.name: item.chain for item in self.chain_store.effective()}
|
|
678
|
+
for dependency in chain.dependencies:
|
|
679
|
+
if dependency.kind == "mcp_tool":
|
|
680
|
+
required.add(dependency.server)
|
|
681
|
+
continue
|
|
682
|
+
target = manifests.get(dependency.name.removeprefix("chain_"))
|
|
683
|
+
if target is not None and target.enabled:
|
|
684
|
+
required.update(self._required_servers_for_chain(target, seen))
|
|
685
|
+
return required
|
|
686
|
+
|
|
687
|
+
@staticmethod
|
|
688
|
+
def _referenced_callables(code: str, catalog: ToolCatalog) -> set[str]:
|
|
689
|
+
tree = _parse_code(code)
|
|
690
|
+
if tree is None:
|
|
691
|
+
return set()
|
|
692
|
+
referenced: set[str] = set()
|
|
693
|
+
for node in ast.walk(tree):
|
|
694
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
695
|
+
continue
|
|
696
|
+
owner = node.func.value
|
|
697
|
+
if not isinstance(owner, ast.Name):
|
|
698
|
+
continue
|
|
699
|
+
public_name = catalog.facade_calls.get((owner.id, node.func.attr))
|
|
700
|
+
if public_name is not None:
|
|
701
|
+
referenced.add(public_name)
|
|
702
|
+
return referenced
|
|
703
|
+
|
|
704
|
+
def _referenced_servers(self, code: str) -> set[str]:
|
|
705
|
+
tree = _parse_code(code)
|
|
706
|
+
if tree is None:
|
|
707
|
+
return set()
|
|
708
|
+
aliases = {alias: server for server, alias in self.catalog.server_aliases.items()}
|
|
709
|
+
return {
|
|
710
|
+
aliases[node.value.id]
|
|
711
|
+
for node in ast.walk(tree)
|
|
712
|
+
if isinstance(node, ast.Attribute)
|
|
713
|
+
and isinstance(node.value, ast.Name)
|
|
714
|
+
and node.value.id in aliases
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _parse_code(code: str) -> ast.AST | None:
|
|
719
|
+
normalized = textwrap.dedent(code).strip("\n")
|
|
720
|
+
wrapped = f"async def __codemcp_main():\n{textwrap.indent(normalized, ' ')}\n"
|
|
721
|
+
try:
|
|
722
|
+
return ast.parse(wrapped, mode="exec")
|
|
723
|
+
except SyntaxError:
|
|
724
|
+
return None
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _compact_description(description: str | None, limit: int = 160) -> str | None:
|
|
728
|
+
if description is None:
|
|
729
|
+
return None
|
|
730
|
+
compact = " ".join(description.split("\n\n", 1)[0].split())
|
|
731
|
+
return compact if len(compact) <= limit else f"{compact[: limit - 1].rstrip()}…"
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
class RuntimeState:
|
|
735
|
+
def __init__(self) -> None:
|
|
736
|
+
self.runtime: GatewayRuntime | None = None
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
_runtime_state = RuntimeState()
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _require_runtime() -> GatewayRuntime:
|
|
743
|
+
if _runtime_state.runtime is None:
|
|
744
|
+
raise RuntimeError("Code Mode sidecar is not initialized")
|
|
745
|
+
return _runtime_state.runtime
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _runtime_paths() -> tuple[Path, Path, Path, Path, Path, Path | None]:
|
|
749
|
+
raw_agent_dir = os.environ.get(CODEMCP_AGENT_DIR_ENV) or os.environ.get(PI_AGENT_DIR_ENV)
|
|
750
|
+
agent_dir = Path(raw_agent_dir).expanduser() if raw_agent_dir else DEFAULT_AGENT_DIR
|
|
751
|
+
state_dir = agent_dir / "pi-codemcp"
|
|
752
|
+
raw_project_chains_dir = os.environ.get(CODEMCP_PROJECT_CHAINS_DIR_ENV)
|
|
753
|
+
project_chains_dir = (
|
|
754
|
+
Path(raw_project_chains_dir).expanduser() if raw_project_chains_dir else None
|
|
755
|
+
)
|
|
756
|
+
return (
|
|
757
|
+
agent_dir / "mcp.json",
|
|
758
|
+
state_dir / "oauth",
|
|
759
|
+
state_dir / "catalog",
|
|
760
|
+
state_dir / "settings.json",
|
|
761
|
+
state_dir / "chains",
|
|
762
|
+
project_chains_dir,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@asynccontextmanager
|
|
767
|
+
async def lifespan(_: FastMCP[None]) -> AsyncIterator[None]:
|
|
768
|
+
(
|
|
769
|
+
config_path,
|
|
770
|
+
oauth_dir,
|
|
771
|
+
catalog_dir,
|
|
772
|
+
settings_path,
|
|
773
|
+
global_chains_dir,
|
|
774
|
+
project_chains_dir,
|
|
775
|
+
) = _runtime_paths()
|
|
776
|
+
_runtime_state.runtime = GatewayRuntime.create(
|
|
777
|
+
config_path,
|
|
778
|
+
oauth_dir,
|
|
779
|
+
catalog_dir,
|
|
780
|
+
settings_path,
|
|
781
|
+
global_chain_dir=global_chains_dir,
|
|
782
|
+
project_chain_dir=project_chains_dir,
|
|
783
|
+
)
|
|
784
|
+
try:
|
|
785
|
+
yield
|
|
786
|
+
finally:
|
|
787
|
+
runtime, _runtime_state.runtime = _runtime_state.runtime, None
|
|
788
|
+
if runtime is not None:
|
|
789
|
+
await runtime.close()
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
mcp = FastMCP(
|
|
793
|
+
"pi-codemcp-sidecar",
|
|
794
|
+
instructions=(
|
|
795
|
+
"Search MCP tools and saved chains, then execute a typed sandboxed Python call graph."
|
|
796
|
+
),
|
|
797
|
+
lifespan=lifespan,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@mcp.tool
|
|
802
|
+
async def search(
|
|
803
|
+
query: str,
|
|
804
|
+
limit: int = 5,
|
|
805
|
+
server: str | None = None,
|
|
806
|
+
) -> SearchResponse:
|
|
807
|
+
"""Search configured upstream MCP tools and saved chains by capability."""
|
|
808
|
+
return await _require_runtime().search(query, limit, server)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
@mcp.tool
|
|
812
|
+
async def discover(server: str) -> StatusResponse:
|
|
813
|
+
"""Force-refresh one enabled upstream tool catalog."""
|
|
814
|
+
return await _require_runtime().discover(server)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
@mcp.tool
|
|
818
|
+
async def reload_settings() -> StatusResponse:
|
|
819
|
+
"""Reload persisted CodeMCP settings and tool policy."""
|
|
820
|
+
return await _require_runtime().reload_settings()
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@mcp.tool
|
|
824
|
+
async def apply_manager_changes(
|
|
825
|
+
changes: list[ChainEnabledChange],
|
|
826
|
+
) -> ManagerApplyResponse:
|
|
827
|
+
"""Apply staged settings and saved-chain enable changes with one catalog rebuild."""
|
|
828
|
+
return await _require_runtime().apply_manager_changes(changes)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
@mcp.tool
|
|
832
|
+
async def execute(code: str) -> ExecutionResponse:
|
|
833
|
+
"""Type-check and run one sandboxed Python MCP SDK chain."""
|
|
834
|
+
return await _require_runtime().execute(code)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
@mcp.tool
|
|
838
|
+
async def save_chain(
|
|
839
|
+
name: str,
|
|
840
|
+
description: str,
|
|
841
|
+
code: str,
|
|
842
|
+
input_schema: JsonObject,
|
|
843
|
+
output_schema: JsonObject,
|
|
844
|
+
scope: ChainScope = "project",
|
|
845
|
+
) -> SaveChainResponse:
|
|
846
|
+
"""Validate and persist one reusable typed MCP chain."""
|
|
847
|
+
return await _require_runtime().chains.save(
|
|
848
|
+
scope=scope,
|
|
849
|
+
name=name,
|
|
850
|
+
description=description,
|
|
851
|
+
code=code,
|
|
852
|
+
input_schema=json_types.JSON_OBJECT_ADAPTER.validate_python(input_schema),
|
|
853
|
+
output_schema=json_types.JSON_OBJECT_ADAPTER.validate_python(output_schema),
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
@mcp.tool
|
|
858
|
+
def list_chains() -> ChainListResponse:
|
|
859
|
+
"""List saved chains and their dependency state."""
|
|
860
|
+
return _require_runtime().chains.list()
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
@mcp.tool
|
|
864
|
+
async def execute_chain(name: str, arguments: JsonObject) -> ExecutionResponse:
|
|
865
|
+
"""Execute one saved chain through its typed input contract."""
|
|
866
|
+
validated_arguments = json_types.JSON_OBJECT_ADAPTER.validate_python(arguments)
|
|
867
|
+
return await _require_runtime().chains.execute(name, validated_arguments)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
@mcp.tool
|
|
871
|
+
async def revalidate_chain(name: str, scope: ChainScope) -> ChainStatusView:
|
|
872
|
+
"""Revalidate one scoped saved chain against the current callable catalog."""
|
|
873
|
+
return await _require_runtime().chains.revalidate(name, scope)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
@mcp.tool
|
|
877
|
+
async def delete_chain(name: str, scope: ChainScope) -> ChainListResponse:
|
|
878
|
+
"""Delete an unused saved chain from its storage scope."""
|
|
879
|
+
return await _require_runtime().chains.delete(name, scope)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
@mcp.tool
|
|
883
|
+
def status() -> StatusResponse:
|
|
884
|
+
"""Report cached catalog and upstream connection state without connecting upstreams."""
|
|
885
|
+
return _require_runtime().status()
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def main() -> None:
|
|
889
|
+
mcp.run(transport="stdio", show_banner=False, log_level="ERROR")
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
if __name__ == "__main__":
|
|
893
|
+
main()
|