spawn-cli 0.1.13__tar.gz → 0.1.15__tar.gz
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.
- {spawn_cli-0.1.13/src/spawn_cli.egg-info → spawn_cli-0.1.15}/PKG-INFO +1 -1
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/pyproject.toml +1 -1
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/cli.py +43 -1
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/core/download.py +1 -10
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/core/high_level.py +63 -8
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/core/low_level.py +94 -4
- spawn_cli-0.1.15/src/spawn_cli/core/mcp_stdio.py +67 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/ide/__init__.py +2 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/ide/claude_code.py +5 -1
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/ide/codex.py +7 -2
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/ide/cursor.py +5 -1
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/ide/gemini_cli.py +7 -2
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/ide/github_copilot.py +7 -2
- spawn_cli-0.1.15/src/spawn_cli/ide/mcp_stdio_argv.py +10 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/models/mcp.py +2 -1
- {spawn_cli-0.1.13 → spawn_cli-0.1.15/src/spawn_cli.egg-info}/PKG-INFO +1 -1
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli.egg-info/SOURCES.txt +2 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/tests/test_cli.py +64 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/LICENSE +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/README.md +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/setup.cfg +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/__init__.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/core/__init__.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/core/errors.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/core/scripts.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/errors.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/ide/_helpers.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/ide/_stub.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/ide/_vacancy.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/ide/registry.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/ide/windsurf.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/io/__init__.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/io/json_io.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/io/lock.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/io/paths.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/io/text_io.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/io/toml_io.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/io/yaml_io.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/models/__init__.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/models/config.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/models/metadata.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/models/navigation.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/models/skill.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/resources/__init__.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/resources/default_core_config.yaml +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli/warnings_display.py +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli.egg-info/dependency_links.txt +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli.egg-info/entry_points.txt +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli.egg-info/requires.txt +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/src/spawn_cli.egg-info/top_level.txt +0 -0
- {spawn_cli-0.1.13 → spawn_cli-0.1.15}/tests/test_warnings_display.py +0 -0
|
@@ -8,6 +8,7 @@ from spawn_cli import __version__
|
|
|
8
8
|
from spawn_cli.core import download as dl
|
|
9
9
|
from spawn_cli.core import high_level as hl
|
|
10
10
|
from spawn_cli.core import low_level as ll
|
|
11
|
+
from spawn_cli.core import mcp_stdio
|
|
11
12
|
from spawn_cli.core.errors import SpawnError
|
|
12
13
|
from spawn_cli.ide.registry import DetectResult, detect_supported_ides
|
|
13
14
|
from spawn_cli.io.lock import spawn_lock
|
|
@@ -210,7 +211,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
210
211
|
action="store_true",
|
|
211
212
|
help=(
|
|
212
213
|
"Fail on issues that default to warnings "
|
|
213
|
-
"(missing skill files,
|
|
214
|
+
"(missing skill files, invalid extsrc/mcp/*.json, empty file descriptions)."
|
|
214
215
|
),
|
|
215
216
|
)
|
|
216
217
|
|
|
@@ -298,6 +299,38 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
298
299
|
help="Fallback Git revision applied when entries lack branch.",
|
|
299
300
|
)
|
|
300
301
|
|
|
302
|
+
mcp_stdio_p = sub.add_parser(
|
|
303
|
+
"mcp_stdio",
|
|
304
|
+
help="Run stdio MCP for an extension server (IDE-stable wrapper)",
|
|
305
|
+
description=(
|
|
306
|
+
"Resolve the inner stdio command from installed extension MCP JSON for this OS "
|
|
307
|
+
"and relay MCP traffic on stdin/stdout. Intended as the IDE MCP entrypoint when "
|
|
308
|
+
"spawn_stdio_proxy is enabled; does not acquire the repository lock."
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
mcp_stdio_l1 = mcp_stdio_p.add_subparsers(dest="mcp_stdio_l1", required=True)
|
|
312
|
+
mcp_stdio_ext = mcp_stdio_l1.add_parser(
|
|
313
|
+
"extension",
|
|
314
|
+
help="Pick installed extension id",
|
|
315
|
+
description="Extension directory name under spawn/.extend/ (same ids as spawn extension list).",
|
|
316
|
+
)
|
|
317
|
+
mcp_stdio_ext.add_argument(
|
|
318
|
+
"mcp_stdio_extension_id",
|
|
319
|
+
metavar="EXTENSION_ID",
|
|
320
|
+
help="Installed extension id (folder under spawn/.extend/).",
|
|
321
|
+
)
|
|
322
|
+
mcp_stdio_l2 = mcp_stdio_ext.add_subparsers(dest="mcp_stdio_l2", required=True)
|
|
323
|
+
mcp_stdio_name = mcp_stdio_l2.add_parser(
|
|
324
|
+
"name",
|
|
325
|
+
help="Pick MCP server by declared name",
|
|
326
|
+
description="Must match servers[].name in mcp/<platform>.json for the current OS.",
|
|
327
|
+
)
|
|
328
|
+
mcp_stdio_name.add_argument(
|
|
329
|
+
"mcp_stdio_server_name",
|
|
330
|
+
metavar="SERVER_NAME",
|
|
331
|
+
help="MCP server name from the extension platform MCP file.",
|
|
332
|
+
)
|
|
333
|
+
|
|
301
334
|
return parser
|
|
302
335
|
|
|
303
336
|
|
|
@@ -327,6 +360,9 @@ def _dispatch(args: argparse.Namespace, target_root: Path) -> int:
|
|
|
327
360
|
|
|
328
361
|
_require_init(target_root)
|
|
329
362
|
|
|
363
|
+
if cmd == "mcp_stdio":
|
|
364
|
+
return _dispatch_mcp_stdio(args, target_root)
|
|
365
|
+
|
|
330
366
|
with spawn_lock(target_root):
|
|
331
367
|
if cmd == "rules":
|
|
332
368
|
return _dispatch_rules(args, target_root)
|
|
@@ -343,6 +379,12 @@ def _dispatch(args: argparse.Namespace, target_root: Path) -> int:
|
|
|
343
379
|
return 1
|
|
344
380
|
|
|
345
381
|
|
|
382
|
+
def _dispatch_mcp_stdio(args: argparse.Namespace, target_root: Path) -> int:
|
|
383
|
+
return mcp_stdio.run_mcp_stdio_proxy(
|
|
384
|
+
target_root, args.mcp_stdio_extension_id, args.mcp_stdio_server_name
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
346
388
|
def _dispatch_rules(args: argparse.Namespace, target_root: Path) -> int:
|
|
347
389
|
sub = args.rules_command
|
|
348
390
|
if sub == "refresh":
|
|
@@ -21,7 +21,6 @@ from ruamel.yaml import YAML
|
|
|
21
21
|
from spawn_cli.core import low_level as ll
|
|
22
22
|
from spawn_cli.core.errors import SpawnError, SpawnWarning
|
|
23
23
|
from spawn_cli.core import scripts
|
|
24
|
-
from spawn_cli.io.json_io import load_json
|
|
25
24
|
from spawn_cli.io.paths import ensure_dir, safe_path
|
|
26
25
|
from spawn_cli.io.yaml_io import load_yaml, save_yaml
|
|
27
26
|
from spawn_cli.models.config import ExtensionConfig, ExtensionsMeta, FileMode, SourceYaml
|
|
@@ -128,15 +127,7 @@ def _candidate_norm_skill_names(extsrc_dir: Path) -> list[str]:
|
|
|
128
127
|
|
|
129
128
|
|
|
130
129
|
def _candidate_mcp_server_names(extsrc_dir: Path) -> list[str]:
|
|
131
|
-
|
|
132
|
-
if not path.is_file():
|
|
133
|
-
return []
|
|
134
|
-
data = load_json(path)
|
|
135
|
-
names: list[str] = []
|
|
136
|
-
for s in data.get("servers") or []:
|
|
137
|
-
if isinstance(s, dict) and s.get("name"):
|
|
138
|
-
names.append(str(s["name"]))
|
|
139
|
-
return names
|
|
130
|
+
return ll.extsrc_mcp_server_names_for_staging(extsrc_dir)
|
|
140
131
|
|
|
141
132
|
|
|
142
133
|
def _validate_render_identity_for_new_extension(target_root: Path, new_name: str, extsrc_dir: Path) -> None:
|
|
@@ -10,7 +10,7 @@ from spawn_cli.core import low_level as ll
|
|
|
10
10
|
from spawn_cli.core import scripts
|
|
11
11
|
from spawn_cli.core.errors import SpawnError, SpawnWarning
|
|
12
12
|
from spawn_cli.ide.registry import IdeCapabilities, get as ide_get
|
|
13
|
-
from spawn_cli.io.json_io import
|
|
13
|
+
from spawn_cli.io.json_io import save_json
|
|
14
14
|
from spawn_cli.io.paths import ensure_dir, safe_path
|
|
15
15
|
from spawn_cli.io.yaml_io import load_yaml, save_yaml
|
|
16
16
|
from spawn_cli.models.config import ExtensionConfig, ReadFlag
|
|
@@ -417,6 +417,16 @@ def extension_healthcheck(target_root: Path, extension: str) -> bool:
|
|
|
417
417
|
return scripts.run_healthcheck_scripts(target_root, extension)
|
|
418
418
|
|
|
419
419
|
|
|
420
|
+
def _ensure_empty_mcp_platform_files(extsrc: Path) -> None:
|
|
421
|
+
mdir = extsrc / "mcp"
|
|
422
|
+
ensure_dir(mdir)
|
|
423
|
+
empty: dict = {"servers": []}
|
|
424
|
+
for stem in ("windows", "linux", "macos"):
|
|
425
|
+
p = mdir / f"{stem}.json"
|
|
426
|
+
if not p.is_file():
|
|
427
|
+
save_json(p, empty)
|
|
428
|
+
|
|
429
|
+
|
|
420
430
|
def extension_init(path: Path, name: str) -> None:
|
|
421
431
|
extsrc = path / "extsrc"
|
|
422
432
|
cfg_path = extsrc / "config.yaml"
|
|
@@ -429,6 +439,7 @@ def extension_init(path: Path, name: str) -> None:
|
|
|
429
439
|
ensure_dir(extsrc / "skills")
|
|
430
440
|
ensure_dir(extsrc / "files")
|
|
431
441
|
ensure_dir(extsrc / "setup")
|
|
442
|
+
_ensure_empty_mcp_platform_files(extsrc)
|
|
432
443
|
template = {
|
|
433
444
|
"name": name,
|
|
434
445
|
"schema": 1,
|
|
@@ -475,15 +486,58 @@ def extension_check(path: Path, strict: bool = False) -> list[str]:
|
|
|
475
486
|
if strict:
|
|
476
487
|
raise SpawnError(msg)
|
|
477
488
|
warnings_out.append(msg)
|
|
478
|
-
|
|
479
|
-
if
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
489
|
+
root_mcp = extsrc / "mcp.json"
|
|
490
|
+
if root_mcp.is_file():
|
|
491
|
+
msg = (
|
|
492
|
+
"obsolete extsrc/mcp.json is not used; remove it and use "
|
|
493
|
+
"extsrc/mcp/windows.json, linux.json, and macos.json only"
|
|
494
|
+
)
|
|
495
|
+
if strict:
|
|
496
|
+
raise SpawnError(msg)
|
|
497
|
+
warnings_out.append(msg)
|
|
498
|
+
mcp_dir = extsrc / "mcp"
|
|
499
|
+
if not mcp_dir.is_dir():
|
|
500
|
+
msg = "missing extsrc/mcp/ directory (expected windows.json, linux.json, macos.json)"
|
|
501
|
+
if strict:
|
|
502
|
+
raise SpawnError(msg)
|
|
503
|
+
warnings_out.append(msg)
|
|
504
|
+
else:
|
|
505
|
+
paths = ll.extension_mcp_platform_json_paths(extsrc)
|
|
506
|
+
if not all(p.is_file() for p in paths):
|
|
507
|
+
missing = [p.name for p in paths if not p.is_file()]
|
|
508
|
+
msg = f"incomplete extsrc/mcp layout (missing {', '.join(missing)})"
|
|
484
509
|
if strict:
|
|
485
|
-
raise SpawnError(msg)
|
|
510
|
+
raise SpawnError(msg)
|
|
486
511
|
warnings_out.append(msg)
|
|
512
|
+
else:
|
|
513
|
+
name_sets: list[frozenset[str]] = []
|
|
514
|
+
for p in paths:
|
|
515
|
+
try:
|
|
516
|
+
nm = ll.normalized_mcp_from_mcp_json_path(p, cfg.name or "extension")
|
|
517
|
+
except Exception as e:
|
|
518
|
+
if strict:
|
|
519
|
+
raise SpawnError(f"MCP file invalid ({p}): {e}") from e
|
|
520
|
+
warnings_out.append(f"MCP file invalid ({p}): {e}")
|
|
521
|
+
name_sets = []
|
|
522
|
+
break
|
|
523
|
+
for srv in nm.servers:
|
|
524
|
+
if srv.spawn_stdio_proxy and srv.transport.type != "stdio":
|
|
525
|
+
msg = (
|
|
526
|
+
f"MCP server {srv.name!r} in {p}: spawn_stdio_proxy requires "
|
|
527
|
+
f"transport.type 'stdio' (got {srv.transport.type!r})"
|
|
528
|
+
)
|
|
529
|
+
if strict:
|
|
530
|
+
raise SpawnError(msg)
|
|
531
|
+
warnings_out.append(msg)
|
|
532
|
+
name_sets.append(frozenset(s.name for s in nm.servers))
|
|
533
|
+
if name_sets and len(set(name_sets)) != 1:
|
|
534
|
+
msg = (
|
|
535
|
+
"MCP server names must match across mcp/windows.json, "
|
|
536
|
+
"linux.json, and macos.json"
|
|
537
|
+
)
|
|
538
|
+
if strict:
|
|
539
|
+
raise SpawnError(msg)
|
|
540
|
+
warnings_out.append(msg)
|
|
487
541
|
if cfg.setup:
|
|
488
542
|
for _phase, rel in (
|
|
489
543
|
("before-install", cfg.setup.before_install),
|
|
@@ -555,6 +609,7 @@ def extension_from_rules(source: str, output_path: Path, name: str, branch: str
|
|
|
555
609
|
)
|
|
556
610
|
ensure_dir(extsrc / "skills")
|
|
557
611
|
ensure_dir(extsrc / "setup")
|
|
612
|
+
_ensure_empty_mcp_platform_files(extsrc)
|
|
558
613
|
finally:
|
|
559
614
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
560
615
|
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
import shutil
|
|
5
|
+
import sys
|
|
5
6
|
import time
|
|
6
7
|
import uuid
|
|
7
8
|
import warnings
|
|
@@ -22,6 +23,8 @@ from spawn_cli.models.config import CoreConfig, ExtensionConfig, FileMode, IdeLi
|
|
|
22
23
|
from spawn_cli.models.mcp import McpCapabilities, McpEnvVar, McpServer, McpTransport, NormalizedMcp
|
|
23
24
|
from spawn_cli.models.skill import SkillFileRef, SkillMetadata, SkillRawInfo
|
|
24
25
|
|
|
26
|
+
MCP_PLATFORM_STEMS: tuple[str, ...] = ("windows", "linux", "macos")
|
|
27
|
+
|
|
25
28
|
CANONICAL_IDE_KEYS: tuple[str, ...] = (
|
|
26
29
|
"cursor",
|
|
27
30
|
"codex",
|
|
@@ -653,14 +656,51 @@ def _parse_mcp_env(env_raw: dict[str, Any]) -> dict[str, McpEnvVar]:
|
|
|
653
656
|
return out
|
|
654
657
|
|
|
655
658
|
|
|
659
|
+
def merged_os_environ_with_mcp_env(
|
|
660
|
+
base: dict[str, str], mcp_env: dict[str, McpEnvVar]
|
|
661
|
+
) -> dict[str, str]:
|
|
662
|
+
out = dict(base)
|
|
663
|
+
for key, spec in mcp_env.items():
|
|
664
|
+
if spec.value is not None:
|
|
665
|
+
out[key] = spec.value
|
|
666
|
+
return out
|
|
667
|
+
|
|
668
|
+
|
|
656
669
|
def _mcp_capabilities(raw: dict[str, Any]) -> McpCapabilities:
|
|
657
670
|
c = raw.get("capabilities") or {}
|
|
658
671
|
return McpCapabilities.model_validate(c) if isinstance(c, dict) else McpCapabilities()
|
|
659
672
|
|
|
660
673
|
|
|
661
|
-
def
|
|
662
|
-
|
|
663
|
-
|
|
674
|
+
def mcp_host_platform_stem() -> str:
|
|
675
|
+
plat = sys.platform
|
|
676
|
+
if plat == "win32":
|
|
677
|
+
return "windows"
|
|
678
|
+
if plat == "darwin":
|
|
679
|
+
return "macos"
|
|
680
|
+
if plat.startswith("linux"):
|
|
681
|
+
return "linux"
|
|
682
|
+
raise SpawnError(
|
|
683
|
+
f"unsupported platform for MCP selection: {plat!r} (expected Windows, Linux, or macOS)"
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def extension_mcp_platform_json_paths(extension_root: Path) -> tuple[Path, Path, Path]:
|
|
688
|
+
d = extension_root / "mcp"
|
|
689
|
+
return tuple(d / f"{s}.json" for s in MCP_PLATFORM_STEMS)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _mcp_spawn_stdio_proxy_from_server(server: dict, path: Path, server_name: str) -> bool:
|
|
693
|
+
if "spawn_stdio_proxy" not in server:
|
|
694
|
+
return False
|
|
695
|
+
v = server["spawn_stdio_proxy"]
|
|
696
|
+
if isinstance(v, bool):
|
|
697
|
+
return v
|
|
698
|
+
raise SpawnError(
|
|
699
|
+
f"spawn_stdio_proxy must be a boolean for server {server_name!r} in {path}"
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _normalized_mcp_from_loaded(data: dict, path: Path, extension: str) -> NormalizedMcp:
|
|
664
704
|
servers_out: list[McpServer] = []
|
|
665
705
|
for s in data.get("servers") or []:
|
|
666
706
|
if not isinstance(s, dict):
|
|
@@ -668,6 +708,8 @@ def list_mcp(target_root: Path, extension: str) -> NormalizedMcp:
|
|
|
668
708
|
nm = s.get("name")
|
|
669
709
|
if nm is None:
|
|
670
710
|
raise SpawnError(f"missing MCP server name in {path}")
|
|
711
|
+
str_name = str(nm)
|
|
712
|
+
spawn_proxy = _mcp_spawn_stdio_proxy_from_server(s, path, str_name)
|
|
671
713
|
tr_raw = s.get("transport") or {}
|
|
672
714
|
transport = McpTransport(
|
|
673
715
|
type=str(tr_raw.get("type", "stdio")),
|
|
@@ -680,9 +722,10 @@ def list_mcp(target_root: Path, extension: str) -> NormalizedMcp:
|
|
|
680
722
|
env = _parse_mcp_env(env_in) if isinstance(env_in, dict) else {}
|
|
681
723
|
servers_out.append(
|
|
682
724
|
McpServer(
|
|
683
|
-
name=
|
|
725
|
+
name=str_name,
|
|
684
726
|
extension=extension,
|
|
685
727
|
transport=transport,
|
|
728
|
+
spawn_stdio_proxy=spawn_proxy,
|
|
686
729
|
env=env,
|
|
687
730
|
capabilities=_mcp_capabilities(s),
|
|
688
731
|
)
|
|
@@ -690,6 +733,49 @@ def list_mcp(target_root: Path, extension: str) -> NormalizedMcp:
|
|
|
690
733
|
return NormalizedMcp(servers=servers_out)
|
|
691
734
|
|
|
692
735
|
|
|
736
|
+
def normalized_mcp_from_mcp_json_path(path: Path, extension: str) -> NormalizedMcp:
|
|
737
|
+
data = load_json(path)
|
|
738
|
+
return _normalized_mcp_from_loaded(data, path, extension)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def list_mcp(target_root: Path, extension: str) -> NormalizedMcp:
|
|
742
|
+
ext_root = _extend_root(target_root) / extension
|
|
743
|
+
mcp_dir = ext_root / "mcp"
|
|
744
|
+
if not mcp_dir.is_dir():
|
|
745
|
+
return NormalizedMcp(servers=[])
|
|
746
|
+
stem = mcp_host_platform_stem()
|
|
747
|
+
path = mcp_dir / f"{stem}.json"
|
|
748
|
+
if not path.is_file():
|
|
749
|
+
raise SpawnError(f"missing MCP platform file for this OS: {path}")
|
|
750
|
+
return normalized_mcp_from_mcp_json_path(path, extension)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def extsrc_mcp_server_names_for_staging(extsrc_dir: Path) -> list[str]:
|
|
754
|
+
"""Return sorted MCP server names from extsrc ``mcp/*.json`` or [] if ``mcp/`` is absent.
|
|
755
|
+
|
|
756
|
+
When ``mcp/`` exists, all three platform files must be present and declare the same
|
|
757
|
+
server ``name`` set.
|
|
758
|
+
"""
|
|
759
|
+
mcp_dir = extsrc_dir / "mcp"
|
|
760
|
+
if not mcp_dir.is_dir():
|
|
761
|
+
return []
|
|
762
|
+
paths = list(extension_mcp_platform_json_paths(extsrc_dir))
|
|
763
|
+
if not all(p.is_file() for p in paths):
|
|
764
|
+
missing = [p.name for p in paths if not p.is_file()]
|
|
765
|
+
raise SpawnError(
|
|
766
|
+
f"extsrc/mcp layout incomplete (missing {', '.join(missing)}) under {mcp_dir}"
|
|
767
|
+
)
|
|
768
|
+
name_sets: list[frozenset[str]] = []
|
|
769
|
+
for p in paths:
|
|
770
|
+
nm = normalized_mcp_from_mcp_json_path(p, "_staging")
|
|
771
|
+
name_sets.append(frozenset(s.name for s in nm.servers))
|
|
772
|
+
if len(set(name_sets)) != 1:
|
|
773
|
+
raise SpawnError(
|
|
774
|
+
"MCP server names must be identical across mcp/windows.json, linux.json, macos.json"
|
|
775
|
+
)
|
|
776
|
+
return sorted(name_sets[0])
|
|
777
|
+
|
|
778
|
+
|
|
693
779
|
def get_navigation_metadata(target_root: Path, extension: str) -> dict:
|
|
694
780
|
return {
|
|
695
781
|
"required": [x.model_dump(by_alias=True) for x in get_required_read_global(target_root, extension)],
|
|
@@ -1205,14 +1291,18 @@ __all__ = [
|
|
|
1205
1291
|
"get_required_read_global_all",
|
|
1206
1292
|
"get_skill_raw_info",
|
|
1207
1293
|
"init",
|
|
1294
|
+
"extsrc_mcp_server_names_for_staging",
|
|
1295
|
+
"extension_mcp_platform_json_paths",
|
|
1208
1296
|
"list_extensions",
|
|
1209
1297
|
"list_ides",
|
|
1210
1298
|
"list_mcp",
|
|
1211
1299
|
"list_skills",
|
|
1300
|
+
"mcp_host_platform_stem",
|
|
1212
1301
|
"sync_core_config_from_defaults",
|
|
1213
1302
|
"merge_core_and_extension_agent_ignore",
|
|
1214
1303
|
"METADATA_TEMP_MAX_AGE_SECONDS",
|
|
1215
1304
|
"normalize_skill_name",
|
|
1305
|
+
"normalized_mcp_from_mcp_json_path",
|
|
1216
1306
|
"prune_metadata_temp",
|
|
1217
1307
|
"push_to_global_gitignore",
|
|
1218
1308
|
"remove_from_global_gitignore",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Runtime stdio MCP proxy: resolve server from platform JSON and exec inner command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from spawn_cli.core.errors import SpawnError
|
|
11
|
+
from spawn_cli.core.low_level import list_extensions, list_mcp, merged_os_environ_with_mcp_env
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_mcp_stdio_proxy(target_root: Path, extension_id: str, server_name: str) -> int:
|
|
15
|
+
if extension_id not in list_extensions(target_root):
|
|
16
|
+
raise SpawnError(
|
|
17
|
+
f"unknown extension {extension_id!r}: install it under spawn/.extend/<id>/ "
|
|
18
|
+
f"(see: spawn extension list)."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
nm = list_mcp(target_root, extension_id)
|
|
22
|
+
srv = next((s for s in nm.servers if s.name == server_name), None)
|
|
23
|
+
existing = [s.name for s in nm.servers]
|
|
24
|
+
if srv is None:
|
|
25
|
+
raise SpawnError(
|
|
26
|
+
f"unknown MCP server {server_name!r} for extension {extension_id!r}; "
|
|
27
|
+
f"declared servers: {existing}"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if not srv.spawn_stdio_proxy:
|
|
31
|
+
raise SpawnError(
|
|
32
|
+
f"MCP server {server_name!r} in extension {extension_id!r} is not enabled for "
|
|
33
|
+
f"stdio proxy: set \"spawn_stdio_proxy\": true on that server in "
|
|
34
|
+
f"extsrc/mcp/*.json (then reinstall or refresh)."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if srv.transport.type != "stdio":
|
|
38
|
+
raise SpawnError(
|
|
39
|
+
f"MCP server {server_name!r} uses transport type {srv.transport.type!r}; "
|
|
40
|
+
f"spawn mcp_stdio requires type \"stdio\"."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
cmd = srv.transport.command
|
|
44
|
+
if not cmd:
|
|
45
|
+
raise SpawnError(
|
|
46
|
+
f"MCP server {server_name!r} has no transport.command; "
|
|
47
|
+
f"fix stdio transport in extension MCP JSON."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
argv = [cmd, *srv.transport.args]
|
|
51
|
+
cwd_path = (target_root / srv.transport.cwd).resolve()
|
|
52
|
+
child_env = merged_os_environ_with_mcp_env(os.environ, srv.env)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
with subprocess.Popen(
|
|
56
|
+
argv,
|
|
57
|
+
cwd=str(cwd_path),
|
|
58
|
+
env=child_env,
|
|
59
|
+
stdin=sys.stdin.buffer,
|
|
60
|
+
stdout=sys.stdout.buffer,
|
|
61
|
+
stderr=None,
|
|
62
|
+
shell=False,
|
|
63
|
+
bufsize=0,
|
|
64
|
+
) as proc:
|
|
65
|
+
return int(proc.wait())
|
|
66
|
+
except OSError as e:
|
|
67
|
+
raise SpawnError(f"failed to start MCP server {server_name!r}: {e}") from e
|
|
@@ -14,6 +14,7 @@ from .codex import CodexAdapter
|
|
|
14
14
|
from .cursor import CursorAdapter
|
|
15
15
|
from .gemini_cli import GeminiCliAdapter
|
|
16
16
|
from .github_copilot import GitHubCopilotAdapter
|
|
17
|
+
from .mcp_stdio_argv import mcp_stdio_argv
|
|
17
18
|
from .windsurf import WindsurfAdapter
|
|
18
19
|
from .registry import (
|
|
19
20
|
ALIASES,
|
|
@@ -55,6 +56,7 @@ __all__ = [
|
|
|
55
56
|
"IGNORE_BLOCK_START",
|
|
56
57
|
"IdeAdapter",
|
|
57
58
|
"IdeCapabilities",
|
|
59
|
+
"mcp_stdio_argv",
|
|
58
60
|
"SPAWN_BLOCK_END",
|
|
59
61
|
"SPAWN_BLOCK_START",
|
|
60
62
|
"StubAdapter",
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
from spawn_cli.core import low_level as ll
|
|
10
10
|
from spawn_cli.ide import _vacancy as _vac
|
|
11
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
11
12
|
from spawn_cli.ide.registry import (
|
|
12
13
|
DetectResult,
|
|
13
14
|
IdeAdapter,
|
|
@@ -24,7 +25,10 @@ from spawn_cli.models.skill import SkillMetadata
|
|
|
24
25
|
def _build_generic_mcp_entry(server: McpServer) -> dict:
|
|
25
26
|
transport = server.transport
|
|
26
27
|
if transport.type == "stdio":
|
|
27
|
-
|
|
28
|
+
if server.spawn_stdio_proxy:
|
|
29
|
+
entry = {"command": "spawn", "args": mcp_stdio_argv(server.extension, server.name)}
|
|
30
|
+
else:
|
|
31
|
+
entry = {"command": transport.command, "args": list(transport.args)}
|
|
28
32
|
elif transport.type in ("streamable-http", "sse"):
|
|
29
33
|
entry = {"type": transport.type, "url": transport.url}
|
|
30
34
|
if transport.headers:
|
|
@@ -6,6 +6,7 @@ import warnings
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from spawn_cli.ide import _vacancy as _vac
|
|
9
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
9
10
|
from spawn_cli.ide.registry import (
|
|
10
11
|
DetectResult,
|
|
11
12
|
IdeAdapter,
|
|
@@ -24,8 +25,12 @@ def _build_toml_server_entry(server: McpServer) -> dict:
|
|
|
24
25
|
transport = server.transport
|
|
25
26
|
entry: dict = {}
|
|
26
27
|
if transport.type == "stdio":
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
if server.spawn_stdio_proxy:
|
|
29
|
+
entry["command"] = "spawn"
|
|
30
|
+
entry["args"] = mcp_stdio_argv(server.extension, server.name)
|
|
31
|
+
else:
|
|
32
|
+
entry["command"] = transport.command
|
|
33
|
+
entry["args"] = list(transport.args)
|
|
29
34
|
else:
|
|
30
35
|
entry["url"] = transport.url
|
|
31
36
|
if server.env:
|
|
@@ -7,6 +7,7 @@ import warnings
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from spawn_cli.ide import _vacancy as _vac
|
|
10
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
10
11
|
from spawn_cli.ide._helpers import (
|
|
11
12
|
clear_split_agent_ignore_file,
|
|
12
13
|
remove_ignore_block,
|
|
@@ -31,7 +32,10 @@ def _build_mcp_server_entry(server: McpServer) -> dict:
|
|
|
31
32
|
"""Convert McpServer to .cursor/mcp.json entry (mcpServers format)."""
|
|
32
33
|
transport = server.transport
|
|
33
34
|
if transport.type == "stdio":
|
|
34
|
-
|
|
35
|
+
if server.spawn_stdio_proxy:
|
|
36
|
+
entry = {"command": "spawn", "args": mcp_stdio_argv(server.extension, server.name)}
|
|
37
|
+
else:
|
|
38
|
+
entry = {"command": transport.command, "args": transport.args}
|
|
35
39
|
elif transport.type in ("streamable-http", "sse"):
|
|
36
40
|
entry = {"type": transport.type, "url": transport.url}
|
|
37
41
|
if transport.headers:
|
|
@@ -7,6 +7,7 @@ import warnings
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from spawn_cli.ide import _vacancy as _vac
|
|
10
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
10
11
|
from spawn_cli.ide._helpers import (
|
|
11
12
|
clear_split_agent_ignore_file,
|
|
12
13
|
remove_ignore_block,
|
|
@@ -31,8 +32,12 @@ def _build_gemini_mcp_entry(server: McpServer) -> dict:
|
|
|
31
32
|
transport = server.transport
|
|
32
33
|
entry: dict = {}
|
|
33
34
|
if transport.type == "stdio":
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
if server.spawn_stdio_proxy:
|
|
36
|
+
entry["command"] = "spawn"
|
|
37
|
+
entry["args"] = mcp_stdio_argv(server.extension, server.name)
|
|
38
|
+
else:
|
|
39
|
+
entry["command"] = transport.command
|
|
40
|
+
entry["args"] = list(transport.args)
|
|
36
41
|
elif transport.type == "streamable-http":
|
|
37
42
|
entry["httpUrl"] = transport.url or ""
|
|
38
43
|
if transport.headers:
|
|
@@ -7,6 +7,7 @@ import warnings
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from spawn_cli.ide import _vacancy as _vac
|
|
10
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
10
11
|
from spawn_cli.ide.registry import (
|
|
11
12
|
DetectResult,
|
|
12
13
|
IdeAdapter,
|
|
@@ -31,8 +32,12 @@ def _build_vscode_mcp_entry(server: McpServer) -> tuple[dict, list[dict]]:
|
|
|
31
32
|
|
|
32
33
|
if transport.type == "stdio":
|
|
33
34
|
entry["type"] = "stdio"
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
if server.spawn_stdio_proxy:
|
|
36
|
+
entry["command"] = "spawn"
|
|
37
|
+
entry["args"] = mcp_stdio_argv(server.extension, server.name)
|
|
38
|
+
else:
|
|
39
|
+
entry["command"] = transport.command
|
|
40
|
+
entry["args"] = list(transport.args)
|
|
36
41
|
else:
|
|
37
42
|
entry["type"] = transport.type
|
|
38
43
|
entry["url"] = transport.url
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Shared argv list for IDE MCP configs that launch ``spawn mcp_stdio``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def mcp_stdio_argv(extension_id: str, server_name: str) -> list[str]:
|
|
7
|
+
return ["mcp_stdio", "extension", extension_id, "name", server_name]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = ["mcp_stdio_argv"]
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Optional
|
|
4
4
|
|
|
5
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictBool
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class McpEnvVar(BaseModel):
|
|
@@ -39,6 +39,7 @@ class McpServer(BaseModel):
|
|
|
39
39
|
name: str
|
|
40
40
|
extension: str
|
|
41
41
|
transport: McpTransport
|
|
42
|
+
spawn_stdio_proxy: StrictBool = False
|
|
42
43
|
env: dict[str, McpEnvVar] = Field(default_factory=dict)
|
|
43
44
|
capabilities: McpCapabilities = Field(default_factory=McpCapabilities)
|
|
44
45
|
|
|
@@ -16,6 +16,7 @@ src/spawn_cli/core/download.py
|
|
|
16
16
|
src/spawn_cli/core/errors.py
|
|
17
17
|
src/spawn_cli/core/high_level.py
|
|
18
18
|
src/spawn_cli/core/low_level.py
|
|
19
|
+
src/spawn_cli/core/mcp_stdio.py
|
|
19
20
|
src/spawn_cli/core/scripts.py
|
|
20
21
|
src/spawn_cli/ide/__init__.py
|
|
21
22
|
src/spawn_cli/ide/_helpers.py
|
|
@@ -26,6 +27,7 @@ src/spawn_cli/ide/codex.py
|
|
|
26
27
|
src/spawn_cli/ide/cursor.py
|
|
27
28
|
src/spawn_cli/ide/gemini_cli.py
|
|
28
29
|
src/spawn_cli/ide/github_copilot.py
|
|
30
|
+
src/spawn_cli/ide/mcp_stdio_argv.py
|
|
29
31
|
src/spawn_cli/ide/registry.py
|
|
30
32
|
src/spawn_cli/ide/windsurf.py
|
|
31
33
|
src/spawn_cli/io/__init__.py
|
|
@@ -349,3 +349,67 @@ def test_spawn_error_exit_code(tmp_path, monkeypatch, capsys):
|
|
|
349
349
|
assert main(["rules", "refresh"]) == 1
|
|
350
350
|
|
|
351
351
|
assert "planned failure" in capsys.readouterr().err
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@contextlib.contextmanager
|
|
355
|
+
def _lock_must_not_be_used(_target_root: Path):
|
|
356
|
+
raise AssertionError("spawn_lock must not be used for mcp_stdio")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def test_spawn_mcp_stdio_parses_nested_argv_fragments(tmp_path, monkeypatch):
|
|
360
|
+
"""Argv may arrive shell-fragmented; argparse still sees one token list."""
|
|
361
|
+
monkeypatch.chdir(tmp_path)
|
|
362
|
+
from spawn_cli.core import low_level as ll
|
|
363
|
+
|
|
364
|
+
ll.init(tmp_path)
|
|
365
|
+
root = tmp_path.resolve()
|
|
366
|
+
mock_run = MagicMock(return_value=0)
|
|
367
|
+
pieces = (["mcp_stdio", "extension"], ["myext"], ["name"], ["srv_name"])
|
|
368
|
+
argv = [tok for part in pieces for tok in part]
|
|
369
|
+
|
|
370
|
+
with patch("spawn_cli.cli.spawn_lock", _lock_must_not_be_used), patch(
|
|
371
|
+
"spawn_cli.cli.mcp_stdio.run_mcp_stdio_proxy", mock_run
|
|
372
|
+
):
|
|
373
|
+
assert main(argv) == 0
|
|
374
|
+
|
|
375
|
+
mock_run.assert_called_once_with(root, "myext", "srv_name")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_spawn_mcp_stdio_never_calls_spawn_lock_factory(tmp_path, monkeypatch):
|
|
379
|
+
monkeypatch.chdir(tmp_path)
|
|
380
|
+
from spawn_cli.core import low_level as ll
|
|
381
|
+
|
|
382
|
+
ll.init(tmp_path)
|
|
383
|
+
root = tmp_path.resolve()
|
|
384
|
+
lock_factory = MagicMock()
|
|
385
|
+
mock_run = MagicMock(return_value=0)
|
|
386
|
+
with patch("spawn_cli.cli.spawn_lock", lock_factory), patch(
|
|
387
|
+
"spawn_cli.cli.mcp_stdio.run_mcp_stdio_proxy", mock_run
|
|
388
|
+
):
|
|
389
|
+
assert main(["mcp_stdio", "extension", "e1", "name", "n1"]) == 0
|
|
390
|
+
|
|
391
|
+
lock_factory.assert_not_called()
|
|
392
|
+
mock_run.assert_called_once_with(root, "e1", "n1")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_spawn_mcp_stdio_parses_nested_argv(tmp_path, monkeypatch):
|
|
396
|
+
monkeypatch.chdir(tmp_path)
|
|
397
|
+
(tmp_path / "spawn").mkdir()
|
|
398
|
+
root = tmp_path.resolve()
|
|
399
|
+
mock_run = MagicMock(return_value=0)
|
|
400
|
+
|
|
401
|
+
with patch("spawn_cli.cli.spawn_lock", _lock_must_not_be_used), patch(
|
|
402
|
+
"spawn_cli.cli.mcp_stdio.run_mcp_stdio_proxy", mock_run
|
|
403
|
+
):
|
|
404
|
+
assert main(["mcp_stdio", "extension", "myext", "name", "srv"]) == 0
|
|
405
|
+
|
|
406
|
+
mock_run.assert_called_once_with(root, "myext", "srv")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_spawn_mcp_stdio_help(capsys):
|
|
410
|
+
with pytest.raises(SystemExit) as exc:
|
|
411
|
+
main(["mcp_stdio", "--help"])
|
|
412
|
+
assert exc.value.code == 0
|
|
413
|
+
out = capsys.readouterr().out
|
|
414
|
+
assert "mcp_stdio" in out
|
|
415
|
+
assert "repository lock" in out.lower() or "lock" in out.lower()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|