Add Python MCP server option

This commit is contained in:
server
2026-04-14 12:03:47 +02:00
parent 5709fd9c35
commit 809c96a5b7
5 changed files with 256 additions and 0 deletions

View File

@@ -68,6 +68,31 @@ Smoke test:
npm run mcp:smoke npm run mcp:smoke
``` ```
## Python MCP Server
If you prefer Python over Node for the MCP wrapper, the repository also ships a
Python FastMCP variant.
Setup:
```bash
python3 -m venv .venv-mcp
. .venv-mcp/bin/activate
pip install -r requirements-mcp.txt
```
Run:
```bash
python mcp_server.py
```
Python smoke test:
```bash
python scripts/mcp_smoke_test.py
```
## Build ## Build
```bash ```bash

View File

@@ -93,6 +93,31 @@ That test:
- lists tools - lists tools
- calls `pack_binary_info` - calls `pack_binary_info`
## Python variant
The repo also contains a Python FastMCP server for teams that prefer Python for
agent tooling.
Setup:
```bash
python3 -m venv .venv-mcp
. .venv-mcp/bin/activate
pip install -r requirements-mcp.txt
```
Run:
```bash
python mcp_server.py
```
Smoke test:
```bash
python scripts/mcp_smoke_test.py
```
## Claude Desktop style config example ## Claude Desktop style config example
```json ```json

165
mcp_server.py Normal file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import FastMCP
REPO_ROOT = Path(__file__).resolve().parent
DEFAULT_BINARY = REPO_ROOT / "build" / "m2pack"
ENV_BINARY = "M2PACK_BINARY"
mcp = FastMCP("m2pack-secure")
def _resolve_binary() -> Path:
env_value = os.environ.get(ENV_BINARY)
if env_value:
candidate = Path(env_value).expanduser()
if candidate.is_file():
return candidate
if DEFAULT_BINARY.is_file():
return DEFAULT_BINARY
which = shutil.which("m2pack")
if which:
return Path(which)
raise FileNotFoundError(
f"m2pack binary not found. Build {DEFAULT_BINARY} or set {ENV_BINARY}."
)
def _run_cli(*args: str) -> dict[str, Any]:
binary = _resolve_binary()
cmd = [str(binary), *args, "--json"]
proc = subprocess.run(
cmd,
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
if proc.returncode != 0:
detail = proc.stderr.strip() or proc.stdout.strip() or f"exit code {proc.returncode}"
raise RuntimeError(f"m2pack failed: {detail}")
stdout = proc.stdout.strip()
if not stdout:
return {"ok": True}
try:
return json.loads(stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"m2pack returned non-JSON output: {stdout}") from exc
@mcp.tool()
def pack_keygen(out_dir: str) -> dict[str, Any]:
"""Generate a new master content key and Ed25519 signing keypair."""
return _run_cli("keygen", "--out-dir", out_dir)
@mcp.tool()
def pack_build(
input_dir: str,
output_archive: str,
key_file: str,
signing_secret_key_file: str,
) -> dict[str, Any]:
"""Build an .m2p archive from a source directory."""
return _run_cli(
"build",
"--input",
input_dir,
"--output",
output_archive,
"--key",
key_file,
"--sign-secret-key",
signing_secret_key_file,
)
@mcp.tool()
def pack_list(archive: str) -> dict[str, Any]:
"""List entries in an .m2p archive."""
return _run_cli("list", "--archive", archive)
@mcp.tool()
def pack_verify(
archive: str,
public_key_file: str | None = None,
key_file: str | None = None,
) -> dict[str, Any]:
"""Verify archive manifest integrity and optionally decrypt all entries."""
args = ["verify", "--archive", archive]
if public_key_file:
args.extend(["--public-key", public_key_file])
if key_file:
args.extend(["--key", key_file])
return _run_cli(*args)
@mcp.tool()
def pack_extract(
archive: str,
output_dir: str,
key_file: str,
) -> dict[str, Any]:
"""Extract an .m2p archive into a directory."""
return _run_cli(
"extract",
"--archive",
archive,
"--output",
output_dir,
"--key",
key_file,
)
@mcp.tool()
def pack_export_client_config(
key_file: str,
public_key_file: str,
output_header: str,
) -> dict[str, Any]:
"""Generate M2PackKeys.h for the Windows client tree."""
return _run_cli(
"export-client-config",
"--key",
key_file,
"--public-key",
public_key_file,
"--output",
output_header,
)
@mcp.tool()
def pack_binary_info() -> dict[str, Any]:
"""Report which m2pack binary the server will execute."""
binary = _resolve_binary()
return {
"ok": True,
"binary": str(binary),
"repo_root": str(REPO_ROOT),
}
def main() -> None:
mcp.run()
if __name__ == "__main__":
main()

1
requirements-mcp.txt Normal file
View File

@@ -0,0 +1 @@
mcp==1.27.0

40
scripts/mcp_smoke_test.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
from __future__ import annotations
import anyio
import os
from pathlib import Path
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
REPO_ROOT = Path(__file__).resolve().parent.parent
async def main() -> None:
server_params = StdioServerParameters(
command=str(REPO_ROOT / ".venv-mcp" / "bin" / "python"),
args=[str(REPO_ROOT / "mcp_server.py")],
cwd=str(REPO_ROOT),
env={
**os.environ,
"M2PACK_BINARY": os.environ.get("M2PACK_BINARY", str(REPO_ROOT / "build" / "m2pack")),
},
)
async with stdio_client(server_params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
tools = await session.list_tools()
binary_info = await session.call_tool("pack_binary_info", {})
print({
"ok": True,
"tool_count": len(tools.tools),
"tool_names": [tool.name for tool in tools.tools],
"binary_info": getattr(binary_info, "structuredContent", None),
})
if __name__ == "__main__":
anyio.run(main)