#!/usr/bin/env python3 """Cursor MCP bridge for a remote Windows CODESYS agent.""" from __future__ import annotations import json import os from typing import Any, Dict import httpx from mcp.server.fastmcp import FastMCP AGENT_URL = os.environ.get("CODESYS_AGENT_URL", "http://127.0.0.1:8787").rstrip("/") AGENT_TOKEN = os.environ.get("CODESYS_AGENT_TOKEN", "") REQUEST_TIMEOUT_SEC = float(os.environ.get("CODESYS_AGENT_REQUEST_TIMEOUT_SEC", "30")) if not AGENT_TOKEN: raise RuntimeError("CODESYS_AGENT_TOKEN is required.") mcp = FastMCP("codesys-bridge") def _headers() -> Dict[str, str]: return {"x-api-token": AGENT_TOKEN, "content-type": "application/json"} def _client() -> httpx.Client: return httpx.Client(timeout=REQUEST_TIMEOUT_SEC) def _post(path: str, payload: Dict[str, Any]) -> Dict[str, Any]: with _client() as client: response = client.post( f"{AGENT_URL}{path}", headers=_headers(), content=json.dumps(payload), ) response.raise_for_status() return response.json() def _get(path: str) -> Dict[str, Any]: with _client() as client: response = client.get(f"{AGENT_URL}{path}", headers=_headers()) response.raise_for_status() return response.json() @mcp.tool() def codesys_health() -> Dict[str, Any]: """Check Windows VM CODESYS agent health.""" with _client() as client: response = client.get(f"{AGENT_URL}/health") response.raise_for_status() return response.json() @mcp.tool() def codesys_submit_job(action: str, payload: Dict[str, Any], dry_run: bool = False) -> Dict[str, Any]: """Submit an asynchronous CODESYS action.""" return _post( "/jobs", { "action": action, "payload": payload, "dry_run": dry_run, }, ) @mcp.tool() def codesys_get_job(job_id: str) -> Dict[str, Any]: """Get CODESYS job status/result by ID.""" return _get(f"/jobs/{job_id}") @mcp.tool() def create_project(project_name: str, output_dir: str, template: str = "standard", dry_run: bool = False) -> Dict[str, Any]: """Create a new CODESYS project in the Windows VM.""" return codesys_submit_job( action="create_project", payload={ "project_name": project_name, "output_dir": output_dir, "template": template, }, dry_run=dry_run, ) @mcp.tool() def read_project_inventory(project_path: str, max_depth: int = 3, dry_run: bool = False) -> Dict[str, Any]: """Read an existing project tree/inventory.""" return codesys_submit_job( action="read_project_inventory", payload={"project_path": project_path, "max_depth": max_depth}, dry_run=dry_run, ) @mcp.tool() def stage_existing_project( source_path: str, projects_root_dir: str, project_folder_name: str = "", move: bool = False, overwrite: bool = False, dry_run: bool = False, ) -> Dict[str, Any]: """Copy/move an existing project into a standardized projects root folder.""" return codesys_submit_job( action="stage_existing_project", payload={ "source_path": source_path, "projects_root_dir": projects_root_dir, "project_folder_name": project_folder_name, "move": move, "overwrite": overwrite, }, dry_run=dry_run, ) @mcp.tool() def write_pou( project_path: str, object_path: str = "", object_name: str = "", declaration: str = "", implementation: str = "", updates: list = [], dry_run: bool = False, ) -> Dict[str, Any]: """Write/update textual objects (POUs, FBs, GVLs, NVLs, structs, programs). For a single object: provide object_path (e.g. "Device/Plc Logic/Application/3.Function Blocks/fb_boiler") or object_name (unique name), plus declaration and/or implementation text. For batch: provide updates list of dicts with object_path/object_name + declaration/implementation. """ payload: Dict[str, Any] = {"project_path": project_path, "dry_run": dry_run} if updates: payload["updates"] = updates else: if object_path: payload["object_path"] = object_path if object_name: payload["object_name"] = object_name if declaration: payload["declaration"] = declaration if implementation: payload["implementation"] = implementation return codesys_submit_job(action="write_pou", payload=payload, dry_run=dry_run) @mcp.tool() def read_device_io( project_path: str, channels_only: bool = True, max_depth: int = 10, export_csv_path: str = "", dry_run: bool = False, ) -> Dict[str, Any]: """Read device tree with I/O channels, parameters, and variable mappings. Returns device identification (type/model), I/O channel details (input/output, bit size, IEC type), and current variable mappings for each device in the project. Set channels_only=False to include non-IO parameters too. Optionally export all IO mappings as CSV on the VM. """ payload: Dict[str, Any] = { "project_path": project_path, "channels_only": channels_only, "max_depth": max_depth, } if export_csv_path: payload["export_csv_path"] = export_csv_path return codesys_submit_job(action="read_device_io", payload=payload, dry_run=dry_run) @mcp.tool() def write_io_mapping( project_path: str, mappings: list = [], import_csv_path: str = "", dry_run: bool = True, ) -> Dict[str, Any]: """Map IEC variables to device I/O channels. Each mapping dict needs: device_path (slash-separated, e.g. "Device/EtherCAT_Master/EK1100/K1"), param_id or param_name (from read_device_io output), and variable (IEC variable expression). Alternatively provide import_csv_path to bulk-import mappings from a CSV file on the VM. dry_run=True by default for safety -- set False to actually write. """ payload: Dict[str, Any] = { "project_path": project_path, "dry_run": dry_run, } if mappings: payload["mappings"] = mappings if import_csv_path: payload["import_csv_path"] = import_csv_path return codesys_submit_job(action="write_io_mapping", payload=payload, dry_run=dry_run) @mcp.tool() def manage_device( project_path: str, operations: list = [], dry_run: bool = True, ) -> Dict[str, Any]: """Add, remove, update, rename, enable/disable devices, or set device parameters. Each operation dict needs action_type plus action-specific fields: - add: parent_path, name, device_type, device_id, device_version [, method="add"|"plug"|"insert"] - remove: device_path - update: device_path, device_type, device_id, device_version - rename: device_path, new_name - enable/disable: device_path, enabled (bool) - set_param: device_path, param_id or param_name, value Use device_type/device_id/device_version values from read_device_io output. dry_run=True by default for safety. """ return codesys_submit_job( action="manage_device", payload={ "project_path": project_path, "operations": operations, "dry_run": dry_run, }, dry_run=dry_run, ) @mcp.tool() def sync_project( project_path: str, textual_updates: list = [], io_mappings: list = [], device_params: list = [], dry_run: bool = True, ) -> Dict[str, Any]: """Batch-push local changes into a CODESYS project in a single open/save cycle. textual_updates: list of {object_path, declaration?, implementation?} io_mappings: list of {device_path, param_id, variable} device_params: list of {device_path, param_id or param_name, value} This is the primary "push" tool -- reads all changes from Cursor and applies them at once. dry_run=True by default for safety. """ return codesys_submit_job( action="sync_project", payload={ "project_path": project_path, "textual_updates": textual_updates, "io_mappings": io_mappings, "device_params": device_params, "dry_run": dry_run, }, dry_run=dry_run, ) @mcp.tool() def build_project(project_path: str, dry_run: bool = False) -> Dict[str, Any]: """Build/compile a CODESYS project.""" return codesys_submit_job( action="build_project", payload={"project_path": project_path}, dry_run=dry_run, ) @mcp.tool() def download_to_device(project_path: str, target_name: str, start_application: bool = False, dry_run: bool = False) -> Dict[str, Any]: """Download project to configured target device.""" return codesys_submit_job( action="download_to_device", payload={ "project_path": project_path, "target_name": target_name, "start_application": start_application, }, dry_run=dry_run, ) if __name__ == "__main__": mcp.run()