Prepared for: Claude one-shot implementation
Date: 2026-05-18
Repository: smarttune-cli
Add a safe Model Context Protocol server to SmartTune so OpenClaw customer-service agents can analyze user-uploaded flight logs without receiving exec, shell, or broad filesystem write permissions.
The result should let an OpenClaw agent call narrow, read-only SmartTune tools such as:
- list supported flight-controller platforms
- inspect flight-log quality
- run PID/FFT/MagFit analysis
- optionally compare two logs in a later iteration
The MCP server must not expose arbitrary command execution, arbitrary file writing, or parameter-writing capabilities.
SmartTune is a Python package with:
- CLI entry point:
stune = smarttune.cli:main - CLI implementation:
smarttune/cli.py - platform registry and adapters:
smarttune/platform/ - analyzers:
smarttune/analyzers/ - output rendering:
smarttune/output/ - result dataclasses:
smarttune/models/analysis_result.py - unified input model:
smarttune/models/flight_data.py - tests:
tests/
The CLI already does most of the work in library calls:
resolve_adapter(platform_name, log_file)adapter.parse(log_file)KnowledgeBase(platform=adapter.name)- call analyzers directly
- render through
OutputFormatter
This is good for MCP because the server can call the library layer directly instead of invoking stune through shell subprocesses.
README.md currently advertises commands like:
stune analyze -i flight.bin --format json
stune analyze -i flight.bin --format markdown -o report.mdBut smarttune/cli.py currently defines:
@click.option("--report", type=click.Choice(["md", "html"]))There is no visible --format json path in the current CLI code. The MCP implementation should avoid depending on the CLI output path and should introduce a shared serializer/API that both MCP and future CLI JSON output can use.
Add three layers:
-
smarttune/services/analysis.py- Pure library functions that parse logs and return structured dataclasses/dicts.
- No Rich console output.
- No shell execution.
- No arbitrary writes.
-
smarttune/services/serialize.py- Converts dataclasses, enums, NumPy arrays/scalars, and recommendation objects into JSON-safe dictionaries.
- Provides compact output suitable for LLM agents.
-
smarttune/mcp_server.py- FastMCP stdio server.
- Exposes read-only tools wrapping service functions.
- Validates paths, size, extension, and output behavior.
This keeps CLI and MCP from drifting. CLI can later reuse services.analysis and services.serialize, but the initial patch does not have to fully refactor the CLI if that would expand the change too much.
Add MCP as an optional extra so normal CLI installs stay light:
[project.optional-dependencies]
mcp = ["mcp>=1.0.0", "pydantic>=2.0.0"]Keep existing extras intact:
all = ["pymavlink>=2.4.0", "pyulog>=1.0.0"]
dev = [...]Recommended install for OpenClaw:
pip install -e ".[all,mcp]"Add optional script:
[project.scripts]
stune = "smarttune.cli:main"
smarttune-mcp = "smarttune.mcp_server:main"If adding a second script is undesirable, python -m smarttune.mcp_server is enough, but the script makes OpenClaw config cleaner.
Purpose: Return supported platforms, extensions, and capabilities.
Inputs: None.
Output: JSON object:
{
"platforms": [
{
"name": "ardupilot",
"display_name": "ArduPilot",
"extensions": ".bin, .log",
"capabilities": ["pid", "fft", "magfit", "hardware", "filter"]
}
]
}Implementation:
Use smarttune.platform.registry.list_platforms().
Annotations:
readOnlyHint=True
destructiveHint=False
idempotentHint=True
openWorldHint=FalsePurpose: Parse a log and return whether it is good enough for analysis.
Inputs:
log_path: strplatform: Literal["auto", "ardupilot", "betaflight", "px4"] = "auto"
Output: JSON object:
{
"platform": "ardupilot",
"display_name": "ArduPilot",
"log_file": "flight.bin",
"file_size_mb": 12.4,
"duration_s": 321.0,
"sample_rate_hz": 400.0,
"axes": ["pitch", "roll", "yaw"],
"has_gyro": true,
"has_accel": true,
"has_mag": true,
"has_motor": true,
"has_battery": true,
"validation_issues": [],
"quality": {
"score": 92,
"rating": "EXCELLENT",
"advice": "Proceed with analysis"
}
}Implementation:
Do not copy the Rich/report-building code from quality() blindly. Move the scoring logic into a service helper, then let both CLI and MCP use it if time permits. For a one-shot MCP implementation, it is acceptable to implement a small service-quality function first and leave CLI unchanged.
Purpose: Run comprehensive log analysis and return compact structured recommendations.
Inputs:
log_path: strplatform: Literal["auto", "ardupilot", "betaflight", "px4"] = "auto"axis: Literal["all", "roll", "pitch", "yaw"] = "all"include_modules: list[Literal["pid", "fft", "magfit", "hardware", "filter"]] | None = Noneresponse_format: Literal["json", "markdown"] = "json"max_recommendations: int = 20
Output for JSON:
{
"platform": "ardupilot",
"display_name": "ArduPilot",
"log_file": "flight.bin",
"duration_s": 321.0,
"modules": {
"pid": {
"overall_assessment": "GOOD",
"axes": {
"roll": {
"assessment": "GOOD",
"step_count": 8,
"metrics": {
"rise_time_ms": 85.0,
"overshoot_percent": 8.2,
"settling_time_ms": 210.0
},
"recommendations": [
{
"param": "ATC_RAT_RLL_P",
"generic_param": "pid.roll.p",
"current": 0.12,
"suggested": 0.14,
"change_percent": 16.7,
"action": "increase",
"confidence": "medium",
"reason": "Slightly slow rise time"
}
]
}
}
},
"fft": {
"vibration_level": "GOOD",
"peaks": [
{
"frequency_hz": 47.5,
"amplitude": 2.1,
"source_guess": "propeller"
}
],
"recommendations": []
},
"magfit": {
"assessment": "GOOD",
"fitness_mgauss": 23.4,
"offsets": {"x": 1.0, "y": -3.0, "z": 5.0},
"recommendations": []
}
},
"module_failures": [],
"safety": {
"read_only": true,
"path_validated": true,
"parameter_write_performed": false
}
}Output for Markdown: Return a concise report with:
- platform and log metadata
- quality summary
- PID diagnosis and parameter recommendations
- FFT peaks and filter recommendations
- MagFit result
- module failures
- note that no parameters were written
Purpose: Compare before/after logs for tuning progress.
Inputs:
before_log_pathafter_log_pathplatform = "auto"axis = "all"
Output: JSON/Markdown delta:
- PID metrics before/after
- vibration peaks before/after
- warnings if logs are not comparable
This should be deferred unless time remains. The first three tools are enough for OpenClaw customer support.
This is the most important part.
Do not call:
subprocessos.system- shell commands
stuneCLI as a child process
Call Python library functions only.
Create smarttune/security.py or keep helper functions in mcp_server.py.
Allow roots should default to:
- current working directory
~/.openclaw/workspace/files/inbox~/.openclaw/workspace/files/output~/.openclaw/media/tmp
Allow override via environment:
SMARTTUNE_MCP_ALLOWED_ROOTS="/path/a:/path/b"
SMARTTUNE_MCP_MAX_FILE_MB="300"Validation steps:
- Expand
~. - Resolve symlinks:
Path(path).expanduser().resolve(strict=True). - Check it is a file.
- Check suffix in
{".bin", ".log", ".bbl", ".bfl", ".ulg"}. - Check resolved path is under an allowed root using
Path.is_relative_toor safe fallback for Python 3.9. - Check size <= configured limit.
- Never return full absolute paths in user-facing output unless needed. Prefer basename plus safe metadata.
Reject examples:
../../.ssh/id_rsa/etc/passwd- non-log extensions
- paths outside allowed roots
- directories
- symlink escaping allowed root
MCP should return data directly. It should not create files by default.
If future image/HTML output is needed:
- only write under a fixed output directory
- generate server-side filenames
- never accept arbitrary output path from the model
MCP calls can parse large logs. Add at least one guard:
- file size limit first
- optional
SMARTTUNE_MCP_TIMEOUT_S - avoid expensive visual plot generation in MCP v1
Python cannot hard-timeout CPU work cleanly inside the same process without signal/process isolation. For v1, size limit is acceptable. Add TODO for process isolation if customer logs become huge.
All MCP tools must mark:
readOnlyHint=True
destructiveHint=False
idempotentHint=True
openWorldHint=FalseCreate smarttune/services/serialize.py.
Implement:
to_jsonable(value)- dataclass handling via
dataclasses.asdictor explicit conversion - Enum
.value - NumPy scalar
.item() - NumPy array truncation/summarization; do not return raw long arrays
serialize_param_recommendation(rec, adapter=None)serialize_pid_result(result, adapter)serialize_fft_result(result, adapter)serialize_magfit_result(result, adapter)
Important:
Do not dump raw_data, step_responses, or full FFT arrays into MCP by default. Those can be huge.
Create smarttune/services/analysis.py.
Suggested public functions:
def load_flight_data(log_path: Path, platform: str = "auto") -> tuple[PlatformAdapter, FlightData]:
...
def get_log_quality(log_path: Path, platform: str = "auto") -> dict:
...
def analyze_log(
log_path: Path,
platform: str = "auto",
axis: str = "all",
include_modules: list[str] | None = None,
max_recommendations: int = 20,
) -> dict:
...Use the same analyzer calls currently in smarttune/cli.py:
- PID:
PIDReviewer(knowledge=kb.get("pid_rules", {})).analyze(...) - FFT:
FFTAnalyzer(knowledge=kb.get("filter_rules", {})).analyze(...) - MagFit:
MAGFit(knowledge=kb.get("magfit_rules", {})).analyze(...) - Hardware: platform hardware report module if requested
- Filter: only if there is already clean service logic; otherwise defer
Module behavior:
- If one module fails, record it in
module_failures. - If every requested module fails, return an actionable error or raise
SmartTuneError. - Do not exit the process.
- Do not print Rich output.
Create smarttune/mcp_server.py.
Skeleton:
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field, ConfigDict
mcp = FastMCP("smarttune_mcp")
class AnalyzeLogInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
log_path: str = Field(..., description="Path to a flight log file...")
platform: str = Field(default="auto", description="auto, ardupilot, betaflight, px4")
axis: str = Field(default="all", description="all, roll, pitch, yaw")
include_modules: list[str] | None = Field(default=None)
response_format: str = Field(default="json")
max_recommendations: int = Field(default=20, ge=1, le=100)Use Pydantic validators for enum-like fields, or use Literal if compatible.
Tool functions should return strings:
- JSON:
json.dumps(result, ensure_ascii=False, indent=2) - Markdown: render a compact Markdown report from the result dict
End with:
def main() -> None:
mcp.run()
if __name__ == "__main__":
main()Update:
- optional dependency
mcp - script
smarttune-mcp
Add tests:
-
tests/test_mcp_security.py- allowed extension accepted
- disallowed extension rejected
- path outside allowed roots rejected
- symlink escape rejected if easy to implement
- oversized file rejected
-
tests/test_services_serialize.py- dataclass to JSON-safe conversion
- NumPy scalar conversion
- NumPy arrays are summarized/truncated
- recommendations include generic and platform-specific parameter names
-
tests/test_services_analysis.py- use existing synthetic fixtures/helpers if available
- ensure
analyze_logreturns top-level keys and does not raise on missing optional modules
-
Optional smoke test for MCP import:
python -m py_compile smarttune/mcp_server.py- import
smarttune.mcp_server
Do not require real .bin/.bbl logs for unit tests unless fixtures already exist.
Update README.md:
- Add MCP section under "For Agents".
- Explain read-only safety boundary.
- Add OpenClaw config example:
{
"mcp": {
"servers": {
"SmartTune": {
"command": "smarttune-mcp",
"args": [],
"env": {
"SMARTTUNE_MCP_ALLOWED_ROOTS": "/home/raylan/.openclaw/workspace/files/inbox:/home/raylan/.openclaw/workspace/files/output:/tmp",
"SMARTTUNE_MCP_MAX_FILE_MB": "300"
}
}
}
}
}If smarttune-mcp script is not added, use:
{
"command": "python",
"args": ["-m", "smarttune.mcp_server"]
}Also fix the --format json mismatch:
Option A:
- Implement CLI
--format json|markdown|htmlnow.
Option B:
- Adjust README to current
--report md|htmland document MCP as the JSON interface for now.
Recommended: Implement a small JSON output path in CLI using the new serializer if time permits. If this expands too much, leave a clear TODO and at least stop advertising a nonexistent flag.
The implementation is complete when:
pip install -e ".[all,mcp,dev]"succeeds.smarttune-mcpstarts as a stdio MCP server.- The MCP server exposes:
smarttune_list_platformssmarttune_log_qualitysmarttune_analyze_log
- No MCP tool can execute arbitrary shell commands.
- No MCP tool accepts arbitrary output paths.
- Path validation blocks files outside allowed roots.
- Log analysis returns structured JSON without Rich formatting or ANSI sequences.
- Module failures are returned as structured data instead of crashing the server.
- Existing CLI tests still pass.
- New MCP/security/serializer tests pass.
cd smarttune-cli
python -m py_compile smarttune/mcp_server.py
python -m py_compile smarttune/services/analysis.py smarttune/services/serialize.py
pytest -q
pip install -e ".[all,mcp,dev]"
smarttune-mcp --help || trueIf smarttune-mcp --help is not meaningful because stdio servers wait for MCP input, just verify import/compile and use MCP Inspector if available:
npx @modelcontextprotocol/inspector smarttune-mcpDo not implement:
- MAVLink parameter writes
- automatic tuning writes
- firmware flashing
- arbitrary report file export from MCP
- cloud upload
- network calls
- background job queue
- user authentication
Those can come later, after the read-only tool surface is proven safe.
Expected new files:
smarttune/services/__init__.pysmarttune/services/analysis.pysmarttune/services/serialize.pysmarttune/mcp_server.pytests/test_mcp_security.pytests/test_services_serialize.pytests/test_services_analysis.py
Expected modified files:
pyproject.tomlREADME.md- optionally
smarttune/cli.pyif adding shared JSON output
Keep the first version conservative. A useful, boring, read-only MCP server is the right target. The customer-service agents need reliable diagnosis, not broad control.
The most important design choice is not the MCP framework; it is capability narrowing:
- user uploads log
- agent calls SmartTune MCP
- SmartTune parses and analyzes
- agent explains result
- no shell
- no writes
- no parameter mutation
That is the safety boundary this patch must preserve.