kctl-mcp 0.2.0__py3-none-any.whl

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.
kctl_mcp/__init__.py ADDED
@@ -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__"]
kctl_mcp/cli.py ADDED
@@ -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."""
kctl_mcp/invoke.py ADDED
@@ -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))
kctl_mcp/projection.py ADDED
@@ -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"))]
kctl_mcp/schema.py ADDED
@@ -0,0 +1,211 @@
1
+ """Turn a Typer command tree into MCP tool definitions.
2
+
3
+ No per-CLI code is needed: `commands tree --json` already emits every parameter's
4
+ opts, type, requiredness, default, choices and flag-ness. This module is the
5
+ translation, plus the filtering the projection demands.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import subprocess
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from .core.exceptions import McpError
17
+ from .projection import MODE_GATEWAY, Projection
18
+
19
+ #: Typer's type names mapped onto JSON Schema.
20
+ _TYPE_MAP = {
21
+ "str": "string",
22
+ "int": "integer",
23
+ "float": "number",
24
+ "bool": "boolean",
25
+ "Path": "string",
26
+ }
27
+
28
+ #: Never surfaced as a tool parameter. The profile decides which tenant and which
29
+ #: environment a command touches, so exposing it would let a model pick production.
30
+ BLOCKED_PARAMS = frozenset({"profile", "json", "quiet", "verbose", "format", "no_header", "version", "help"})
31
+
32
+ #: Introspection is a local subprocess; a CLI that hangs must not hang the server.
33
+ INTROSPECT_TIMEOUT = 60
34
+
35
+
36
+ def clean_env() -> dict[str, str]:
37
+ """Environment for a machine-readable subprocess call.
38
+
39
+ kctl-lib's update notifier writes "WARN Update available: ..." to *stdout*,
40
+ which corrupts any JSON a caller is trying to parse. The fleet already
41
+ disables it in CI with this variable; the bridge needs it for the same reason.
42
+ """
43
+ env = dict(os.environ)
44
+ env["KCTL_NO_UPDATE_CHECK"] = "1"
45
+ env["NO_COLOR"] = "1"
46
+ env["TERM"] = "dumb"
47
+ return env
48
+
49
+
50
+ def first_json_object(text: str) -> str:
51
+ """Strip any preamble a CLI printed before its JSON document."""
52
+ start = text.find("{")
53
+ return text[start:] if start > 0 else text
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ToolDef:
58
+ name: str
59
+ description: str
60
+ input_schema: dict[str, Any]
61
+ command_path: str
62
+ params: list[dict[str, Any]] = field(default_factory=list)
63
+
64
+
65
+ def introspect(cli: str, timeout: int = INTROSPECT_TIMEOUT) -> dict[str, Any]:
66
+ """Read a CLI's own command tree. The CLI is the authority on its surface."""
67
+ try:
68
+ result = subprocess.run(
69
+ [cli, "commands", "tree", "--json"],
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=timeout,
73
+ env=clean_env(),
74
+ )
75
+ except FileNotFoundError:
76
+ raise McpError(f"{cli} is not installed or not on PATH") from None
77
+ except subprocess.TimeoutExpired:
78
+ raise McpError(f"{cli} commands tree timed out after {timeout}s") from None
79
+ if result.returncode != 0:
80
+ raise McpError(f"{cli} commands tree failed: {result.stderr.strip()[:200]}")
81
+ try:
82
+ return json.loads(first_json_object(result.stdout))
83
+ except json.JSONDecodeError as exc:
84
+ raise McpError(f"{cli} commands tree returned invalid JSON: {exc}") from None
85
+
86
+
87
+ def walk_commands(node: dict[str, Any], prefix: str = "") -> list[tuple[str, dict[str, Any]]]:
88
+ """Flatten the tree into (command path, command node) pairs."""
89
+ found: list[tuple[str, dict[str, Any]]] = []
90
+ for command in node.get("commands") or []:
91
+ if command.get("hidden"):
92
+ continue
93
+ name = command.get("name", "")
94
+ path = f"{prefix} {name}".strip()
95
+ found.append((path, command))
96
+ for group in node.get("groups") or []:
97
+ if group.get("hidden"):
98
+ continue
99
+ name = group.get("name", "")
100
+ found.extend(walk_commands(group, f"{prefix} {name}".strip()))
101
+ return found
102
+
103
+
104
+ def param_schema(param: dict[str, Any]) -> tuple[str, dict[str, Any]] | None:
105
+ """One Typer parameter as a JSON Schema property, or None when it must not surface."""
106
+ name = str(param.get("name", ""))
107
+ if not name or name in BLOCKED_PARAMS:
108
+ return None
109
+
110
+ prop: dict[str, Any] = {
111
+ "type": "boolean" if param.get("is_flag") else _TYPE_MAP.get(str(param.get("type")), "string")
112
+ }
113
+ if param.get("help"):
114
+ prop["description"] = str(param["help"])
115
+ if param.get("choices"):
116
+ prop["enum"] = list(param["choices"])
117
+ if param.get("default") is not None:
118
+ prop["default"] = param["default"]
119
+ return name, prop
120
+
121
+
122
+ def tool_name(server: str, command_path: str) -> str:
123
+ """`odoo` + `analyze run` -> `odoo_analyze_run`."""
124
+ return f"{server}_{command_path.replace(' ', '_').replace('-', '_')}"
125
+
126
+
127
+ def build_tools(tree: dict[str, Any], projection: Projection) -> list[ToolDef]:
128
+ """Every exposed command as an MCP tool."""
129
+ tools: list[ToolDef] = []
130
+ for path, command in walk_commands(tree):
131
+ if path.startswith("commands") or not projection.allows(path):
132
+ continue
133
+ properties: dict[str, Any] = {}
134
+ required: list[str] = []
135
+ params: list[dict[str, Any]] = []
136
+ for param in command.get("params") or []:
137
+ built = param_schema(param)
138
+ if built is None:
139
+ continue
140
+ key, prop = built
141
+ properties[key] = prop
142
+ params.append(param)
143
+ if param.get("required"):
144
+ required.append(key)
145
+ schema: dict[str, Any] = {"type": "object", "properties": properties}
146
+ if required:
147
+ schema["required"] = required
148
+ tools.append(
149
+ ToolDef(
150
+ name=tool_name(projection.name, path),
151
+ description=str(command.get("help") or path),
152
+ input_schema=schema,
153
+ command_path=path,
154
+ params=params,
155
+ )
156
+ )
157
+ return tools
158
+
159
+
160
+ def gateway_tools(projection: Projection) -> list[ToolDef]:
161
+ """The two-tool projection for a large CLI.
162
+
163
+ A discover/run pair keeps every allowed command reachable without putting
164
+ hundreds of schemas in the model's context -- the same two-call shape the
165
+ fleet already documents for analytics.
166
+ """
167
+ exposed = ", ".join(projection.expose) or "nothing"
168
+ return [
169
+ ToolDef(
170
+ name=f"{projection.name}_discover",
171
+ description=(
172
+ f"Search {projection.cli} commands and their exact flags. "
173
+ f"Exposed surface: {exposed}. Call this before {projection.name}_run."
174
+ ),
175
+ input_schema={
176
+ "type": "object",
177
+ "properties": {"keyword": {"type": "string", "description": "Filter commands by keyword."}},
178
+ "required": ["keyword"],
179
+ },
180
+ command_path="__discover__",
181
+ ),
182
+ ToolDef(
183
+ name=f"{projection.name}_run",
184
+ description=(
185
+ f"Run one exposed {projection.cli} command and return its JSON. "
186
+ "Use the exact command path returned by discover."
187
+ ),
188
+ input_schema={
189
+ "type": "object",
190
+ "properties": {
191
+ "command": {
192
+ "type": "string",
193
+ "description": "Command path, e.g. 'analyze run'.",
194
+ },
195
+ "args": {
196
+ "type": "array",
197
+ "items": {"type": "string"},
198
+ "description": "Flags and values, e.g. ['--date-from', '2026-01-01'].",
199
+ },
200
+ },
201
+ "required": ["command"],
202
+ },
203
+ command_path="__run__",
204
+ ),
205
+ ]
206
+
207
+
208
+ def build(tree: dict[str, Any], projection: Projection) -> list[ToolDef]:
209
+ if projection.mode == MODE_GATEWAY:
210
+ return gateway_tools(projection)
211
+ return build_tools(tree, projection)
kctl_mcp/server.py ADDED
@@ -0,0 +1,93 @@
1
+ """The MCP server: a projection, served over stdio.
2
+
3
+ The protocol handling is the official SDK's; everything interesting is in
4
+ `projection.py` (what may be exposed) and `invoke.py` (how it is run).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ from .core.exceptions import McpError, NotExposedError
13
+ from .invoke import build_invocation, discover, run
14
+ from .projection import MODE_GATEWAY, Projection
15
+ from .schema import ToolDef, build, introspect
16
+
17
+
18
+ def load_tools(projection: Projection) -> list[ToolDef]:
19
+ """Every tool this projection exposes. Gateway mode never introspects."""
20
+ if projection.mode == MODE_GATEWAY:
21
+ return build(tree={}, projection=projection)
22
+ return build(introspect(projection.cli), projection)
23
+
24
+
25
+ def dispatch(projection: Projection, tools: list[ToolDef], name: str, arguments: dict[str, Any]) -> str:
26
+ """Route one tool call. Unknown names and unexposed commands are refused, not guessed."""
27
+ tool = next((t for t in tools if t.name == name), None)
28
+ if tool is None:
29
+ raise NotExposedError(f"unknown tool {name!r}")
30
+
31
+ profile = projection.resolve_profile()
32
+
33
+ if tool.command_path == "__discover__":
34
+ return discover(projection, str(arguments.get("keyword", ""))).output
35
+
36
+ if tool.command_path == "__run__":
37
+ command = str(arguments.get("command", ""))
38
+ raw_args = arguments.get("args") or []
39
+ args = [str(a) for a in raw_args] if isinstance(raw_args, list) else []
40
+ result = run(build_invocation(projection, command, args, profile))
41
+ return result.output
42
+
43
+ return run(build_invocation(projection, tool.command_path, flags_for(tool, arguments), profile)).output
44
+
45
+
46
+ def flags_for(tool: ToolDef, arguments: dict[str, Any]) -> list[str]:
47
+ """Turn a tool call's JSON object back into CLI flags.
48
+
49
+ Only parameters the schema declared are considered, so an argument the model
50
+ invents cannot reach the command line.
51
+ """
52
+ by_name = {p.get("name"): p for p in tool.params}
53
+ args: list[str] = []
54
+ for key, value in arguments.items():
55
+ param = by_name.get(key)
56
+ if param is None or value is None:
57
+ continue
58
+ opts = param.get("opts") or []
59
+ flag = opts[0] if opts else f"--{str(key).replace('_', '-')}"
60
+ if param.get("is_flag"):
61
+ if value:
62
+ args.append(flag)
63
+ continue
64
+ if param.get("param_type") == "argument":
65
+ args.append(str(value))
66
+ continue
67
+ args += [flag, str(value)]
68
+ return args
69
+
70
+
71
+ async def serve(projection: Projection) -> None: # pragma: no cover - protocol I/O
72
+ """Run the stdio server. Imported lazily so the CLI works without the SDK."""
73
+ import mcp.types as types
74
+ from mcp.server import Server
75
+ from mcp.server.stdio import stdio_server
76
+
77
+ tools = load_tools(projection)
78
+ server: Server = Server(f"kctl-{projection.name}")
79
+
80
+ @server.list_tools()
81
+ async def list_tools() -> list[types.Tool]:
82
+ return [types.Tool(name=t.name, description=t.description, inputSchema=t.input_schema) for t in tools]
83
+
84
+ @server.call_tool()
85
+ async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]:
86
+ try:
87
+ text = dispatch(projection, tools, name, arguments or {})
88
+ except McpError as exc:
89
+ text = json.dumps({"error": f"{type(exc).__name__}: {exc}"})
90
+ return [types.TextContent(type="text", text=text)]
91
+
92
+ async with stdio_server() as (read, write):
93
+ await server.run(read, write, server.create_initialization_options())
@@ -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,14 @@
1
+ kctl_mcp/__init__.py,sha256=wkGQvsQnLHA8WNUqHG3VMS8LASKesCITo30omq2VSC0,113
2
+ kctl_mcp/cli.py,sha256=UFtFK6AKa6o-PgoCFZcqJArMcxOCxGi9d_aiIn78hXM,5362
3
+ kctl_mcp/invoke.py,sha256=QT-rnZpGwU8U9Y7QrBGyQkR5OiRBqXoBwoTSBqet1-c,3974
4
+ kctl_mcp/projection.py,sha256=jTrmUd9K6Bi6Mw80bcDIcuUaqrZBVGOaXwn30bpOhSg,4487
5
+ kctl_mcp/schema.py,sha256=y7s8UP9aYU-epygZaP1pD6WIyZjbb_-UsayRFlSl91o,7540
6
+ kctl_mcp/server.py,sha256=6uqA6_1MI2KTD_Thvpnucxp_SU9rs2VCr93XXLR9RFA,3610
7
+ kctl_mcp/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ kctl_mcp/core/callbacks.py,sha256=QscNwfHIqCoDzELgYgxErKPIFzru54K0H_Lkvb6sHK8,247
9
+ kctl_mcp/core/config.py,sha256=B0AiYmEU3tN-fVG82_Rvq1O2jue1kyd0PP1lNaZlyxw,582
10
+ kctl_mcp/core/exceptions.py,sha256=13eAFiV-37gwFh3Ovn62Yjad4Q3Ka4Bysurk1kc3TpU,353
11
+ kctl_mcp-0.2.0.dist-info/METADATA,sha256=FQJlU52x0FX_hEkiFJARMgVgBwdjdFgg0babTJ9cS6g,302
12
+ kctl_mcp-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
13
+ kctl_mcp-0.2.0.dist-info/entry_points.txt,sha256=m-mmjU4ooPqYzIcaN_SwaocGff6SyO4OKaEg0qt-0bo,47
14
+ kctl_mcp-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kctl-mcp = kctl_mcp.cli:_run