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,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()