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:
23
vscode_codesys_companion/.eslintrc.json
Normal file
23
vscode_codesys_companion/.eslintrc.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
vscode_codesys_companion/.vscodeignore
Normal file
6
vscode_codesys_companion/.vscodeignore
Normal file
@@ -0,0 +1,6 @@
|
||||
src/**
|
||||
tsconfig.json
|
||||
.eslintrc.json
|
||||
**/*.map
|
||||
**/*.ts
|
||||
.vscode-test/**
|
||||
84
vscode_codesys_companion/README.md
Normal file
84
vscode_codesys_companion/README.md
Normal 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`
|
||||
56
vscode_codesys_companion/docs/smoke-test.md
Normal file
56
vscode_codesys_companion/docs/smoke-test.md
Normal 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
|
||||
47
vscode_codesys_companion/language-configuration.json
Normal file
47
vscode_codesys_companion/language-configuration.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"comments": {
|
||||
"lineComment": "//",
|
||||
"blockComment": [
|
||||
"(*",
|
||||
"*)"
|
||||
]
|
||||
},
|
||||
"brackets": [
|
||||
[
|
||||
"(",
|
||||
")"
|
||||
],
|
||||
[
|
||||
"[",
|
||||
"]"
|
||||
]
|
||||
],
|
||||
"autoClosingPairs": [
|
||||
{
|
||||
"open": "(",
|
||||
"close": ")"
|
||||
},
|
||||
{
|
||||
"open": "[",
|
||||
"close": "]"
|
||||
},
|
||||
{
|
||||
"open": "\"",
|
||||
"close": "\""
|
||||
}
|
||||
],
|
||||
"surroundingPairs": [
|
||||
[
|
||||
"(",
|
||||
")"
|
||||
],
|
||||
[
|
||||
"[",
|
||||
"]"
|
||||
],
|
||||
[
|
||||
"\"",
|
||||
"\""
|
||||
]
|
||||
]
|
||||
}
|
||||
4
vscode_codesys_companion/media/codesys.svg
Normal file
4
vscode_codesys_companion/media/codesys.svg
Normal 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
6192
vscode_codesys_companion/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
240
vscode_codesys_companion/package.json
Normal file
240
vscode_codesys_companion/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
46
vscode_codesys_companion/snippets/codesys-st.json
Normal file
46
vscode_codesys_companion/snippets/codesys-st.json
Normal 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"
|
||||
}
|
||||
}
|
||||
10
vscode_codesys_companion/src/codesys/payloads.ts
Normal file
10
vscode_codesys_companion/src/codesys/payloads.ts
Normal 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: []
|
||||
};
|
||||
}
|
||||
243
vscode_codesys_companion/src/codesys/service.ts
Normal file
243
vscode_codesys_companion/src/codesys/service.ts
Normal 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;
|
||||
}
|
||||
296
vscode_codesys_companion/src/commands.ts
Normal file
296
vscode_codesys_companion/src/commands.ts
Normal 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;
|
||||
}
|
||||
44
vscode_codesys_companion/src/config.ts
Normal file
44
vscode_codesys_companion/src/config.ts
Normal 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.");
|
||||
}
|
||||
51
vscode_codesys_companion/src/extension.ts
Normal file
51
vscode_codesys_companion/src/extension.ts
Normal 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 {}
|
||||
29
vscode_codesys_companion/src/logging.ts
Normal file
29
vscode_codesys_companion/src/logging.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
190
vscode_codesys_companion/src/mcp/client.ts
Normal file
190
vscode_codesys_companion/src/mcp/client.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
vscode_codesys_companion/src/state.ts
Normal file
15
vscode_codesys_companion/src/state.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
vscode_codesys_companion/src/test/runTest.ts
Normal file
19
vscode_codesys_companion/src/test/runTest.ts
Normal 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();
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as assert from "assert";
|
||||
|
||||
suite("Extension Smoke", () => {
|
||||
test("sanity", () => {
|
||||
assert.strictEqual(1 + 1, 2);
|
||||
});
|
||||
});
|
||||
22
vscode_codesys_companion/src/test/suite/index.ts
Normal file
22
vscode_codesys_companion/src/test/suite/index.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
12
vscode_codesys_companion/src/test/suite/payloads.test.ts
Normal file
12
vscode_codesys_companion/src/test/suite/payloads.test.ts
Normal 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, []);
|
||||
});
|
||||
});
|
||||
27
vscode_codesys_companion/src/types.ts
Normal file
27
vscode_codesys_companion/src/types.ts
Normal 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;
|
||||
}
|
||||
91
vscode_codesys_companion/src/views.ts
Normal file
91
vscode_codesys_companion/src/views.ts
Normal 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)}`);
|
||||
}
|
||||
36
vscode_codesys_companion/syntaxes/codesys-st.tmLanguage.json
Normal file
36
vscode_codesys_companion/syntaxes/codesys-st.tmLanguage.json
Normal 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": "\""
|
||||
}
|
||||
]
|
||||
}
|
||||
29
vscode_codesys_companion/tsconfig.json
Normal file
29
vscode_codesys_companion/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user