Update .gitignore and README.md for CODESYS MCP project. Added entries to .gitignore for VSIX files and VS Code settings. Expanded README to include architecture, components, deployment instructions, and details about the Cursor extension.

This commit is contained in:
2026-05-24 15:47:21 +03:00
parent acdd740d01
commit bedc5d0628
55 changed files with 11120 additions and 3 deletions

View File

@@ -0,0 +1,23 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"env": {
"node": true,
"es2022": true
},
"ignorePatterns": [
"out",
"node_modules"
],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off"
}
}

View File

@@ -0,0 +1,6 @@
src/**
tsconfig.json
.eslintrc.json
**/*.map
**/*.ts
.vscode-test/**

View File

@@ -0,0 +1,84 @@
# CODESYS MCP Companion Extension
VS Code/Cursor companion extension for the CODESYS MCP bridge.
This extension provides command workflows, project/actions/jobs views, status bar state, and diagnostics for the existing Python MCP bridge located at:
- `python_scripting/mcp_codesys_bridge/server.py`
## Features
- MCP bridge lifecycle and health check
- Project-level commands:
- create project
- stage existing project
- read project inventory
- read device I/O
- write POU
- write I/O mapping
- manage device
- sync project
- build project
- download to device
- Job polling UX through Jobs view
- Error diagnostics surfaced in Problems panel
- ST language support:
- syntax grammar
- snippets
- context menu action for current ST file sync
## Settings
All settings are under the `codesys.*` namespace:
- `codesys.pythonPath`
- `codesys.bridgeScriptPath`
- `codesys.agentUrl`
- `codesys.agentToken`
- `codesys.requestTimeoutSec`
- `codesys.pollIntervalMs`
- `codesys.defaultProjectPath`
- `codesys.defaultDryRunMutations`
- `codesys.allowDownloadCommand`
## Local Development
Prerequisites:
- Node.js 20+
- npm 10+
- Python 3 available for bridge startup
Install and build:
```bash
cd vscode_codesys_companion
npm install
npm run check
```
Run extension in Extension Development Host:
1. Open `vscode_codesys_companion` in VS Code/Cursor.
2. Press `F5`.
3. Use command palette commands with prefix `CODESYS:`.
## Packaging
Build VSIX package:
```bash
cd vscode_codesys_companion
npm install
npm run compile
npm run package
```
## Operational Notes
- Keep mutating operations in dry-run mode first.
- `download_to_device` command is gated by `codesys.allowDownloadCommand`.
- Bridge auth requires `codesys.agentToken` to match VM agent token.
- Align operations with:
- `python_scripting/how-to-use-codesys-mcp.md`
- `python_scripting/codesys-mcp-bridge-operations.md`

View File

@@ -0,0 +1,56 @@
# Companion Extension Smoke Test
This validates extension + MCP bridge + VM agent integration.
## 1. Preconditions
- Windows VM agent is reachable.
- `codesys.agentUrl` and `codesys.agentToken` are configured in extension settings.
- CODESYS bridge script path resolves correctly.
Reference operational guide:
- `../python_scripting/codesys-mcp-bridge-operations.md`
## 2. Connect + Health
1. Run `CODESYS: Connect MCP Bridge`.
2. Run `CODESYS: Health Check`.
3. Confirm status bar shows connected and output logs health response.
## 3. Project Selection
1. Run `CODESYS: Select Project`.
2. Provide a valid project path.
3. Confirm Projects view shows active project.
## 4. Read Workflows
1. Run `CODESYS: Read Project Inventory`.
2. Run `CODESYS: Read Device I/O`.
3. Confirm Jobs view shows `succeeded` for both jobs.
## 5. Safe Mutation (Dry Run)
1. Run `CODESYS: Sync Project`.
2. Keep dry-run enabled.
3. Confirm job success and no errors in Problems view.
## 6. Build
1. Run `CODESYS: Build Project`.
2. Confirm job success.
## 7. Optional Deploy
1. Set `codesys.allowDownloadCommand=true`.
2. Run `CODESYS: Download To Device`.
3. Validate target confirmation and completion.
## 8. Failure Signal Check
1. Intentionally provide bad input (for example invalid project path) to `Read Project Inventory`.
2. Confirm failure:
- appears in output
- appears in Jobs view
- appears in Problems panel diagnostics

View File

@@ -0,0 +1,47 @@
{
"comments": {
"lineComment": "//",
"blockComment": [
"(*",
"*)"
]
},
"brackets": [
[
"(",
")"
],
[
"[",
"]"
]
],
"autoClosingPairs": [
{
"open": "(",
"close": ")"
},
{
"open": "[",
"close": "]"
},
{
"open": "\"",
"close": "\""
}
],
"surroundingPairs": [
[
"(",
")"
],
[
"[",
"]"
],
[
"\"",
"\""
]
]
}

View File

@@ -0,0 +1,4 @@
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="8" width="112" height="112" rx="14" fill="#2b2b2b"/>
<path d="M30 88V40h18l11 20 11-20h18v48H74V66l-15 22-15-22v22H30z" fill="#f2f2f2"/>
</svg>

After

Width:  |  Height:  |  Size: 251 B

6192
vscode_codesys_companion/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,240 @@
{
"name": "codesys-mcp-companion",
"displayName": "CODESYS MCP Companion",
"description": "VS Code/Cursor companion extension for CODESYS MCP bridge workflows.",
"version": "0.1.0",
"publisher": "nearxos",
"private": true,
"engines": {
"vscode": "^1.90.0"
},
"categories": [
"Other",
"Programming Languages"
],
"activationEvents": [
"onStartupFinished",
"onCommand:codesys.connect",
"onCommand:codesys.health",
"onCommand:codesys.createProject",
"onCommand:codesys.syncProject",
"onCommand:codesys.buildProject",
"onCommand:codesys.downloadToDevice"
],
"main": "./out/extension.js",
"contributes": {
"commands": [
{
"command": "codesys.connect",
"title": "CODESYS: Connect MCP Bridge"
},
{
"command": "codesys.health",
"title": "CODESYS: Health Check"
},
{
"command": "codesys.createProject",
"title": "CODESYS: Create Project"
},
{
"command": "codesys.selectProject",
"title": "CODESYS: Select Project"
},
{
"command": "codesys.stageExistingProject",
"title": "CODESYS: Stage Existing Project"
},
{
"command": "codesys.readProjectInventory",
"title": "CODESYS: Read Project Inventory"
},
{
"command": "codesys.readDeviceIo",
"title": "CODESYS: Read Device I/O"
},
{
"command": "codesys.writePou",
"title": "CODESYS: Write POU"
},
{
"command": "codesys.writeIoMapping",
"title": "CODESYS: Write IO Mapping"
},
{
"command": "codesys.manageDevice",
"title": "CODESYS: Manage Device"
},
{
"command": "codesys.syncProject",
"title": "CODESYS: Sync Project"
},
{
"command": "codesys.buildProject",
"title": "CODESYS: Build Project"
},
{
"command": "codesys.downloadToDevice",
"title": "CODESYS: Download To Device"
},
{
"command": "codesys.refreshJobs",
"title": "CODESYS: Refresh Jobs"
},
{
"command": "codesys.openOutput",
"title": "CODESYS: Open Output"
},
{
"command": "codesys.syncCurrentFile",
"title": "CODESYS: Sync Current ST File"
}
],
"configuration": {
"title": "CODESYS MCP Companion",
"properties": {
"codesys.pythonPath": {
"type": "string",
"default": "python",
"description": "Python executable used to launch the CODESYS MCP bridge server."
},
"codesys.bridgeScriptPath": {
"type": "string",
"default": "",
"description": "Absolute path to python_scripting/mcp_codesys_bridge/server.py. If empty, default repo-relative path is used."
},
"codesys.agentUrl": {
"type": "string",
"default": "http://127.0.0.1:8787",
"description": "Windows CODESYS agent URL passed to MCP bridge."
},
"codesys.agentToken": {
"type": "string",
"default": "",
"description": "Windows CODESYS agent shared token."
},
"codesys.requestTimeoutSec": {
"type": "number",
"default": 30,
"minimum": 5,
"description": "CODESYS MCP bridge request timeout in seconds."
},
"codesys.pollIntervalMs": {
"type": "number",
"default": 1500,
"minimum": 500,
"description": "Polling interval for asynchronous job progress."
},
"codesys.defaultProjectPath": {
"type": "string",
"default": "",
"description": "Default CODESYS project path used by commands."
},
"codesys.defaultDryRunMutations": {
"type": "boolean",
"default": true,
"description": "Run mutating operations in dry-run mode unless explicitly disabled in command input."
},
"codesys.allowDownloadCommand": {
"type": "boolean",
"default": false,
"description": "Enable UI command for download_to_device."
},
"codesys.mockMode": {
"type": "boolean",
"default": false,
"description": "Use local mocked MCP responses for extension integration testing."
}
}
},
"viewsContainers": {
"activitybar": [
{
"id": "codesys",
"title": "CODESYS",
"icon": "media/codesys.svg"
}
]
},
"views": {
"codesys": [
{
"id": "codesysProjectsView",
"name": "Projects"
},
{
"id": "codesysJobsView",
"name": "Jobs"
},
{
"id": "codesysActionsView",
"name": "Actions"
}
]
},
"menus": {
"editor/context": [
{
"command": "codesys.syncCurrentFile",
"when": "resourceExtname =~ /\\.st$/ || resourceExtname =~ /\\.gvl$/ || resourceExtname =~ /\\.nvl$/ || resourceExtname =~ /\\.txt$/",
"group": "navigation@90"
}
],
"view/title": [
{
"command": "codesys.refreshJobs",
"when": "view == codesysJobsView",
"group": "navigation"
}
]
},
"languages": [
{
"id": "codesys-st",
"aliases": [
"CODESYS Structured Text",
"ST"
],
"extensions": [
".st",
".gvl",
".nvl",
".type.st"
],
"configuration": "./language-configuration.json"
}
],
"grammars": [
{
"language": "codesys-st",
"scopeName": "source.codesys.st",
"path": "./syntaxes/codesys-st.tmLanguage.json"
}
],
"snippets": [
{
"language": "codesys-st",
"path": "./snippets/codesys-st.json"
}
]
},
"scripts": {
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"lint": "eslint src --ext ts",
"test": "npm run compile && node ./out/test/runTest.js",
"package": "vsce package --no-dependencies",
"check": "npm run compile && npm run lint"
},
"devDependencies": {
"@types/mocha": "^10.0.8",
"@types/node": "^20.16.5",
"@types/vscode": "^1.90.0",
"@typescript-eslint/eslint-plugin": "^8.13.0",
"@typescript-eslint/parser": "^8.13.0",
"@vscode/test-electron": "^2.4.1",
"@vscode/vsce": "^3.2.2",
"eslint": "^8.57.1",
"mocha": "^10.7.3",
"typescript": "^5.6.2"
}
}

View File

@@ -0,0 +1,46 @@
{
"Function Block": {
"prefix": "fb",
"body": [
"FUNCTION_BLOCK ${1:FB_Name}",
"VAR_INPUT",
" ${2:inputVar} : ${3:BOOL};",
"END_VAR",
"",
"VAR_OUTPUT",
" ${4:outputVar} : ${5:BOOL};",
"END_VAR",
"",
"VAR",
" ${6:internalVar} : ${7:BOOL};",
"END_VAR",
"",
"${0}",
"END_FUNCTION_BLOCK"
],
"description": "CODESYS function block template"
},
"Program": {
"prefix": "prg",
"body": [
"PROGRAM ${1:PLC_PRG}",
"VAR",
" ${2:xStart} : BOOL;",
"END_VAR",
"",
"${0}",
"END_PROGRAM"
],
"description": "CODESYS program template"
},
"Timer TON": {
"prefix": "ton",
"body": [
"${1:tonTimer}(IN := ${2:xEnable}, PT := T#${3:1s});",
"IF ${1:tonTimer}.Q THEN",
" ${0}",
"END_IF;"
],
"description": "Insert TON call template"
}
}

View File

@@ -0,0 +1,10 @@
import { JsonObject } from "../types";
export function buildDefaultSyncPayload(projectPath: string): JsonObject {
return {
project_path: projectPath,
textual_updates: [],
io_mappings: [],
device_params: []
};
}

View File

@@ -0,0 +1,243 @@
import * as vscode from "vscode";
import { CompanionSettings, resolveBridgeScriptPath } from "../config";
import { Logger } from "../logging";
import { McpClient } from "../mcp/client";
import { JsonObject, McpCallResult } from "../types";
export interface CodesysCallOptions {
dryRun?: boolean;
poll?: boolean;
}
export interface JobRecord {
id: string;
action: string;
status: string;
detail?: string;
}
export class CodesysService {
private client?: McpClient;
private connected = false;
private readonly jobs = new Map<string, JobRecord>();
constructor(
private readonly logger: Logger,
private readonly settings: CompanionSettings,
private readonly diagnostics: vscode.DiagnosticCollection
) {}
public getJobs(): JobRecord[] {
return Array.from(this.jobs.values()).sort((a, b) => b.id.localeCompare(a.id));
}
public isConnected(): boolean {
return this.connected;
}
public async connect(): Promise<void> {
if (this.settings.mockMode) {
this.connected = true;
this.logger.info("Mock mode enabled. MCP process is not started.");
return;
}
if (this.connected && this.client) {
return;
}
const bridgeScriptPath = resolveBridgeScriptPath(this.settings);
const client = new McpClient({
pythonPath: this.settings.pythonPath,
bridgeScriptPath,
environment: {
CODESYS_AGENT_URL: this.settings.agentUrl,
CODESYS_AGENT_TOKEN: this.settings.agentToken,
CODESYS_AGENT_REQUEST_TIMEOUT_SEC: String(this.settings.requestTimeoutSec)
},
logger: this.logger
});
await client.connect();
await client.listTools();
this.client = client;
this.connected = true;
}
public async disconnect(): Promise<void> {
await this.client?.disconnect();
this.client = undefined;
this.connected = false;
}
public async call(action: string, payload: JsonObject, options: CodesysCallOptions = {}): Promise<JsonObject> {
if (this.settings.mockMode) {
return this.mockCall(action, payload, options);
}
await this.connect();
const mergedPayload: JsonObject = { ...payload };
if (options.dryRun !== undefined) {
mergedPayload.dry_run = options.dryRun;
}
const result = await this.executeTool(action, mergedPayload);
const jobId = extractJobId(result);
if (!jobId || !options.poll) {
return result;
}
this.jobs.set(jobId, { id: jobId, action, status: "queued", detail: "submitted" });
return this.pollJob(action, jobId);
}
private async mockCall(action: string, payload: JsonObject, options: CodesysCallOptions): Promise<JsonObject> {
if (action === "codesys_health") {
return { ok: true, service: "codesys-vm-agent", mocked: true };
}
if (action === "codesys_get_job") {
return {
id: String(payload.job_id ?? "mock-job"),
action: "mock_action",
status: "succeeded",
result: { mocked: true }
};
}
const dryRun = typeof options.dryRun === "boolean" ? options.dryRun : this.settings.defaultDryRunMutations;
return {
mocked: true,
action,
payload,
dry_run: dryRun,
job_id: `mock-${Date.now()}`
};
}
public async codesysHealth(): Promise<JsonObject> {
return this.call("codesys_health", {}, { poll: false });
}
public async codesysSubmitJob(payload: JsonObject): Promise<JsonObject> {
return this.call("codesys_submit_job", payload, { poll: false });
}
public async codesysGetJob(payload: JsonObject): Promise<JsonObject> {
return this.call("codesys_get_job", payload, { poll: false });
}
public async createProject(payload: JsonObject, options: CodesysCallOptions): Promise<JsonObject> {
return this.call("create_project", payload, { ...options, poll: true });
}
public async stageExistingProject(payload: JsonObject, options: CodesysCallOptions): Promise<JsonObject> {
return this.call("stage_existing_project", payload, options);
}
public async readProjectInventory(payload: JsonObject): Promise<JsonObject> {
return this.call("read_project_inventory", payload, { poll: true });
}
public async readDeviceIo(payload: JsonObject): Promise<JsonObject> {
return this.call("read_device_io", payload, { poll: true });
}
public async writePou(payload: JsonObject, options: CodesysCallOptions): Promise<JsonObject> {
return this.call("write_pou", payload, { ...options, poll: true });
}
public async writeIoMapping(payload: JsonObject, options: CodesysCallOptions): Promise<JsonObject> {
return this.call("write_io_mapping", payload, { ...options, poll: true });
}
public async manageDevice(payload: JsonObject, options: CodesysCallOptions): Promise<JsonObject> {
return this.call("manage_device", payload, { ...options, poll: true });
}
public async syncProject(payload: JsonObject, options: CodesysCallOptions): Promise<JsonObject> {
return this.call("sync_project", payload, { ...options, poll: true });
}
public async buildProject(payload: JsonObject): Promise<JsonObject> {
return this.call("build_project", payload, { poll: true });
}
public async downloadToDevice(payload: JsonObject): Promise<JsonObject> {
return this.call("download_to_device", payload, { poll: true });
}
public async pollJob(action: string, jobId: string): Promise<JsonObject> {
const pollInterval = Math.max(500, this.settings.pollIntervalMs);
while (true) {
const result = await this.executeTool("codesys_get_job", { job_id: jobId });
const status = String(result.status ?? "unknown");
this.jobs.set(jobId, {
id: jobId,
action,
status,
detail: status === "failed" ? String(result.error ?? "Unknown error") : undefined
});
if (status === "succeeded" || status === "failed") {
if (status === "failed") {
this.raiseDiagnostic(String(result.error ?? `Job ${jobId} failed.`));
} else {
this.clearDiagnostics();
}
return result;
}
await sleep(pollInterval);
}
}
private async executeTool(tool: string, payload: JsonObject): Promise<JsonObject> {
const response = await this.client?.callTool(tool, payload);
if (!response) {
throw new Error(`No response from MCP tool ${tool}`);
}
const parsed = this.parseToolResult(response);
return parsed;
}
private parseToolResult(response: McpCallResult): JsonObject {
if (response.structuredContent) {
return response.structuredContent;
}
const text = response.content?.find((item) => item.type === "text")?.text;
if (!text) {
return {};
}
try {
return JSON.parse(text) as JsonObject;
} catch {
return { text };
}
}
private raiseDiagnostic(message: string): void {
const wsFolder = vscode.workspace.workspaceFolders?.[0]?.uri;
if (!wsFolder) {
return;
}
const uri = vscode.Uri.joinPath(wsFolder, ".codesys-mcp-errors");
const diagnostic = new vscode.Diagnostic(
new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 1)),
message,
vscode.DiagnosticSeverity.Error
);
diagnostic.source = "codesys-mcp";
this.diagnostics.set(uri, [diagnostic]);
}
private clearDiagnostics(): void {
this.diagnostics.clear();
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function extractJobId(result: JsonObject): string | undefined {
const id = result.job_id;
return typeof id === "string" && id.length > 0 ? id : undefined;
}

View File

@@ -0,0 +1,296 @@
import * as path from "path";
import * as vscode from "vscode";
import { CompanionSettings } from "./config";
import { buildDefaultSyncPayload } from "./codesys/payloads";
import { CodesysService } from "./codesys/service";
import { Logger } from "./logging";
import { ExtensionState } from "./state";
import { JsonObject } from "./types";
import { ActionsViewProvider, JobsViewProvider, ProjectsViewProvider } from "./views";
interface CommandDeps {
service: CodesysService;
logger: Logger;
state: ExtensionState;
settings: CompanionSettings;
status: vscode.StatusBarItem;
projectsView: ProjectsViewProvider;
jobsView: JobsViewProvider;
actionsView: ActionsViewProvider;
}
export function registerCommands(context: vscode.ExtensionContext, deps: CommandDeps): void {
const register = (name: string, callback: () => Promise<void>) =>
context.subscriptions.push(
vscode.commands.registerCommand(name, async () => {
try {
await callback();
deps.jobsView.refresh();
deps.projectsView.refresh();
deps.actionsView.refresh();
updateStatus(deps);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.logger.error(message);
vscode.window.showErrorMessage(`CODESYS: ${message}`);
}
})
);
register("codesys.connect", async () => {
await deps.service.connect();
deps.logger.info("Connected to MCP bridge.");
vscode.window.showInformationMessage("Connected to CODESYS MCP bridge.");
});
register("codesys.health", async () => {
const result = await deps.service.codesysHealth();
deps.logger.info(`health: ${JSON.stringify(result)}`);
vscode.window.showInformationMessage(`CODESYS health ok=${String(result.ok ?? "unknown")}`);
});
register("codesys.createProject", async () => {
const projectName = await ask("Project name", "NewProject");
const outputDir = await ask("Output directory", "C:\\\\Temp\\\\codesys-projects");
if (!projectName || !outputDir) {
return;
}
const template = (await ask("Template", "standard")) ?? "standard";
const dryRun = await askDryRun(deps.settings.defaultDryRunMutations);
const result = await deps.service.createProject(
{ project_name: projectName, output_dir: outputDir, template },
{ dryRun }
);
deps.logger.info(`create_project: ${JSON.stringify(result)}`);
});
register("codesys.selectProject", async () => {
const value = await vscode.window.showInputBox({
prompt: "Project path (.project/.projectarchive or folder)",
value: deps.state.activeProjectPath || deps.settings.defaultProjectPath
});
if (!value) {
return;
}
await deps.state.setActiveProjectPath(value);
});
register("codesys.stageExistingProject", async () => {
const sourcePath = await ask("Source path", "");
const projectsRootDir = await ask("Projects root dir", "");
if (!sourcePath || !projectsRootDir) {
return;
}
const projectFolderName = (await ask("Project folder name (optional)", "")) ?? "";
const move = await askQuickPickBoolean("Move source instead of copy?", false);
const overwrite = await askQuickPickBoolean("Overwrite existing destination?", false);
const dryRun = await askDryRun(deps.settings.defaultDryRunMutations);
const result = await deps.service.stageExistingProject(
{ source_path: sourcePath, projects_root_dir: projectsRootDir, project_folder_name: projectFolderName, move, overwrite },
{ dryRun, poll: true }
);
deps.logger.info(`stage_existing_project: ${JSON.stringify(result)}`);
});
register("codesys.readProjectInventory", async () => {
const projectPath = await requireProjectPath(deps);
if (!projectPath) {
return;
}
const maxDepth = Number.parseInt((await ask("max_depth", "3")) ?? "3", 10);
const result = await deps.service.readProjectInventory({ project_path: projectPath, max_depth: Number.isFinite(maxDepth) ? maxDepth : 3 });
deps.logger.info(`read_project_inventory: ${JSON.stringify(result)}`);
});
register("codesys.readDeviceIo", async () => {
const projectPath = await requireProjectPath(deps);
if (!projectPath) {
return;
}
const channelsOnly = await askQuickPickBoolean("Read channels only?", true);
const maxDepth = Number.parseInt((await ask("max_depth", "10")) ?? "10", 10);
const result = await deps.service.readDeviceIo({
project_path: projectPath,
channels_only: channelsOnly,
max_depth: Number.isFinite(maxDepth) ? maxDepth : 10
});
deps.logger.info(`read_device_io: ${JSON.stringify(result)}`);
});
register("codesys.writePou", async () => {
const projectPath = await requireProjectPath(deps);
if (!projectPath) {
return;
}
const objectPath = await ask("Object path", "");
const declaration = await ask("Declaration text (optional)", "");
const implementation = await ask("Implementation text (optional)", "");
if (!objectPath) {
return;
}
const dryRun = await askDryRun(deps.settings.defaultDryRunMutations);
const result = await deps.service.writePou(
{ project_path: projectPath, object_path: objectPath, declaration: declaration ?? "", implementation: implementation ?? "" },
{ dryRun }
);
deps.logger.info(`write_pou: ${JSON.stringify(result)}`);
});
register("codesys.writeIoMapping", async () => {
const projectPath = await requireProjectPath(deps);
if (!projectPath) {
return;
}
const devicePath = await ask("Device path", "");
const paramId = await ask("Param id", "");
const variable = await ask("IEC variable", "");
if (!devicePath || !paramId || !variable) {
return;
}
const dryRun = await askDryRun(deps.settings.defaultDryRunMutations);
const mappings = [{ device_path: devicePath, param_id: paramId, variable }];
const result = await deps.service.writeIoMapping(
{ project_path: projectPath, mappings },
{ dryRun }
);
deps.logger.info(`write_io_mapping: ${JSON.stringify(result)}`);
});
register("codesys.manageDevice", async () => {
const projectPath = await requireProjectPath(deps);
if (!projectPath) {
return;
}
const operationText = await ask("Operation JSON (single operation object)", '{"action_type":"rename","device_path":"Device/X","new_name":"X"}');
if (!operationText) {
return;
}
const operation = JSON.parse(operationText) as JsonObject;
const dryRun = await askDryRun(deps.settings.defaultDryRunMutations);
const result = await deps.service.manageDevice({ project_path: projectPath, operations: [operation] }, { dryRun });
deps.logger.info(`manage_device: ${JSON.stringify(result)}`);
});
register("codesys.syncProject", async () => {
const projectPath = await requireProjectPath(deps);
if (!projectPath) {
return;
}
const dryRun = await askDryRun(deps.settings.defaultDryRunMutations);
const result = await deps.service.syncProject(buildDefaultSyncPayload(projectPath), { dryRun });
deps.logger.info(`sync_project: ${JSON.stringify(result)}`);
});
register("codesys.buildProject", async () => {
const projectPath = await requireProjectPath(deps);
if (!projectPath) {
return;
}
const result = await deps.service.buildProject({ project_path: projectPath });
deps.logger.info(`build_project: ${JSON.stringify(result)}`);
});
register("codesys.downloadToDevice", async () => {
if (!deps.settings.allowDownloadCommand) {
vscode.window.showWarningMessage("Download command disabled by setting codesys.allowDownloadCommand.");
return;
}
const projectPath = await requireProjectPath(deps);
if (!projectPath) {
return;
}
const targetName = await ask("Target name", "");
if (!targetName) {
return;
}
const startApplication = await askQuickPickBoolean("Start application after download?", false);
const result = await deps.service.downloadToDevice(
{ project_path: projectPath, target_name: targetName, start_application: startApplication },
);
deps.logger.info(`download_to_device: ${JSON.stringify(result)}`);
});
register("codesys.refreshJobs", async () => {
deps.jobsView.refresh();
deps.logger.info("Jobs view refreshed.");
});
register("codesys.openOutput", async () => {
deps.logger.show();
});
register("codesys.syncCurrentFile", async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return;
}
const projectPath = await requireProjectPath(deps);
if (!projectPath) {
return;
}
const absFilePath = editor.document.uri.fsPath;
const objectPath = inferCodesysObjectPath(absFilePath);
const declaration = editor.document.getText();
const dryRun = await askDryRun(deps.settings.defaultDryRunMutations);
const result = await deps.service.writePou(
{ project_path: projectPath, object_path: objectPath, declaration, implementation: "" },
{ dryRun }
);
deps.logger.info(`syncCurrentFile write_pou: ${JSON.stringify(result)}`);
});
updateStatus(deps);
}
function updateStatus(deps: CommandDeps): void {
deps.status.text = deps.service.isConnected() ? "CODESYS: Connected" : "CODESYS: Disconnected";
deps.status.tooltip = deps.state.activeProjectPath || "No active project selected";
}
async function requireProjectPath(deps: CommandDeps): Promise<string | undefined> {
if (deps.state.activeProjectPath) {
return deps.state.activeProjectPath;
}
const fallback = deps.settings.defaultProjectPath;
const chosen = await vscode.window.showInputBox({
prompt: "Project path",
value: fallback
});
if (!chosen) {
return undefined;
}
await deps.state.setActiveProjectPath(chosen);
return chosen;
}
async function ask(prompt: string, value: string): Promise<string | undefined> {
return vscode.window.showInputBox({ prompt, value });
}
async function askQuickPickBoolean(prompt: string, defaultValue: boolean): Promise<boolean> {
const defaultLabel = defaultValue ? "yes" : "no";
const selected = await vscode.window.showQuickPick(["yes", "no"], { placeHolder: `${prompt} (default: ${defaultLabel})` });
return (selected ?? defaultLabel) === "yes";
}
async function askDryRun(defaultValue: boolean): Promise<boolean> {
return askQuickPickBoolean("Run in dry-run mode?", defaultValue);
}
function inferCodesysObjectPath(absFilePath: string): string {
const marker = `${path.sep}codesys_tree${path.sep}`;
const index = absFilePath.indexOf(marker);
if (index < 0) {
return path.basename(absFilePath);
}
const relative = absFilePath.slice(index + marker.length).replaceAll(path.sep, "/");
const withoutExt = relative
.replace(/\.program\.st$/i, "")
.replace(/\.fb\.st$/i, "")
.replace(/\.type\.st$/i, "")
.replace(/\.gvl$/i, "")
.replace(/\.nvl$/i, "")
.replace(/\.st$/i, "");
return withoutExt;
}

View File

@@ -0,0 +1,44 @@
import * as path from "path";
import * as vscode from "vscode";
export interface CompanionSettings {
pythonPath: string;
bridgeScriptPath: string;
agentUrl: string;
agentToken: string;
requestTimeoutSec: number;
pollIntervalMs: number;
defaultProjectPath: string;
defaultDryRunMutations: boolean;
allowDownloadCommand: boolean;
mockMode: boolean;
}
export function readSettings(): CompanionSettings {
const cfg = vscode.workspace.getConfiguration("codesys");
return {
pythonPath: cfg.get<string>("pythonPath", "python"),
bridgeScriptPath: cfg.get<string>("bridgeScriptPath", ""),
agentUrl: cfg.get<string>("agentUrl", "http://127.0.0.1:8787"),
agentToken: cfg.get<string>("agentToken", ""),
requestTimeoutSec: cfg.get<number>("requestTimeoutSec", 30),
pollIntervalMs: cfg.get<number>("pollIntervalMs", 1500),
defaultProjectPath: cfg.get<string>("defaultProjectPath", ""),
defaultDryRunMutations: cfg.get<boolean>("defaultDryRunMutations", true),
allowDownloadCommand: cfg.get<boolean>("allowDownloadCommand", false),
mockMode: cfg.get<boolean>("mockMode", false)
};
}
export function resolveBridgeScriptPath(settings: CompanionSettings): string {
if (settings.bridgeScriptPath.trim()) {
return settings.bridgeScriptPath.trim();
}
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (root) {
return path.resolve(root, "python_scripting", "mcp_codesys_bridge", "server.py");
}
throw new Error("No workspace open and codesys.bridgeScriptPath is empty.");
}

View File

@@ -0,0 +1,51 @@
import * as vscode from "vscode";
import { registerCommands } from "./commands";
import { readSettings } from "./config";
import { CodesysService } from "./codesys/service";
import { Logger } from "./logging";
import { ExtensionState } from "./state";
import { ActionsViewProvider, JobsViewProvider, ProjectsViewProvider } from "./views";
export async function activate(context: vscode.ExtensionContext): Promise<void> {
const logger = new Logger();
const settings = readSettings();
const diagnostics = vscode.languages.createDiagnosticCollection("codesys-mcp");
const service = new CodesysService(logger, settings, diagnostics);
const state = new ExtensionState(context);
const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 20);
status.command = "codesys.openOutput";
status.text = "CODESYS: Disconnected";
status.show();
const projectsView = new ProjectsViewProvider(state);
const jobsView = new JobsViewProvider(service);
const actionsView = new ActionsViewProvider();
context.subscriptions.push(
logger,
diagnostics,
status,
vscode.window.registerTreeDataProvider("codesysProjectsView", projectsView),
vscode.window.registerTreeDataProvider("codesysJobsView", jobsView),
vscode.window.registerTreeDataProvider("codesysActionsView", actionsView),
{
dispose: () => {
void service.disconnect();
}
}
);
registerCommands(context, {
service,
logger,
state,
settings,
status,
projectsView,
jobsView,
actionsView
});
}
export function deactivate(): void {}

View File

@@ -0,0 +1,29 @@
import * as vscode from "vscode";
export class Logger {
private readonly output: vscode.OutputChannel;
constructor() {
this.output = vscode.window.createOutputChannel("CODESYS MCP Companion");
}
public info(message: string): void {
this.output.appendLine(`[INFO] ${new Date().toISOString()} ${message}`);
}
public warn(message: string): void {
this.output.appendLine(`[WARN] ${new Date().toISOString()} ${message}`);
}
public error(message: string): void {
this.output.appendLine(`[ERROR] ${new Date().toISOString()} ${message}`);
}
public show(): void {
this.output.show(true);
}
public dispose(): void {
this.output.dispose();
}
}

View File

@@ -0,0 +1,190 @@
import { ChildProcessWithoutNullStreams, spawn } from "child_process";
import * as fs from "fs";
import { Logger } from "../logging";
import { JsonObject, JsonValue, McpCallResult, McpTool } from "../types";
interface PendingRequest {
resolve: (value: JsonValue) => void;
reject: (reason?: unknown) => void;
}
interface McpClientOptions {
pythonPath: string;
bridgeScriptPath: string;
environment: Record<string, string>;
logger: Logger;
}
export class McpClient {
private process?: ChildProcessWithoutNullStreams;
private readonly pending = new Map<number, PendingRequest>();
private readonly logger: Logger;
private nextRequestId = 1;
private readBuffer = Buffer.alloc(0);
private initialized = false;
constructor(private readonly options: McpClientOptions) {
this.logger = options.logger;
}
public async connect(): Promise<void> {
if (this.initialized) {
return;
}
if (!fs.existsSync(this.options.bridgeScriptPath)) {
throw new Error(`Bridge script not found: ${this.options.bridgeScriptPath}`);
}
this.process = spawn(this.options.pythonPath, [this.options.bridgeScriptPath], {
env: {
...process.env,
...this.options.environment
},
stdio: "pipe"
});
this.process.stderr.on("data", (chunk: Buffer) => {
this.logger.warn(`MCP stderr: ${chunk.toString("utf8").trim()}`);
});
this.process.on("exit", (code) => {
this.logger.warn(`MCP process exited: ${code ?? -1}`);
this.initialized = false;
});
this.process.stdout.on("data", (chunk: Buffer) => this.handleStdout(chunk));
await this.request("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: {
name: "codesys-mcp-companion",
version: "0.1.0"
}
});
await this.notify("notifications/initialized", {});
this.initialized = true;
this.logger.info("MCP connection initialized.");
}
public async disconnect(): Promise<void> {
if (!this.process) {
return;
}
this.process.kill();
this.process = undefined;
this.pending.clear();
this.initialized = false;
}
public async listTools(): Promise<McpTool[]> {
const response = (await this.request("tools/list", {})) as JsonObject;
const tools = response.tools as JsonValue[] | undefined;
if (!Array.isArray(tools)) {
return [];
}
return tools as unknown as McpTool[];
}
public async callTool(name: string, argumentsPayload: JsonObject): Promise<McpCallResult> {
const response = (await this.request("tools/call", {
name,
arguments: argumentsPayload
})) as JsonObject;
return response as unknown as McpCallResult;
}
private async notify(method: string, params: JsonObject): Promise<void> {
this.ensureConnected();
this.writeMessage({ jsonrpc: "2.0", method, params });
}
private async request(method: string, params: JsonObject): Promise<JsonValue> {
this.ensureConnected();
const id = this.nextRequestId++;
const payload = {
jsonrpc: "2.0",
id,
method,
params
};
const responsePromise = new Promise<JsonValue>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
setTimeout(() => {
if (this.pending.has(id)) {
this.pending.delete(id);
reject(new Error(`MCP request timeout (${method})`));
}
}, 60_000);
});
this.writeMessage(payload);
return responsePromise;
}
private ensureConnected(): void {
if (!this.process || !this.process.stdin.writable) {
throw new Error("MCP process is not connected.");
}
}
private writeMessage(message: JsonObject): void {
const raw = JSON.stringify(message);
const framed = `Content-Length: ${Buffer.byteLength(raw, "utf8")}\r\n\r\n${raw}`;
this.process?.stdin.write(framed, "utf8");
}
private handleStdout(chunk: Buffer): void {
this.readBuffer = Buffer.concat([this.readBuffer, chunk]);
while (true) {
const splitMarker = this.readBuffer.indexOf("\r\n\r\n");
if (splitMarker === -1) {
return;
}
const header = this.readBuffer.subarray(0, splitMarker).toString("utf8");
const contentLengthLine = header
.split("\r\n")
.find((line) => line.toLowerCase().startsWith("content-length:"));
if (!contentLengthLine) {
this.logger.warn("Dropping MCP frame with no Content-Length.");
this.readBuffer = this.readBuffer.subarray(splitMarker + 4);
continue;
}
const lengthText = contentLengthLine.split(":")[1]?.trim() ?? "0";
const length = Number.parseInt(lengthText, 10);
const bodyStart = splitMarker + 4;
const bodyEnd = bodyStart + length;
if (this.readBuffer.length < bodyEnd) {
return;
}
const bodyRaw = this.readBuffer.subarray(bodyStart, bodyEnd).toString("utf8");
this.readBuffer = this.readBuffer.subarray(bodyEnd);
this.handleMessage(bodyRaw);
}
}
private handleMessage(raw: string): void {
try {
const message = JSON.parse(raw) as {
id?: number;
result?: JsonValue;
error?: { message?: string };
};
if (typeof message.id === "number") {
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
this.pending.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message ?? "Unknown MCP error"));
return;
}
pending.resolve(message.result ?? null);
}
} catch (error) {
this.logger.error(`Failed to parse MCP message: ${String(error)}`);
}
}
}

View File

@@ -0,0 +1,15 @@
import * as vscode from "vscode";
const ACTIVE_PROJECT_KEY = "codesys.activeProjectPath";
export class ExtensionState {
constructor(private readonly context: vscode.ExtensionContext) {}
public get activeProjectPath(): string {
return this.context.globalState.get<string>(ACTIVE_PROJECT_KEY, "");
}
public async setActiveProjectPath(path: string): Promise<void> {
await this.context.globalState.update(ACTIVE_PROJECT_KEY, path);
}
}

View File

@@ -0,0 +1,19 @@
import * as path from "path";
import { runTests } from "@vscode/test-electron";
async function main(): Promise<void> {
try {
const extensionDevelopmentPath = path.resolve(__dirname, "..", "..");
const extensionTestsPath = path.resolve(__dirname, "./suite/index");
await runTests({
extensionDevelopmentPath,
extensionTestsPath
});
} catch (error) {
console.error("Failed to run tests", error);
process.exit(1);
}
}
void main();

View File

@@ -0,0 +1,7 @@
import * as assert from "assert";
suite("Extension Smoke", () => {
test("sanity", () => {
assert.strictEqual(1 + 1, 2);
});
});

View File

@@ -0,0 +1,22 @@
import Mocha from "mocha";
import * as path from "path";
export function run(): Promise<void> {
const mocha = new Mocha({
ui: "tdd",
color: true
});
const testsRoot = path.resolve(__dirname, "..", "suite");
return new Promise((resolve, reject) => {
mocha.addFile(path.resolve(testsRoot, "extension.test.js"));
mocha.addFile(path.resolve(testsRoot, "payloads.test.js"));
mocha.run((failures: number) => {
if (failures > 0) {
reject(new Error(`${failures} tests failed.`));
return;
}
resolve();
});
});
}

View File

@@ -0,0 +1,12 @@
import * as assert from "assert";
import { buildDefaultSyncPayload } from "../../codesys/payloads";
suite("Payloads", () => {
test("buildDefaultSyncPayload creates empty arrays", () => {
const payload = buildDefaultSyncPayload("C:\\project.project");
assert.strictEqual(payload.project_path, "C:\\project.project");
assert.deepStrictEqual(payload.textual_updates, []);
assert.deepStrictEqual(payload.io_mappings, []);
assert.deepStrictEqual(payload.device_params, []);
});
});

View File

@@ -0,0 +1,27 @@
export type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
export interface JsonObject {
[key: string]: JsonValue;
}
export interface McpTool {
name: string;
description?: string;
inputSchema?: JsonObject;
}
export interface McpCallResult {
content?: Array<{ type: string; text?: string }>;
structuredContent?: JsonObject;
isError?: boolean;
}
export interface JobSummary {
id: string;
action: string;
status: string;
created_at: number;
started_at?: number;
finished_at?: number;
error?: string | null;
}

View File

@@ -0,0 +1,91 @@
import * as vscode from "vscode";
import { CodesysService, JobRecord } from "./codesys/service";
import { ExtensionState } from "./state";
export class SimpleItem extends vscode.TreeItem {
constructor(label: string, description?: string, command?: vscode.Command) {
super(label, vscode.TreeItemCollapsibleState.None);
this.description = description;
this.command = command;
}
}
export class ProjectsViewProvider implements vscode.TreeDataProvider<SimpleItem> {
private emitter = new vscode.EventEmitter<SimpleItem | undefined>();
public readonly onDidChangeTreeData = this.emitter.event;
constructor(private readonly state: ExtensionState) {}
public refresh(): void {
this.emitter.fire(undefined);
}
public getTreeItem(element: SimpleItem): vscode.TreeItem {
return element;
}
public getChildren(): Thenable<SimpleItem[]> {
const active = this.state.activeProjectPath || "(none)";
return Promise.resolve([
new SimpleItem("Active Project", active),
new SimpleItem("Select Project", "Pick a project path", {
title: "Select Project",
command: "codesys.selectProject"
})
]);
}
}
export class JobsViewProvider implements vscode.TreeDataProvider<SimpleItem> {
private emitter = new vscode.EventEmitter<SimpleItem | undefined>();
public readonly onDidChangeTreeData = this.emitter.event;
constructor(private readonly service: CodesysService) {}
public refresh(): void {
this.emitter.fire(undefined);
}
public getTreeItem(element: SimpleItem): vscode.TreeItem {
return element;
}
public getChildren(): Thenable<SimpleItem[]> {
const jobs = this.service.getJobs();
if (jobs.length === 0) {
return Promise.resolve([new SimpleItem("No jobs yet")]);
}
return Promise.resolve(jobs.slice(0, 20).map(toJobItem));
}
}
export class ActionsViewProvider implements vscode.TreeDataProvider<SimpleItem> {
private emitter = new vscode.EventEmitter<SimpleItem | undefined>();
public readonly onDidChangeTreeData = this.emitter.event;
public refresh(): void {
this.emitter.fire(undefined);
}
public getTreeItem(element: SimpleItem): vscode.TreeItem {
return element;
}
public getChildren(): Thenable<SimpleItem[]> {
const make = (label: string, command: string) =>
new SimpleItem(label, undefined, { title: label, command });
return Promise.resolve([
make("Health Check", "codesys.health"),
make("Create Project", "codesys.createProject"),
make("Read Project Inventory", "codesys.readProjectInventory"),
make("Read Device I/O", "codesys.readDeviceIo"),
make("Sync Project", "codesys.syncProject"),
make("Build Project", "codesys.buildProject"),
make("Download To Device", "codesys.downloadToDevice")
]);
}
}
function toJobItem(job: JobRecord): SimpleItem {
return new SimpleItem(`${job.action}`, `${job.status} ${job.id.slice(0, 8)}`);
}

View File

@@ -0,0 +1,36 @@
{
"scopeName": "source.codesys.st",
"name": "CODESYS ST",
"patterns": [
{
"name": "comment.block.codesys",
"begin": "\\(\\*",
"end": "\\*\\)"
},
{
"name": "comment.line.double-slash.codesys",
"match": "//.*$"
},
{
"name": "keyword.control.codesys",
"match": "\\b(IF|THEN|ELSE|ELSIF|CASE|OF|FOR|TO|BY|DO|WHILE|REPEAT|UNTIL|RETURN|EXIT)\\b"
},
{
"name": "keyword.declaration.codesys",
"match": "\\b(PROGRAM|FUNCTION|FUNCTION_BLOCK|TYPE|STRUCT|END_STRUCT|VAR|VAR_INPUT|VAR_OUTPUT|VAR_IN_OUT|VAR_GLOBAL|VAR_TEMP|END_VAR|END_FUNCTION|END_FUNCTION_BLOCK|END_PROGRAM|END_TYPE)\\b"
},
{
"name": "constant.language.bool.codesys",
"match": "\\b(TRUE|FALSE)\\b"
},
{
"name": "constant.numeric.codesys",
"match": "\\b\\d+(?:\\.\\d+)?\\b"
},
{
"name": "string.quoted.double.codesys",
"begin": "\"",
"end": "\""
}
]
}

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"outDir": "out",
"lib": [
"ES2022"
],
"sourceMap": true,
"rootDir": "src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"moduleResolution": "node",
"types": [
"node",
"vscode"
],
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
".vscode-test"
]
}