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.
- kctl_mcp-0.2.0/.gitignore +39 -0
- kctl_mcp-0.2.0/PKG-INFO +10 -0
- kctl_mcp-0.2.0/README.md +119 -0
- kctl_mcp-0.2.0/pyproject.toml +43 -0
- kctl_mcp-0.2.0/src/kctl_mcp/__init__.py +5 -0
- kctl_mcp-0.2.0/src/kctl_mcp/cli.py +165 -0
- kctl_mcp-0.2.0/src/kctl_mcp/core/__init__.py +0 -0
- kctl_mcp-0.2.0/src/kctl_mcp/core/callbacks.py +13 -0
- kctl_mcp-0.2.0/src/kctl_mcp/core/config.py +18 -0
- kctl_mcp-0.2.0/src/kctl_mcp/core/exceptions.py +15 -0
- kctl_mcp-0.2.0/src/kctl_mcp/invoke.py +120 -0
- kctl_mcp-0.2.0/src/kctl_mcp/projection.py +121 -0
- kctl_mcp-0.2.0/src/kctl_mcp/schema.py +211 -0
- kctl_mcp-0.2.0/src/kctl_mcp/server.py +93 -0
- kctl_mcp-0.2.0/tests/conftest.py +93 -0
- kctl_mcp-0.2.0/tests/test_dispatch.py +49 -0
- kctl_mcp-0.2.0/tests/test_invoke.py +81 -0
- kctl_mcp-0.2.0/tests/test_projection.py +67 -0
- kctl_mcp-0.2.0/tests/test_schema.py +78 -0
- kctl_mcp-0.2.0/tests/test_standard.py +42 -0
|
@@ -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/
|
kctl_mcp-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
kctl_mcp-0.2.0/README.md
ADDED
|
@@ -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,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"))]
|