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 +5 -0
- kctl_mcp/cli.py +165 -0
- kctl_mcp/core/__init__.py +0 -0
- kctl_mcp/core/callbacks.py +13 -0
- kctl_mcp/core/config.py +18 -0
- kctl_mcp/core/exceptions.py +15 -0
- kctl_mcp/invoke.py +120 -0
- kctl_mcp/projection.py +121 -0
- kctl_mcp/schema.py +211 -0
- kctl_mcp/server.py +93 -0
- kctl_mcp-0.2.0.dist-info/METADATA +10 -0
- kctl_mcp-0.2.0.dist-info/RECORD +14 -0
- kctl_mcp-0.2.0.dist-info/WHEEL +4 -0
- kctl_mcp-0.2.0.dist-info/entry_points.txt +2 -0
kctl_mcp/__init__.py
ADDED
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
|
kctl_mcp/core/config.py
ADDED
|
@@ -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,,
|