pi-codemcp 1.0.0 → 1.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/README.md +144 -13
- package/extensions/index.ts +4 -1
- package/package.json +7 -7
- package/sidecar/catalog_cache.py +2 -1
- package/sidecar/cli.py +402 -0
- package/sidecar/executor.py +349 -36
- package/sidecar/gateway.py +499 -60
- package/sidecar/models.py +41 -4
- package/sidecar/pyproject.toml +33 -40
- package/sidecar/runtime_paths.py +71 -0
- package/sidecar/settings.py +7 -3
- package/sidecar/stats.py +424 -0
- package/sidecar/tool_catalog.py +311 -46
- package/sidecar/uv.lock +99 -244
- package/src/chains.ts +1 -2
- package/src/execution-rendering.ts +7 -5
- package/src/mcp-client.ts +5 -1
- package/src/modal.ts +258 -14
- package/src/output.ts +27 -24
- package/src/prompts.ts +24 -0
- package/src/settings.ts +18 -17
- package/src/tools.ts +270 -36
package/sidecar/cli.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ValidationError
|
|
12
|
+
|
|
13
|
+
from . import gateway
|
|
14
|
+
from .json_types import JSON_OBJECT_ADAPTER, JSON_VALUE_ADAPTER
|
|
15
|
+
from .mcp_config import load_mcp_json
|
|
16
|
+
from .runtime_paths import RuntimePaths, resolve_runtime_paths
|
|
17
|
+
from .settings import load_settings
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
from .json_types import JsonObject, JsonValue
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PROGRAM_NAME = "codemcp"
|
|
26
|
+
INTERRUPTED = 130
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog=PROGRAM_NAME,
|
|
32
|
+
description="Internal command-line entrypoint for the pi-codemcp sidecar.",
|
|
33
|
+
)
|
|
34
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
35
|
+
|
|
36
|
+
serve = subcommands.add_parser("serve", help="Run the sidecar as an MCP server.")
|
|
37
|
+
_add_runtime_path_args(serve)
|
|
38
|
+
serve.add_argument(
|
|
39
|
+
"--stdio",
|
|
40
|
+
action="store_true",
|
|
41
|
+
required=True,
|
|
42
|
+
help="Serve the MCP protocol over standard input/output.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
status = subcommands.add_parser("status", help="Print lazy sidecar runtime status as JSON.")
|
|
46
|
+
_add_runtime_path_args(status)
|
|
47
|
+
|
|
48
|
+
discover = subcommands.add_parser(
|
|
49
|
+
"discover",
|
|
50
|
+
help="Force-refresh one enabled upstream MCP server catalog.",
|
|
51
|
+
)
|
|
52
|
+
_add_runtime_path_args(discover)
|
|
53
|
+
discover.add_argument("server", help="Configured upstream server name to discover.")
|
|
54
|
+
|
|
55
|
+
search = subcommands.add_parser(
|
|
56
|
+
"search",
|
|
57
|
+
help="Search configured upstream MCP tools and saved chains.",
|
|
58
|
+
)
|
|
59
|
+
_add_runtime_path_args(search)
|
|
60
|
+
search.add_argument("query", help="Words describing an upstream capability or saved chain.")
|
|
61
|
+
search.add_argument("--limit", type=int, default=5, help="Maximum matches to return.")
|
|
62
|
+
search.add_argument("--server", help="Restrict search to one configured server or chains.")
|
|
63
|
+
|
|
64
|
+
execute = subcommands.add_parser(
|
|
65
|
+
"execute",
|
|
66
|
+
help="Type-check and execute one sandboxed Python MCP call graph.",
|
|
67
|
+
)
|
|
68
|
+
_add_runtime_path_args(execute)
|
|
69
|
+
_add_text_input_args(execute, "code", "Sandboxed Python body to execute.")
|
|
70
|
+
|
|
71
|
+
chain = subcommands.add_parser("chain", help="Manage and run saved MCP chains.")
|
|
72
|
+
chain_subcommands = chain.add_subparsers(dest="chain_command", required=True)
|
|
73
|
+
|
|
74
|
+
chain_list = chain_subcommands.add_parser("list", help="List saved chains as JSON.")
|
|
75
|
+
_add_runtime_path_args(chain_list)
|
|
76
|
+
|
|
77
|
+
chain_run = chain_subcommands.add_parser("run", help="Execute one saved chain.")
|
|
78
|
+
_add_runtime_path_args(chain_run)
|
|
79
|
+
chain_run.add_argument("name", help="Saved chain name.")
|
|
80
|
+
_add_json_input_args(chain_run, "input", "Saved-chain input object.", default="{}")
|
|
81
|
+
|
|
82
|
+
chain_save = chain_subcommands.add_parser("save", help="Validate and save one chain.")
|
|
83
|
+
_add_runtime_path_args(chain_save)
|
|
84
|
+
chain_save.add_argument("name", help="Saved chain name.")
|
|
85
|
+
chain_save.add_argument("--scope", choices=["project", "global"], default="project")
|
|
86
|
+
chain_save.add_argument("--description", required=True, help="Saved chain description.")
|
|
87
|
+
_add_text_input_args(chain_save, "code", "Sandboxed Python body to save.")
|
|
88
|
+
_add_json_input_args(chain_save, "input-schema", "Input JSON Schema object.")
|
|
89
|
+
_add_json_input_args(chain_save, "output-schema", "Output JSON Schema object.")
|
|
90
|
+
|
|
91
|
+
chain_revalidate = chain_subcommands.add_parser(
|
|
92
|
+
"revalidate",
|
|
93
|
+
help="Revalidate one scoped saved chain against the current catalog.",
|
|
94
|
+
)
|
|
95
|
+
_add_runtime_path_args(chain_revalidate)
|
|
96
|
+
chain_revalidate.add_argument("name", help="Saved chain name.")
|
|
97
|
+
chain_revalidate.add_argument("--scope", choices=["project", "global"], required=True)
|
|
98
|
+
|
|
99
|
+
chain_delete = chain_subcommands.add_parser("delete", help="Delete one unused saved chain.")
|
|
100
|
+
_add_runtime_path_args(chain_delete)
|
|
101
|
+
chain_delete.add_argument("name", help="Saved chain name.")
|
|
102
|
+
chain_delete.add_argument("--scope", choices=["project", "global"], required=True)
|
|
103
|
+
|
|
104
|
+
doctor = subcommands.add_parser("doctor", help="Check local sidecar paths and config.")
|
|
105
|
+
_add_runtime_path_args(doctor)
|
|
106
|
+
|
|
107
|
+
return parser
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
111
|
+
parser = build_parser()
|
|
112
|
+
args = parser.parse_args(argv)
|
|
113
|
+
try:
|
|
114
|
+
if args.command == "serve":
|
|
115
|
+
return _serve(args)
|
|
116
|
+
return asyncio.run(_run_one_shot(args))
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
_write_stderr(f"{PROGRAM_NAME}: interrupted")
|
|
119
|
+
return INTERRUPTED
|
|
120
|
+
except (OSError, RuntimeError, TypeError, ValueError, ValidationError) as error:
|
|
121
|
+
_write_stderr(f"{PROGRAM_NAME}: {error}")
|
|
122
|
+
return 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _serve(args: argparse.Namespace) -> int:
|
|
126
|
+
if args.stdio is not True:
|
|
127
|
+
raise ValueError("serve requires --stdio")
|
|
128
|
+
gateway.configure_runtime_paths(_runtime_paths_from_args(args))
|
|
129
|
+
try:
|
|
130
|
+
gateway.main()
|
|
131
|
+
finally:
|
|
132
|
+
gateway.configure_runtime_paths(None)
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _run_one_shot(args: argparse.Namespace) -> int:
|
|
137
|
+
if args.command == "doctor":
|
|
138
|
+
doctor_result = _doctor(args)
|
|
139
|
+
_print_json(doctor_result)
|
|
140
|
+
return 0 if doctor_result["ok"] is True else 1
|
|
141
|
+
|
|
142
|
+
runtime = _runtime_from_args(args)
|
|
143
|
+
try:
|
|
144
|
+
runtime_result = await _dispatch_runtime_command(args, runtime)
|
|
145
|
+
finally:
|
|
146
|
+
await runtime.close()
|
|
147
|
+
_print_json(runtime_result)
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def _dispatch_runtime_command(
|
|
152
|
+
args: argparse.Namespace,
|
|
153
|
+
runtime: gateway.GatewayRuntime,
|
|
154
|
+
) -> BaseModel:
|
|
155
|
+
if args.command == "status":
|
|
156
|
+
return runtime.status()
|
|
157
|
+
if args.command == "discover":
|
|
158
|
+
return await runtime.discover(args.server)
|
|
159
|
+
if args.command == "search":
|
|
160
|
+
return await runtime.search(args.query, args.limit, args.server)
|
|
161
|
+
if args.command == "execute":
|
|
162
|
+
return await runtime.execute(_read_text_input(args, "code"))
|
|
163
|
+
if args.command == "chain":
|
|
164
|
+
return await _dispatch_chain_command(args, runtime)
|
|
165
|
+
raise ValueError(f"unknown command: {args.command}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _dispatch_chain_command(
|
|
169
|
+
args: argparse.Namespace,
|
|
170
|
+
runtime: gateway.GatewayRuntime,
|
|
171
|
+
) -> BaseModel:
|
|
172
|
+
if args.chain_command == "list":
|
|
173
|
+
return runtime.chains.list()
|
|
174
|
+
if args.chain_command == "run":
|
|
175
|
+
return await runtime.chains.execute(args.name, _read_json_object_input(args, "input"))
|
|
176
|
+
if args.chain_command == "save":
|
|
177
|
+
return await runtime.chains.save(
|
|
178
|
+
scope=args.scope,
|
|
179
|
+
name=args.name,
|
|
180
|
+
description=args.description,
|
|
181
|
+
code=_read_text_input(args, "code"),
|
|
182
|
+
input_schema=_read_json_object_input(args, "input_schema"),
|
|
183
|
+
output_schema=_read_json_object_input(args, "output_schema"),
|
|
184
|
+
)
|
|
185
|
+
if args.chain_command == "revalidate":
|
|
186
|
+
return await runtime.chains.revalidate(args.name, args.scope)
|
|
187
|
+
if args.chain_command == "delete":
|
|
188
|
+
return await runtime.chains.delete(args.name, args.scope)
|
|
189
|
+
raise ValueError(f"unknown chain command: {args.chain_command}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _doctor(args: argparse.Namespace) -> JsonObject:
|
|
193
|
+
paths = _runtime_paths_from_args(args)
|
|
194
|
+
checks = [
|
|
195
|
+
_config_check(paths),
|
|
196
|
+
_settings_check(paths),
|
|
197
|
+
_writable_path_check("oauth_dir", paths.oauth_dir),
|
|
198
|
+
_writable_path_check("catalog_dir", paths.catalog_dir),
|
|
199
|
+
_writable_path_check("global_chains_dir", paths.global_chains_dir),
|
|
200
|
+
_optional_writable_path_check("project_chains_dir", paths.project_chains_dir),
|
|
201
|
+
]
|
|
202
|
+
return JSON_OBJECT_ADAPTER.validate_python({
|
|
203
|
+
"ok": all(check["ok"] is True for check in checks),
|
|
204
|
+
"paths": {
|
|
205
|
+
"config_path": str(paths.config_path),
|
|
206
|
+
"oauth_dir": str(paths.oauth_dir),
|
|
207
|
+
"catalog_dir": str(paths.catalog_dir),
|
|
208
|
+
"settings_path": str(paths.settings_path),
|
|
209
|
+
"global_chains_dir": str(paths.global_chains_dir),
|
|
210
|
+
"project_chains_dir": str(paths.project_chains_dir)
|
|
211
|
+
if paths.project_chains_dir is not None
|
|
212
|
+
else None,
|
|
213
|
+
},
|
|
214
|
+
"checks": checks,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _config_check(paths: RuntimePaths) -> JsonObject:
|
|
219
|
+
try:
|
|
220
|
+
load_mcp_json(paths.config_path)
|
|
221
|
+
except (OSError, RuntimeError, TypeError, ValueError, ValidationError) as error:
|
|
222
|
+
return _check("mcp_config", ok=False, message=str(error))
|
|
223
|
+
return _check("mcp_config", ok=True, message="ok")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _settings_check(paths: RuntimePaths) -> JsonObject:
|
|
227
|
+
try:
|
|
228
|
+
load_settings(paths.settings_path)
|
|
229
|
+
except (OSError, RuntimeError, TypeError, ValueError, ValidationError) as error:
|
|
230
|
+
return _check("settings", ok=False, message=str(error))
|
|
231
|
+
if paths.settings_path.exists():
|
|
232
|
+
return _check("settings", ok=True, message="ok")
|
|
233
|
+
return _check("settings", ok=True, message="not found; defaults will be used")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _optional_writable_path_check(name: str, path: Path | None) -> JsonObject:
|
|
237
|
+
if path is None:
|
|
238
|
+
return _check(name, ok=True, message="not configured")
|
|
239
|
+
return _writable_path_check(name, path)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _writable_path_check(name: str, path: Path) -> JsonObject:
|
|
243
|
+
target = path if path.exists() else _nearest_existing_parent(path)
|
|
244
|
+
if target is None:
|
|
245
|
+
return _check(name, ok=False, message="no existing parent directory")
|
|
246
|
+
if os.access(target, os.W_OK):
|
|
247
|
+
return _check(name, ok=True, message=f"writable via {target}")
|
|
248
|
+
return _check(name, ok=False, message=f"not writable via {target}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _nearest_existing_parent(path: Path) -> Path | None:
|
|
252
|
+
current = path
|
|
253
|
+
while current != current.parent:
|
|
254
|
+
current = current.parent
|
|
255
|
+
if current.exists():
|
|
256
|
+
return current
|
|
257
|
+
return current if current.exists() else None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _check(name: str, *, ok: bool, message: str) -> JsonObject:
|
|
261
|
+
return {"name": name, "ok": ok, "message": message}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _runtime_from_args(args: argparse.Namespace) -> gateway.GatewayRuntime:
|
|
265
|
+
paths = _runtime_paths_from_args(args)
|
|
266
|
+
return gateway.GatewayRuntime.create(
|
|
267
|
+
paths.config_path,
|
|
268
|
+
paths.oauth_dir,
|
|
269
|
+
paths.catalog_dir,
|
|
270
|
+
paths.settings_path,
|
|
271
|
+
global_chain_dir=paths.global_chains_dir,
|
|
272
|
+
project_chain_dir=paths.project_chains_dir,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _runtime_paths_from_args(args: argparse.Namespace) -> RuntimePaths:
|
|
277
|
+
return resolve_runtime_paths(
|
|
278
|
+
agent_dir=_path_from_namespace(args, "agent_dir"),
|
|
279
|
+
config_path=_path_from_namespace(args, "config_path"),
|
|
280
|
+
settings_path=_path_from_namespace(args, "settings_path"),
|
|
281
|
+
oauth_dir=_path_from_namespace(args, "oauth_dir"),
|
|
282
|
+
catalog_dir=_path_from_namespace(args, "catalog_dir"),
|
|
283
|
+
global_chains_dir=_path_from_namespace(args, "global_chains_dir"),
|
|
284
|
+
project_chains_dir=_path_from_namespace(args, "project_chains_dir"),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _path_from_namespace(args: argparse.Namespace, name: str) -> Path | None:
|
|
289
|
+
value = getattr(args, name, None)
|
|
290
|
+
return value if isinstance(value, Path) else None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _path_arg(value: str) -> Path:
|
|
294
|
+
return Path(value).expanduser()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _add_runtime_path_args(parser: argparse.ArgumentParser) -> None:
|
|
298
|
+
parser.add_argument("--agent-dir", type=_path_arg, help="Pi agent directory.")
|
|
299
|
+
parser.add_argument("--config", dest="config_path", type=_path_arg, help="MCP config path.")
|
|
300
|
+
parser.add_argument("--settings", dest="settings_path", type=_path_arg, help="Settings path.")
|
|
301
|
+
parser.add_argument("--oauth-dir", type=_path_arg, help="OAuth token storage directory.")
|
|
302
|
+
parser.add_argument("--catalog-dir", type=_path_arg, help="Tool catalog cache directory.")
|
|
303
|
+
parser.add_argument(
|
|
304
|
+
"--global-chains-dir",
|
|
305
|
+
type=_path_arg,
|
|
306
|
+
help="Global saved-chain manifest directory.",
|
|
307
|
+
)
|
|
308
|
+
parser.add_argument(
|
|
309
|
+
"--project-chains-dir",
|
|
310
|
+
type=_path_arg,
|
|
311
|
+
help="Project saved-chain manifest directory.",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _add_text_input_args(
|
|
316
|
+
parser: argparse.ArgumentParser,
|
|
317
|
+
name: str,
|
|
318
|
+
help_text: str,
|
|
319
|
+
) -> None:
|
|
320
|
+
dashed = name.replace("_", "-")
|
|
321
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
322
|
+
group.add_argument(f"--{dashed}", dest=name, help=help_text)
|
|
323
|
+
group.add_argument(
|
|
324
|
+
f"--{dashed}-file",
|
|
325
|
+
dest=f"{name}_file",
|
|
326
|
+
type=_path_arg,
|
|
327
|
+
help=f"Read {dashed} from a UTF-8 file.",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _add_json_input_args(
|
|
332
|
+
parser: argparse.ArgumentParser,
|
|
333
|
+
name: str,
|
|
334
|
+
help_text: str,
|
|
335
|
+
*,
|
|
336
|
+
default: str | None = None,
|
|
337
|
+
) -> None:
|
|
338
|
+
attribute = name.replace("-", "_")
|
|
339
|
+
group = parser.add_mutually_exclusive_group(required=default is None)
|
|
340
|
+
group.add_argument(f"--{name}", dest=attribute, help=help_text)
|
|
341
|
+
group.add_argument(
|
|
342
|
+
f"--{name}-file",
|
|
343
|
+
dest=f"{attribute}_file",
|
|
344
|
+
type=_path_arg,
|
|
345
|
+
help=f"Read {name} JSON from a UTF-8 file.",
|
|
346
|
+
)
|
|
347
|
+
if default is not None:
|
|
348
|
+
parser.set_defaults(**{f"{attribute}_default": default})
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _read_text_input(args: argparse.Namespace, name: str) -> str:
|
|
352
|
+
raw = getattr(args, name, None)
|
|
353
|
+
if isinstance(raw, str):
|
|
354
|
+
return raw
|
|
355
|
+
file = getattr(args, f"{name}_file", None)
|
|
356
|
+
if isinstance(file, Path):
|
|
357
|
+
return file.read_text(encoding="utf-8")
|
|
358
|
+
raise ValueError(f"missing {name} input")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _read_json_object_input(args: argparse.Namespace, name: str) -> JsonObject:
|
|
362
|
+
return JSON_OBJECT_ADAPTER.validate_python(_read_json_input(args, name))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _read_json_input(args: argparse.Namespace, name: str) -> JsonValue:
|
|
366
|
+
raw = getattr(args, name, None)
|
|
367
|
+
if isinstance(raw, str):
|
|
368
|
+
return _parse_json(raw, name)
|
|
369
|
+
file = getattr(args, f"{name}_file", None)
|
|
370
|
+
if isinstance(file, Path):
|
|
371
|
+
return _parse_json(file.read_text(encoding="utf-8"), str(file))
|
|
372
|
+
default = getattr(args, f"{name}_default", None)
|
|
373
|
+
if isinstance(default, str):
|
|
374
|
+
return _parse_json(default, name)
|
|
375
|
+
raise ValueError(f"missing {name} JSON input")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _parse_json(value: str, label: str) -> JsonValue:
|
|
379
|
+
try:
|
|
380
|
+
parsed: object = json.loads(value)
|
|
381
|
+
except json.JSONDecodeError as error:
|
|
382
|
+
raise ValueError(f"invalid JSON for {label}: {error.msg}") from error
|
|
383
|
+
return JSON_VALUE_ADAPTER.validate_python(parsed)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _print_json(value: BaseModel | JsonObject) -> None:
|
|
387
|
+
if isinstance(value, BaseModel):
|
|
388
|
+
_write_stdout(value.model_dump_json(indent=2))
|
|
389
|
+
return
|
|
390
|
+
_write_stdout(json.dumps(value, indent=2, sort_keys=True))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _write_stdout(value: str) -> None:
|
|
394
|
+
sys.stdout.write(f"{value}\n")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _write_stderr(value: str) -> None:
|
|
398
|
+
sys.stderr.write(f"{value}\n")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
if __name__ == "__main__":
|
|
402
|
+
raise SystemExit(main())
|