Skip to content

Commit 458d90c

Browse files
committed
Add automation_file_mcp console script and mcp CLI subcommand
MCPServer now has a thin argparse entry point (_cli) exposed as the automation_file_mcp console script via [project.scripts] in both stable.toml and dev.toml. The automation_file CLI learns a new mcp subcommand that forwards --name / --version / --allowed-actions to the same entry, so hosts can launch the bridge without writing Python glue. --allowed-actions takes a comma-separated whitelist and builds a filtered registry at startup — recommended because the default registry includes high-privilege actions like FA_run_shell. Five new tests cover the whitelist filter, the CLI entry point, and the mcp subcommand forwarding.
1 parent c3207c3 commit 458d90c

File tree

6 files changed

+187
-2
lines changed

6 files changed

+187
-2
lines changed

automation_file/__main__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ def _cmd_ui(_args: argparse.Namespace) -> int:
104104
return launch_ui()
105105

106106

107+
def _cmd_mcp(args: argparse.Namespace) -> int:
108+
from automation_file.server.mcp_server import _cli as mcp_cli
109+
110+
forwarded: list[str] = ["--name", args.name, "--version", args.version]
111+
if args.allowed_actions:
112+
forwarded.extend(["--allowed-actions", args.allowed_actions])
113+
return mcp_cli(forwarded)
114+
115+
107116
def _cmd_drive_upload(args: argparse.Namespace) -> int:
108117
from automation_file.remote.google_drive.client import driver_instance
109118
from automation_file.remote.google_drive.upload_ops import (
@@ -177,6 +186,18 @@ def _build_parser() -> argparse.ArgumentParser:
177186
ui_parser = subparsers.add_parser("ui", help="launch the PySide6 GUI")
178187
ui_parser.set_defaults(handler=_cmd_ui)
179188

189+
mcp_parser = subparsers.add_parser(
190+
"mcp", help="serve the action registry as an MCP server over stdio"
191+
)
192+
mcp_parser.add_argument("--name", default="automation_file")
193+
mcp_parser.add_argument("--version", default="1.0.0")
194+
mcp_parser.add_argument(
195+
"--allowed-actions",
196+
default=None,
197+
help="comma-separated allow list (default: expose every registered action)",
198+
)
199+
mcp_parser.set_defaults(handler=_cmd_mcp)
200+
180201
drive_parser = subparsers.add_parser("drive-upload", help="upload a file to Google Drive")
181202
drive_parser.add_argument("file")
182203
drive_parser.add_argument("--token", required=True)

automation_file/server/mcp_server.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919

2020
from __future__ import annotations
2121

22+
import argparse
2223
import inspect
2324
import json
2425
import sys
25-
from collections.abc import Callable, Iterable
26+
from collections.abc import Callable, Iterable, Sequence
2627
from typing import Any, TextIO
2728

2829
from automation_file.core.action_executor import executor
@@ -232,3 +233,61 @@ def tools_from_registry(registry: ActionRegistry) -> Iterable[dict[str, Any]]:
232233
"""
233234
server = MCPServer(registry)
234235
yield from server._handle_tools_list()["tools"]
236+
237+
238+
def _filtered_registry(source: ActionRegistry, allowed: Sequence[str]) -> ActionRegistry:
239+
filtered = ActionRegistry()
240+
missing: list[str] = []
241+
for name in allowed:
242+
command = source.resolve(name)
243+
if command is None:
244+
missing.append(name)
245+
continue
246+
filtered.register(name, command)
247+
if missing:
248+
raise MCPServerException("unknown action(s) in allow list: " + ", ".join(sorted(missing)))
249+
return filtered
250+
251+
252+
def _build_cli_parser() -> argparse.ArgumentParser:
253+
parser = argparse.ArgumentParser(
254+
prog="automation_file_mcp",
255+
description="Expose the automation_file action registry as an MCP server over stdio.",
256+
)
257+
parser.add_argument(
258+
"--name", default="automation_file", help="serverInfo.name reported at handshake"
259+
)
260+
parser.add_argument(
261+
"--version", default="1.0.0", help="serverInfo.version reported at handshake"
262+
)
263+
parser.add_argument(
264+
"--allowed-actions",
265+
default=None,
266+
help=(
267+
"comma-separated allow list of action names (e.g. "
268+
"'FA_list_dir,FA_file_checksum'); defaults to every registered action"
269+
),
270+
)
271+
return parser
272+
273+
274+
def _cli(argv: Sequence[str] | None = None) -> int:
275+
"""Console-script entry point for the MCP stdio server."""
276+
args = _build_cli_parser().parse_args(argv)
277+
registry = executor.registry
278+
if args.allowed_actions:
279+
names = [name.strip() for name in args.allowed_actions.split(",") if name.strip()]
280+
registry = _filtered_registry(registry, names)
281+
server = MCPServer(registry, name=args.name, version=args.version)
282+
file_automation_logger.info(
283+
"mcp_server: serving %d tools over stdio (name=%s version=%s)",
284+
len(registry.event_dict),
285+
args.name,
286+
args.version,
287+
)
288+
server.serve_stdio()
289+
return 0
290+
291+
292+
if __name__ == "__main__":
293+
sys.exit(_cli())

dev.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ dev = [
4949
"twine>=5.1.0"
5050
]
5151

52+
[project.scripts]
53+
automation_file_mcp = "automation_file.server.mcp_server:_cli"
54+
5255
[project.urls]
5356
"Homepage" = "https://github.com/JE-Chen/Integration-testing-environment"
5457

stable.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ dev = [
4949
"twine>=5.1.0"
5050
]
5151

52+
[project.scripts]
53+
automation_file_mcp = "automation_file.server.mcp_server:_cli"
54+
5255
[project.urls]
5356
"Homepage" = "https://github.com/JE-Chen/Integration-testing-environment"
5457

tests/test_cli_main.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Tests for ``automation_file.__main__`` subcommand wiring."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from automation_file import __main__ as cli_main
8+
9+
10+
def test_mcp_subcommand_forwards_all_flags(monkeypatch: pytest.MonkeyPatch) -> None:
11+
captured: dict[str, list[str]] = {}
12+
13+
def _fake_cli(argv: list[str] | None = None) -> int:
14+
captured["argv"] = list(argv or [])
15+
return 0
16+
17+
monkeypatch.setattr("automation_file.server.mcp_server._cli", _fake_cli)
18+
19+
rc = cli_main.main(
20+
[
21+
"mcp",
22+
"--name",
23+
"svc",
24+
"--version",
25+
"3.2.1",
26+
"--allowed-actions",
27+
"FA_file_checksum,FA_fast_find",
28+
]
29+
)
30+
31+
assert rc == 0
32+
assert captured["argv"] == [
33+
"--name",
34+
"svc",
35+
"--version",
36+
"3.2.1",
37+
"--allowed-actions",
38+
"FA_file_checksum,FA_fast_find",
39+
]
40+
41+
42+
def test_mcp_subcommand_omits_allowed_actions_when_unset(
43+
monkeypatch: pytest.MonkeyPatch,
44+
) -> None:
45+
captured: dict[str, list[str]] = {}
46+
47+
def _fake_cli(argv: list[str] | None = None) -> int:
48+
captured["argv"] = list(argv or [])
49+
return 0
50+
51+
monkeypatch.setattr("automation_file.server.mcp_server._cli", _fake_cli)
52+
53+
rc = cli_main.main(["mcp"])
54+
55+
assert rc == 0
56+
assert "--allowed-actions" not in captured["argv"]
57+
assert captured["argv"] == ["--name", "automation_file", "--version", "1.0.0"]

tests/test_mcp_server.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@
55
import io
66
import json
77

8+
import pytest
9+
810
from automation_file.core.action_registry import ActionRegistry
9-
from automation_file.server.mcp_server import MCPServer, tools_from_registry
11+
from automation_file.exceptions import MCPServerException
12+
from automation_file.server.mcp_server import (
13+
MCPServer,
14+
_cli,
15+
_filtered_registry,
16+
tools_from_registry,
17+
)
1018

1119

1220
def _make_registry() -> ActionRegistry:
@@ -151,3 +159,37 @@ def test_serve_stdio_handles_malformed_json() -> None:
151159
server.serve_stdio(stdin=stdin, stdout=stdout)
152160
reply = json.loads(stdout.getvalue().strip())
153161
assert reply["error"]["code"] == -32700
162+
163+
164+
def test_filtered_registry_keeps_only_allowed_actions() -> None:
165+
filtered = _filtered_registry(_make_registry(), ["add"])
166+
assert set(filtered.event_dict.keys()) == {"add"}
167+
168+
169+
def test_filtered_registry_raises_on_unknown_name() -> None:
170+
with pytest.raises(MCPServerException, match="unknown action"):
171+
_filtered_registry(_make_registry(), ["add", "does_not_exist"])
172+
173+
174+
def test_cli_serves_whitelisted_registry(monkeypatch: pytest.MonkeyPatch) -> None:
175+
captured: dict[str, object] = {}
176+
177+
class _FakeServer:
178+
def __init__(self, registry: ActionRegistry, *, name: str, version: str) -> None:
179+
captured["tools"] = set(registry.event_dict.keys())
180+
captured["name"] = name
181+
captured["version"] = version
182+
183+
def serve_stdio(self) -> None:
184+
captured["served"] = True
185+
186+
stub_registry = _make_registry()
187+
monkeypatch.setattr("automation_file.server.mcp_server.executor.registry", stub_registry)
188+
monkeypatch.setattr("automation_file.server.mcp_server.MCPServer", _FakeServer)
189+
190+
rc = _cli(["--allowed-actions", "echo", "--name", "t", "--version", "2.0.0"])
191+
assert rc == 0
192+
assert captured["tools"] == {"echo"}
193+
assert captured["name"] == "t"
194+
assert captured["version"] == "2.0.0"
195+
assert captured["served"] is True

0 commit comments

Comments
 (0)