kctl-mcp 0.2.0__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.
@@ -0,0 +1,39 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ .eggs/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # IDE
15
+ .idea/
16
+ .vscode/
17
+ *.swp
18
+ *.swo
19
+
20
+ # Testing
21
+ .pytest_cache/
22
+ .coverage
23
+ htmlcov/
24
+ .mypy_cache/
25
+ .ruff_cache/
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Environment
32
+ .env
33
+ .env.local
34
+
35
+ # Agent memory (claude-mem regenerates AGENTS.md locally; not a committed guide)
36
+ AGENTS.md
37
+
38
+ # kctl-agent container render output (derived from agents.toml)
39
+ out/
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.5
2
+ Name: kctl-mcp
3
+ Version: 0.2.0
4
+ Summary: Turn any kctl-* Typer CLI into a curated MCP server, with no per-CLI code
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: kctl-agent>=0.1.0
7
+ Requires-Dist: kctl-lib>=0.14.0
8
+ Requires-Dist: mcp>=1.0
9
+ Requires-Dist: rich>=13.0
10
+ Requires-Dist: typer>=0.9.0
@@ -0,0 +1,119 @@
1
+ # kctl-mcp
2
+
3
+ Turn any `kctl-*` CLI into an MCP server, with **no per-CLI code**.
4
+
5
+ Every kctl CLI already emits complete per-parameter schemas via
6
+ `commands tree --json` — opts, type, requiredness, default, choices, flag-ness. That is a
7
+ tool definition. This package is the translation, plus the restraint that makes it safe.
8
+
9
+ ## The design work is restraint, not the bridge
10
+
11
+ `kctl-odoo` has **1,179 commands**. `kctl-dokploy` has 283. A naive bridge would expose all
12
+ of them, including `applications delete`, and would degrade tool selection for every other
13
+ server in the session.
14
+
15
+ Two rules prevent that:
16
+
17
+ 1. **Allow-list, not deny-list.** Nothing is exposed until a pattern names it. `deny` is a
18
+ second net, so widening an `expose` pattern by accident still cannot surface a destructive
19
+ command.
20
+ 2. **The profile binds at launch, never as a tool argument.** A model that can pass
21
+ `--profile` can reach production. This is the MCP form of the fleet's no-default-profile
22
+ rule, and it is enforced in three places: the parameter is stripped from every generated
23
+ schema, a model-supplied `--profile` flag is filtered out of argv, and `MCP-003` fails a
24
+ projection that does not declare `profile_from`.
25
+
26
+ ## Two modes
27
+
28
+ | Mode | Shape | Use for |
29
+ |---|---|---|
30
+ | `tools` | one MCP tool per exposed command | curated surfaces, ≤30 tools |
31
+ | `gateway` | exactly two tools: `<name>_discover(keyword)` and `<name>_run(command, args)` | large CLIs — all 1,179 Odoo commands stay reachable without 1,179 schemas in context |
32
+
33
+ Gateway mode mirrors the two-call pattern the fleet already documents for analytics, and it
34
+ never introspects at startup.
35
+
36
+ ## A projection
37
+
38
+ ```toml
39
+ # mcp/odoo.toml
40
+ name = "odoo"
41
+ cli = "kctl-odoo"
42
+ mode = "gateway"
43
+ transport = "stdio"
44
+
45
+ expose = ["analyze *", "kpi *", "dashboard *", "report quick", "report list", "doctor *"]
46
+ deny = ["*delete*", "*drop*", "*deploy*", "*restore*", "*apply*", "*push*", "*write*"]
47
+
48
+ profile_from = "env:KCTL_ODOO_PROFILE"
49
+ ```
50
+
51
+ ## Commands
52
+
53
+ | Command | Purpose |
54
+ |---------|---------|
55
+ | `kctl-mcp list` | The projections this repo declares. |
56
+ | `kctl-mcp tools <name>` | The tools a projection would expose — without starting a server. |
57
+ | `kctl-mcp audit <name>` | Which of a CLI's commands are exposed, and how many are hidden. |
58
+ | `kctl-mcp schema <name>` | Raw MCP tool schemas, for debugging a client that mis-parses them. |
59
+ | `kctl-mcp serve <name>` | Serve over stdio. This is what an MCP client launches. |
60
+
61
+ `audit` is the review tool: it prints the exposed set against the total, so a projection can
62
+ be checked at a glance.
63
+
64
+ ```
65
+ $ kctl-mcp --json audit odoo
66
+ total=1179 exposed=30 hidden=1149 over_budget=False
67
+ ```
68
+
69
+ ## Wiring a client
70
+
71
+ ```json
72
+ {
73
+ "mcpServers": {
74
+ "odoo": {
75
+ "command": "kctl-mcp",
76
+ "args": ["serve", "odoo"],
77
+ "env": { "KCTL_ODOO_PROFILE": "idtpp-tpp-odoo-erp" }
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ `kctl-agent` manages these entries; see `agents.toml`.
84
+
85
+ ## Safety
86
+
87
+ Every guard lives in `invoke.py`, because that is the only place model input becomes a
88
+ process:
89
+
90
+ - the command path is re-checked against the projection on every call, never trusted from the
91
+ tool arguments — this matters most in gateway mode, where the model supplies the path;
92
+ - a command path is a whitelist of `[a-z0-9-_ ]`, so `;`, `|`, `&&`, `$(…)` and `..` are
93
+ refused outright;
94
+ - arguments are passed as an argv list with `shell=False`, so a hostile value stays one
95
+ element;
96
+ - `--force`, `--yes`, `-y` and `--no-backup` are stripped, with their values;
97
+ - arguments the model invents but the schema never declared are dropped;
98
+ - output is capped at 60,000 characters so a runaway report cannot fill the context window;
99
+ - subprocesses run with `KCTL_NO_UPDATE_CHECK=1`, because kctl-lib's update notifier writes
100
+ to *stdout* and corrupts any JSON a caller is parsing.
101
+
102
+ ## Governance
103
+
104
+ Projections are governed by the `MCP-*` rule family in `kctl-agent`, which runs in the same
105
+ `just conform` ratchet as everything else:
106
+
107
+ ```bash
108
+ kctl-agent lint # MCP-001..006 over every mcp/*.toml
109
+ kctl-conform explain MCP-003
110
+ ```
111
+
112
+ ## Testing
113
+
114
+ ```bash
115
+ uv run pytest packages/kctl-mcp/tests/ -v
116
+ ```
117
+
118
+ No test executes a real CLI or opens a socket; the Typer tree is a fixture shaped exactly
119
+ like real introspection output.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kctl-mcp"
7
+ version = "0.2.0"
8
+ description = "Turn any kctl-* Typer CLI into a curated MCP server, with no per-CLI code"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "kctl-lib>=0.14.0",
12
+ "kctl-agent>=0.1.0",
13
+ "typer>=0.9.0",
14
+ "rich>=13.0",
15
+ "mcp>=1.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ kctl-mcp = "kctl_mcp.cli:_run"
20
+
21
+ [tool.uv.sources]
22
+ kctl-lib = { workspace = true }
23
+ kctl-agent = { workspace = true }
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/kctl_mcp"]
27
+
28
+ [tool.kctl-conform]
29
+ kind = "meta"
30
+ # A protocol bridge, not a service CLI:
31
+ # AFFORD-001 = no global --json/--profile; the profile binds at server launch
32
+ # and is deliberately NOT a runtime option (see MCP-005).
33
+ # AFFORD-004 = no `doctor ai-summary`; kctl-agent doctor covers MCP health.
34
+ # LAYOUT-006 = no core/client.py; it introspects local Typer apps.
35
+ # LAYOUT-003 = five flat commands live in a 165-line cli.py, well inside the
36
+ # SIZE-001 budget; a commands/ package would be one module per
37
+ # function. Same call as kctl-skill.
38
+ exempt = [
39
+ "AFFORD-001",
40
+ "AFFORD-004",
41
+ "LAYOUT-003",
42
+ "LAYOUT-006",
43
+ ]
@@ -0,0 +1,5 @@
1
+ """kctl-mcp — a curated MCP projection of any kctl-* CLI."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,165 @@
1
+ """kctl-mcp — serve any kctl-* CLI as a curated MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from kctl_lib import cli_entrypoint, register_introspection_commands
11
+
12
+ from . import __version__
13
+ from .core.callbacks import AppContext
14
+ from .core.config import find_repo_root
15
+ from .projection import MAX_TOOLS, discover_projections, load_projection
16
+ from .schema import introspect, walk_commands
17
+ from .server import load_tools
18
+
19
+ app = typer.Typer(
20
+ name="kctl-mcp",
21
+ help="Serve any kctl-* CLI as a curated MCP server.",
22
+ no_args_is_help=True,
23
+ )
24
+
25
+
26
+ def _version_callback(value: bool) -> None:
27
+ if value:
28
+ typer.echo(f"kctl-mcp {__version__}")
29
+ raise typer.Exit()
30
+
31
+
32
+ @app.callback()
33
+ def main(
34
+ ctx: typer.Context,
35
+ json_output: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
36
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress non-essential output.")] = False,
37
+ version: Annotated[
38
+ bool,
39
+ typer.Option("--version", "-V", callback=_version_callback, is_eager=True, help="Show version."),
40
+ ] = False,
41
+ ) -> None:
42
+ """Root callback."""
43
+ ctx.obj = AppContext(json_mode=json_output, quiet=quiet, format="json" if json_output else "pretty")
44
+
45
+
46
+ def _projection(name: str, root: Path): # type: ignore[no-untyped-def]
47
+ candidate = Path(name)
48
+ return load_projection(candidate if candidate.is_file() else root / "mcp" / f"{name}.toml")
49
+
50
+
51
+ @app.command("list")
52
+ def list_cmd(ctx: typer.Context) -> None:
53
+ """List the projections this repo declares."""
54
+ actx: AppContext = ctx.obj
55
+ out = actx.output
56
+ root = find_repo_root()
57
+ projections = discover_projections(root)
58
+
59
+ if actx.json_mode:
60
+ out.raw_json(
61
+ [{"name": p.name, "cli": p.cli, "mode": p.mode, "expose": p.expose, "deny": p.deny} for p in projections]
62
+ )
63
+ return
64
+ columns = [("name", "cyan"), ("cli", "white"), ("mode", "dim"), ("exposed patterns", "dim")]
65
+ rows = [[p.name, p.cli, p.mode, ", ".join(p.expose) or "(nothing)"] for p in projections]
66
+ out.table("MCP projections", columns, rows)
67
+
68
+
69
+ @app.command("tools")
70
+ def tools_cmd(
71
+ ctx: typer.Context,
72
+ name: Annotated[str, typer.Argument(help="Projection name or path to an mcp/*.toml.")],
73
+ ) -> None:
74
+ """Show the tools a projection would expose, without starting a server."""
75
+ actx: AppContext = ctx.obj
76
+ out = actx.output
77
+ projection = _projection(name, find_repo_root())
78
+ tools = load_tools(projection)
79
+
80
+ if actx.json_mode:
81
+ out.raw_json(
82
+ [
83
+ {
84
+ "name": t.name,
85
+ "description": t.description,
86
+ "input_schema": t.input_schema,
87
+ "command": t.command_path,
88
+ }
89
+ for t in tools
90
+ ]
91
+ )
92
+ return
93
+
94
+ columns = [("tool", "cyan"), ("command", "white"), ("description", "dim")]
95
+ rows = [[t.name, t.command_path, (t.description or "")[:70]] for t in tools]
96
+ out.table(f"{projection.name} ({projection.mode} mode)", columns, rows)
97
+ if not actx.quiet:
98
+ typer.echo(f"\n{len(tools)} tool(s); budget {MAX_TOOLS}")
99
+
100
+
101
+ @app.command("audit")
102
+ def audit_cmd(
103
+ ctx: typer.Context,
104
+ name: Annotated[str, typer.Argument(help="Projection name.")],
105
+ ) -> None:
106
+ """Show which of a CLI's commands the projection exposes, and which it hides."""
107
+ actx: AppContext = ctx.obj
108
+ out = actx.output
109
+ projection = _projection(name, find_repo_root())
110
+ paths = [p for p, _ in walk_commands(introspect(projection.cli)) if not p.startswith("commands")]
111
+ exposed = [p for p in paths if projection.allows(p)]
112
+ hidden = [p for p in paths if not projection.allows(p)]
113
+
114
+ payload = {
115
+ "cli": projection.cli,
116
+ "mode": projection.mode,
117
+ "total_commands": len(paths),
118
+ "exposed": exposed,
119
+ "hidden_count": len(hidden),
120
+ "over_budget": len(exposed) > MAX_TOOLS and projection.mode == "tools",
121
+ }
122
+ if actx.json_mode:
123
+ out.raw_json(payload)
124
+ return
125
+ typer.echo(f"{projection.cli}: {len(paths)} command(s), {len(exposed)} exposed, {len(hidden)} hidden")
126
+ for path in exposed:
127
+ typer.echo(f" + {path}")
128
+ if payload["over_budget"]:
129
+ out.warn(f'{len(exposed)} tools exceeds the budget of {MAX_TOOLS}; use mode = "gateway"')
130
+
131
+
132
+ @app.command("serve")
133
+ def serve_cmd(
134
+ name: Annotated[str, typer.Argument(help="Projection name or path to an mcp/*.toml.")],
135
+ ) -> None:
136
+ """Serve a projection over stdio. This is what an MCP client launches."""
137
+ import anyio
138
+
139
+ from .server import serve
140
+
141
+ projection = _projection(name, find_repo_root())
142
+ anyio.run(serve, projection)
143
+
144
+
145
+ @app.command("schema")
146
+ def schema_cmd(
147
+ name: Annotated[str, typer.Argument(help="Projection name.")],
148
+ ) -> None:
149
+ """Print the raw MCP tool schemas, for debugging a client that mis-parses them."""
150
+ projection = _projection(name, find_repo_root())
151
+ tools = load_tools(projection)
152
+ typer.echo(
153
+ _json.dumps(
154
+ [{"name": t.name, "description": t.description, "inputSchema": t.input_schema} for t in tools],
155
+ indent=2,
156
+ )
157
+ )
158
+
159
+
160
+ register_introspection_commands(app)
161
+ app = cli_entrypoint(app)
162
+
163
+
164
+ def _run() -> None:
165
+ app()
File without changes
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from kctl_lib import AppContextBase
7
+
8
+
9
+ @dataclass
10
+ class AppContext(AppContextBase):
11
+ """Typer context for kctl-mcp."""
12
+
13
+ root: Path | None = None
@@ -0,0 +1,18 @@
1
+ """Workspace root resolution for the MCP bridge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .exceptions import McpError
8
+
9
+ _WORKSPACE_MARKER = "[tool.uv.workspace]"
10
+
11
+
12
+ def find_repo_root(start: Path | None = None) -> Path:
13
+ current = (start or Path.cwd()).resolve()
14
+ for candidate in (current, *current.parents):
15
+ pyproject = candidate / "pyproject.toml"
16
+ if pyproject.is_file() and _WORKSPACE_MARKER in pyproject.read_text(encoding="utf-8"):
17
+ return candidate
18
+ raise McpError(f"no uv workspace root found from {current}")
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from kctl_lib import KctlError
4
+
5
+
6
+ class McpError(KctlError):
7
+ """Base for every kctl-mcp failure."""
8
+
9
+
10
+ class ProjectionError(McpError):
11
+ """An mcp/<name>.toml projection is missing, malformed, or unsafe."""
12
+
13
+
14
+ class NotExposedError(McpError):
15
+ """A tool call named a command the projection does not expose."""
@@ -0,0 +1,120 @@
1
+ """Run an exposed command and return its output.
2
+
3
+ Every guard that matters lives here, because this is the only place the bridge
4
+ turns model input into a process:
5
+
6
+ * the command path is re-checked against the projection, never trusted from the
7
+ tool call;
8
+ * arguments are passed as an argv list, never through a shell;
9
+ * the profile is injected from the launch environment and stripped from anything
10
+ the model supplied;
11
+ * output is capped, because a runaway report should not fill the context window.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ from dataclasses import dataclass
18
+
19
+ from .core.exceptions import NotExposedError
20
+ from .projection import Projection
21
+ from .schema import clean_env
22
+
23
+ DEFAULT_TIMEOUT = 120
24
+
25
+ #: Characters answered for by argv-passing, but a command path is a whitelist anyway.
26
+ _PATH_CHARS = set("abcdefghijklmnopqrstuvwxyz0123456789-_ ")
27
+
28
+ #: Truncation point for tool output, in characters.
29
+ MAX_OUTPUT = 60_000
30
+
31
+ #: Flags the model may never set: they choose the tenant, the environment, or
32
+ #: bypass confirmation.
33
+ BLOCKED_FLAGS = frozenset({"--profile", "-p", "--force", "--yes", "-y", "--no-backup"})
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Invocation:
38
+ argv: list[str]
39
+ timeout: int = DEFAULT_TIMEOUT
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Result:
44
+ ok: bool
45
+ output: str
46
+ exit_code: int
47
+
48
+
49
+ def sanitise_path(command_path: str) -> str:
50
+ """A command path is a space-separated whitelist of command names, nothing else."""
51
+ cleaned = command_path.strip()
52
+ if not cleaned or not set(cleaned.lower()) <= _PATH_CHARS:
53
+ raise NotExposedError(f"invalid command path {command_path!r}")
54
+ return cleaned
55
+
56
+
57
+ def filter_args(args: list[str]) -> list[str]:
58
+ """Drop flags the model must not control, with their values."""
59
+ out: list[str] = []
60
+ skip_next = False
61
+ for arg in args:
62
+ if skip_next:
63
+ skip_next = False
64
+ continue
65
+ base = arg.split("=", 1)[0]
66
+ if base in BLOCKED_FLAGS:
67
+ skip_next = "=" not in arg
68
+ continue
69
+ out.append(arg)
70
+ return out
71
+
72
+
73
+ def build_invocation(
74
+ projection: Projection,
75
+ command_path: str,
76
+ args: list[str] | None = None,
77
+ profile: str | None = None,
78
+ timeout: int = DEFAULT_TIMEOUT,
79
+ ) -> Invocation:
80
+ """Assemble argv for one exposed command, or refuse."""
81
+ path = sanitise_path(command_path)
82
+ if not projection.allows(path):
83
+ raise NotExposedError(f"{path!r} is not exposed by the {projection.name} projection")
84
+ argv = [projection.cli]
85
+ if profile:
86
+ argv += ["--profile", profile]
87
+ argv.append("--json")
88
+ argv += path.split()
89
+ argv += filter_args(list(args or []))
90
+ return Invocation(argv=argv, timeout=timeout)
91
+
92
+
93
+ def run(invocation: Invocation) -> Result:
94
+ """Execute without a shell. Failures come back as text, never as exceptions."""
95
+ try:
96
+ completed = subprocess.run(
97
+ invocation.argv,
98
+ capture_output=True,
99
+ text=True,
100
+ timeout=invocation.timeout,
101
+ shell=False,
102
+ env=clean_env(),
103
+ )
104
+ except FileNotFoundError:
105
+ return Result(False, f"{invocation.argv[0]} is not installed or not on PATH", 127)
106
+ except subprocess.TimeoutExpired:
107
+ return Result(False, f"timed out after {invocation.timeout}s", 124)
108
+
109
+ output = completed.stdout if completed.returncode == 0 else (completed.stderr or completed.stdout)
110
+ if len(output) > MAX_OUTPUT:
111
+ output = output[:MAX_OUTPUT] + f"\n... truncated at {MAX_OUTPUT} characters"
112
+ return Result(completed.returncode == 0, output.strip(), completed.returncode)
113
+
114
+
115
+ def discover(projection: Projection, keyword: str, timeout: int = DEFAULT_TIMEOUT) -> Result:
116
+ """Gateway mode's discovery half: the CLI's own filtered command list."""
117
+ argv = [projection.cli, "commands", "list", "--json"]
118
+ if keyword:
119
+ argv += ["--filter", keyword]
120
+ return run(Invocation(argv=argv, timeout=timeout))
@@ -0,0 +1,121 @@
1
+ """A projection decides which of a CLI's commands become MCP tools.
2
+
3
+ The bridge itself is trivial -- every kctl CLI already emits full per-parameter
4
+ schemas. The design work is restraint: `kctl-odoo` alone has hundreds of
5
+ commands, and a server that exposed them all would degrade tool selection for
6
+ every other server in the session.
7
+
8
+ Two rules make that safe:
9
+
10
+ 1. **Allow-list, not deny-list.** Nothing is exposed until a pattern names it.
11
+ ``deny`` is a second net, not the mechanism.
12
+ 2. **The profile binds at launch, never as a tool argument.** A model that can
13
+ pass ``--profile`` can reach production. This is the MCP form of the fleet's
14
+ no-default-profile rule.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import tomllib
21
+ from dataclasses import dataclass, field
22
+ from fnmatch import fnmatchcase
23
+ from pathlib import Path
24
+
25
+ from .core.exceptions import ProjectionError
26
+
27
+ MODE_TOOLS = "tools"
28
+ MODE_GATEWAY = "gateway"
29
+ VALID_MODES = (MODE_TOOLS, MODE_GATEWAY)
30
+
31
+ TRANSPORT_STDIO = "stdio"
32
+ VALID_TRANSPORTS = (TRANSPORT_STDIO,)
33
+
34
+ #: Selection accuracy degrades as the tool list grows; past this a projection
35
+ #: should switch to gateway mode instead of listing every command.
36
+ MAX_TOOLS = 30
37
+
38
+ #: Verbs that change the world. Never exposed unless a pattern names them exactly.
39
+ DESTRUCTIVE_HINTS = ("delete", "drop", "destroy", "remove", "purge", "reset", "restore", "deploy")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Projection:
44
+ """One `mcp/<name>.toml`."""
45
+
46
+ name: str
47
+ cli: str
48
+ mode: str = MODE_TOOLS
49
+ transport: str = TRANSPORT_STDIO
50
+ expose: list[str] = field(default_factory=list)
51
+ deny: list[str] = field(default_factory=list)
52
+ profile_from: str = ""
53
+ path: Path | None = None
54
+
55
+ def resolve_profile(self, environ: dict[str, str] | None = None) -> str | None:
56
+ """Read the launch-bound profile. ``env:NAME`` is the only supported source."""
57
+ if not self.profile_from:
58
+ return None
59
+ source = environ if environ is not None else dict(os.environ)
60
+ if self.profile_from.startswith("env:"):
61
+ return source.get(self.profile_from[4:])
62
+ raise ProjectionError(f"{self.name}: profile_from must be 'env:<VAR>', got {self.profile_from!r}")
63
+
64
+ def allows(self, command_path: str) -> bool:
65
+ """True when this command may be invoked.
66
+
67
+ Deny always wins, and an empty allow-list exposes nothing -- a projection
68
+ that forgot to declare ``expose`` is inert rather than wide open.
69
+ """
70
+ if any(fnmatchcase(command_path, pattern) for pattern in self.deny):
71
+ return False
72
+ return any(fnmatchcase(command_path, pattern) for pattern in self.expose)
73
+
74
+
75
+ def parse_projection(text: str, name: str = "", path: Path | None = None) -> Projection:
76
+ try:
77
+ raw = tomllib.loads(text)
78
+ except tomllib.TOMLDecodeError as exc:
79
+ raise ProjectionError(f"{name or 'projection'}: not valid TOML: {exc}") from None
80
+
81
+ cli = str(raw.get("cli", "")).strip()
82
+ if not cli:
83
+ raise ProjectionError(f"{name or 'projection'}: 'cli' is required")
84
+
85
+ mode = str(raw.get("mode", MODE_TOOLS))
86
+ if mode not in VALID_MODES:
87
+ raise ProjectionError(f"{name}: mode must be one of {', '.join(VALID_MODES)}, got {mode!r}")
88
+
89
+ transport = str(raw.get("transport", TRANSPORT_STDIO))
90
+ if transport not in VALID_TRANSPORTS:
91
+ raise ProjectionError(f"{name}: transport must be one of {', '.join(VALID_TRANSPORTS)}, got {transport!r}")
92
+
93
+ def str_list(key: str) -> list[str]:
94
+ value = raw.get(key, [])
95
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
96
+ raise ProjectionError(f"{name}: '{key}' must be a list of strings")
97
+ return list(value)
98
+
99
+ return Projection(
100
+ name=str(raw.get("name", name or cli.removeprefix("kctl-"))),
101
+ cli=cli,
102
+ mode=mode,
103
+ transport=transport,
104
+ expose=str_list("expose"),
105
+ deny=str_list("deny"),
106
+ profile_from=str(raw.get("profile_from", "")),
107
+ path=path,
108
+ )
109
+
110
+
111
+ def load_projection(path: Path) -> Projection:
112
+ if not path.is_file():
113
+ raise ProjectionError(f"no projection at {path}")
114
+ return parse_projection(path.read_text(encoding="utf-8"), name=path.stem, path=path)
115
+
116
+
117
+ def discover_projections(root: Path, subdir: str = "mcp") -> list[Projection]:
118
+ base = root / subdir
119
+ if not base.is_dir():
120
+ return []
121
+ return [load_projection(p) for p in sorted(base.glob("*.toml"))]