diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f54a346..b6f8cef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -160,6 +160,7 @@ ETTerms/ └── Infrastructure/ ├── AppLogger.cs # 日誌 (port 自 MyTeraTerm) ├── AppSettings.cs # 使用者偏好 (JSON, %LocalAppData%\ETTerms\settings.json) + ├── McpRegistrar.cs # ✅ 一鍵把 Serial MCP 註冊/移除到 Claude Code / Kiro 設定檔(Settings → AI MCP) └── NativeTheme.cs # 深色標題列 (DWM) │ └── ETTerms.SerialMcp/ # ✅ Serial MCP server(stdio)——不自己開 port,經 named pipe 橋接 GUI @@ -411,7 +412,11 @@ ETTerms GUI(單一行程,唯一開 COM3 的人) - **可視性:** AI 的 TX 在 GUI 以 `[AI]` 標色,與使用者手打的輸入區分;RX 兩邊同源。 - **安全:** 本機、無雲、不碰 credential;pipe 僅限本機行程,只搬 serial bytes。 -### 註冊(Kiro CLI) +### 註冊(兩種方式) + +**方式 A — 一鍵設定(推薦,v0.2.1):** GUI **Settings → AI MCP** 分頁,對 Claude Code / Kiro 各按 **Setup** 即可。`McpRegistrar` 以 read-modify-write 把 `etterms-serial` 寫進該 CLI 的使用者層級設定檔(Claude Code:`~/.claude.json`;Kiro:`~/.kiro/settings/mcp.json`),保留檔內其他既有 MCP server,原子寫回避免壞檔。註冊的執行檔路徑指向 `\ETTerms.SerialMcp\ETTerms.SerialMcp.exe`(與 publish 慣例對齊,必定存在);卡片同時顯示 CLI 驗證指令,按 **Remove** 可移除。 + +**方式 B — 手動 CLI:** ```powershell kiro-cli mcp add --name serial --command dotnet ` @@ -508,7 +513,8 @@ ETTerms_v0.2.0\ ``` **規則:** -1. **GUI 與 MCP 都發佈**:主程式發到 `ETTerms_v{Version}\`,`ETTerms.SerialMcp` 發到其下 **`ETTerms.SerialMcp\` 子資料夾**(兩者相依 dll 不互相覆蓋)。 +1. **GUI publish 會自動帶上 MCP**:`ETTerms.csproj` 有 `PublishSerialMcp` target(`AfterTargets="Publish"`),會把 `ETTerms.SerialMcp` 一併發佈到 `ETTerms_v{Version}\ETTerms.SerialMcp\` **子資料夾**(與 GUI 相依 dll 隔離)。因此**只要發佈 GUI 一個指令**即可,不必再單獨發 MCP。 + - 對齊 `McpRegistrar.ResolveServerExe()`:它解析的 `\ETTerms.SerialMcp\ETTerms.SerialMcp.exe` 因此**必定存在**,AI MCP 一鍵設定寫進去的路徑才不會落空。 2. **主 exe 改名**:`dotnet publish` 產生的 `ETTerms.exe` 重新命名為 **`ETTerms v{Version}.exe`**。 - 可安全改名:.NET apphost 內部記錄要載入的 `ETTerms.dll`,**不靠自身檔名**,改名後仍正常啟動。 3. **框架相依**:`--self-contained false -r win-x64`(目標機需已裝 .NET 8 Desktop Runtime)。 @@ -519,13 +525,9 @@ $ver = ([regex]::Match((Get-Content src\ETTerms\ETTerms.csproj -Raw), '([^<]+)')).Groups[1].Value $root = "src\ETTerms\Publish\ETTerms_v$ver" dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained false -o $root Rename-Item (Join-Path $root "ETTerms.exe") "ETTerms v$ver.exe" -dotnet publish src\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj -c Release -r win-x64 --self-contained false -o (Join-Path $root "ETTerms.SerialMcp") # 註冊 Serial MCP server(給 AI agent 操作 serial) +# 推薦:GUI Settings → AI MCP 分頁,對 Claude Code / Kiro 按 Setup 一鍵註冊(McpRegistrar)。 +# 或手動 CLI: kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj" ``` diff --git a/README.md b/README.md index 41fff74..09739c1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > A native Windows terminal workspace (C# .NET 8 WinForms) — **SSH**, **Serial Port**, and **local Shell (ConPTY)** in one window, with a **TTL scripting engine** ported from MyTeraTerm for automation, plus an optional **Serial MCP server** that lets AI agents (Kiro CLI / Claude CLI) drive the serial port directly. Standalone, no cloud, no login. -![version](https://img.shields.io/badge/version-0.1.0-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows&logoColor=white) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg?logo=dotnet&logoColor=white) ![UI](https://img.shields.io/badge/UI-WinForms-5C2D91.svg) ![SSH](https://img.shields.io/badge/SSH-SSH.NET-success.svg) ![Serial](https://img.shields.io/badge/Serial-System.IO.Ports-success.svg) ![status](https://img.shields.io/badge/status-WIP-orange.svg) ![license](https://img.shields.io/badge/license-MIT-green.svg) +![version](https://img.shields.io/badge/version-0.2.1-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows&logoColor=white) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg?logo=dotnet&logoColor=white) ![UI](https://img.shields.io/badge/UI-WinForms-5C2D91.svg) ![SSH](https://img.shields.io/badge/SSH-SSH.NET-success.svg) ![Serial](https://img.shields.io/badge/Serial-System.IO.Ports-success.svg) ![status](https://img.shields.io/badge/status-beta-yellow.svg) ![license](https://img.shields.io/badge/license-MIT-green.svg) --- @@ -65,10 +65,11 @@ - Control PDU outlets over SNMP (`SnmpSharpNet`) to power-cycle devices during tests - Script commands: `pduconnect` / `pductrl` -- 🤖 **AI / MCP integration (optional, planned)** +- 🤖 **AI / MCP integration (optional)** - The **GUI owns the COM port**; a standalone stdio **Serial MCP server** bridges to it over a local named pipe for **Kiro CLI / Claude CLI** - Tools: `serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach` - AI's serial TX/RX shows live in the GUI tagged `[AI]` — open the port in the GUI first, then let the AI attach + - **One-click setup** in **Settings → AI MCP**: register the server into Claude Code / Kiro with a single button - 📊 **Session RX logging** - `logopen` / `logwrite` / `logclose` write session output to file @@ -271,7 +272,7 @@ wait 'login: ' ## 🤖 AI / MCP Integration -> 🔜 **Planned (Phase 9).** Let AI agents (**Kiro CLI / Claude CLI**) send/receive on a serial port **while you watch it live in the ETTerms GUI**. +> Let AI agents (**Kiro CLI / Claude CLI**) send/receive on a serial port **while you watch it live in the ETTerms GUI**. **Key design: the GUI owns the COM port; the MCP server never opens it.** A COM port can be opened by only one process at a time, so rather than the MCP server grabbing it, the GUI is the sole owner and runs a local **named pipe server** (`SerialBridgeServer`). The standalone `ETTerms.SerialMcp` (launched by Kiro/Claude CLI) is a thin client: every `write` / `read` is forwarded over the pipe to the GUI, which does the real port I/O. AI's TX is echoed into the terminal tagged `[AI]`, so RX/TX flow through the GUI channel and what you see is exactly what the AI sees. @@ -287,7 +288,11 @@ Full closed loop — all inside the ETTerms GUI: | `serial_read` | waitFor?, timeoutMs? | Drain accumulated RX; optionally wait for a substring / timeout | | `serial_detach` | — | Unbind (does **not** close the GUI's port) | -Register in Kiro CLI: +### One-click setup (recommended) + +In **Settings → AI MCP**, click **Setup** on the Claude Code or Kiro card to register the Serial MCP server into that CLI's user-level config (`~/.claude.json` / `~/.kiro/settings/mcp.json`) with a single button. Your other MCP servers are preserved, and each card shows the CLI command to verify the connection. **Remove** unregisters it. + +### Manual setup ```powershell kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj" @@ -377,8 +382,8 @@ ETTerms/ │ ├── Sessions/ # Connection abstraction: ISessionChannel / SshChannel / SerialChannel / ShellChannel │ ├── Connections/ # Connection data: Connection / ConnectionStore(SQLite) / CredentialVault │ ├── Scripting/ # TTL engine: TTLInterpreter / ScriptRunner / GroupSyncContext / Pdu -│ └── Infrastructure/ # AppLogger / AppSettings / NativeTheme -└── src/ETTerms.SerialMcp/ # 🔜 Serial MCP server (stdio) — lets AI agents drive the serial port +│ └── Infrastructure/ # AppLogger / AppSettings / McpRegistrar / NativeTheme +└── src/ETTerms.SerialMcp/ # Serial MCP server (stdio) — lets AI agents drive the serial port ``` **Core design:** every connection implements `ISessionChannel` (`Write(byte[])` + `event DataReceived`). `TerminalView` and `TTLInterpreter` only know this abstraction, so SSH / Serial / Shell look identical to upper layers — the key to "one script engine driving multiple connection types." See [ARCHITECTURE.md](ARCHITECTURE.md). @@ -397,18 +402,25 @@ ETTerms/ ## 📜 Version History -### v0.1.0 (in development) +### v0.2.1 + +- **AI MCP one-click setup** — new **Settings → AI MCP** tab registers the Serial MCP server into Claude Code (`~/.claude.json`) or Kiro (`~/.kiro/settings/mcp.json`) with a single button; existing MCP servers are preserved, and each card shows the verify command +- Publish now auto-bundles `ETTerms.SerialMcp` into the app folder, so the registered path always resolves + +### v0.2.0 + +- **Phase 9: Serial MCP server** (`ETTerms.SerialMcp`) — AI agents (Kiro CLI / Claude CLI) send/receive on the serial port while you watch it live in the GUI +- GUI is the sole COM-port owner; the MCP server bridges over a local named pipe (`SerialBridgeServer`) and never opens the port itself +- Tools: `serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach`; AI's TX echoed into the terminal tagged `[AI]` +- New app icon (window / taskbar / executable); version shown in the title bar + +### v0.1.0 - Phases 1–8 complete: window shell, connection sidebar, tiling workspace, Serial / SSH / local Shell (ConPTY) / SFTP - Owner-drawn VT100 terminal rendering - TTL scripting engine (ported from MyTeraTerm) + Group sync execution - PDU power control (SNMP) - Settings / About -- Packaging TBD - -**Planned** - -- Phase 9: Serial MCP server (`ETTerms.SerialMcp`) — let AI agents (Kiro CLI / Claude CLI) drive the serial port directly --- diff --git a/README.zh-TW.md b/README.zh-TW.md index dc6c208..8b72846 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -4,7 +4,7 @@ > 原生 Windows 終端機工作台(C# .NET 8 WinForms)—— 一個視窗整合 **SSH**、**Serial Port**、**本機 Shell (ConPTY)** 連線,內建從 MyTeraTerm 移植的 **TTL 腳本引擎**做自動化,並提供選用的 **Serial MCP server**,讓 AI agent(Kiro CLI / Claude CLI)直接操作 serial port。單機、無雲、無登入系統。 -![version](https://img.shields.io/badge/version-0.1.0-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows&logoColor=white) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg?logo=dotnet&logoColor=white) ![UI](https://img.shields.io/badge/UI-WinForms-5C2D91.svg) ![SSH](https://img.shields.io/badge/SSH-SSH.NET-success.svg) ![Serial](https://img.shields.io/badge/Serial-System.IO.Ports-success.svg) ![status](https://img.shields.io/badge/status-WIP-orange.svg) ![license](https://img.shields.io/badge/license-MIT-green.svg) +![version](https://img.shields.io/badge/version-0.2.1-blue.svg) ![platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows&logoColor=white) ![.NET](https://img.shields.io/badge/.NET-8.0-512BD4.svg?logo=dotnet&logoColor=white) ![UI](https://img.shields.io/badge/UI-WinForms-5C2D91.svg) ![SSH](https://img.shields.io/badge/SSH-SSH.NET-success.svg) ![Serial](https://img.shields.io/badge/Serial-System.IO.Ports-success.svg) ![status](https://img.shields.io/badge/status-beta-yellow.svg) ![license](https://img.shields.io/badge/license-MIT-green.svg) --- @@ -65,10 +65,11 @@ - 透過 SNMP(`SnmpSharpNet`)控制 PDU 插座,測試中遠端電源循環 - 腳本指令:`pduconnect` / `pductrl` -- 🤖 **AI / MCP 整合(選用,規劃中)** +- 🤖 **AI / MCP 整合(選用)** - **COM port 由 GUI 持有**;獨立的 stdio **Serial MCP server** 經本機 named pipe 橋接過去,供 **Kiro CLI / Claude CLI** 使用 - 工具:`serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach` - AI 的 serial 收發會即時顯示在 GUI(標 `[AI]`)——先在 GUI 開好 port,再讓 AI attach + - **一鍵設定**:在 **設定 → AI MCP** 一個按鈕就把 server 註冊進 Claude Code / Kiro - 📊 **連線 RX 日誌** - `logopen` / `logwrite` / `logclose` 將工作階段輸出寫檔 @@ -271,7 +272,7 @@ wait 'login: ' ## 🤖 AI / MCP 整合 -> 🔜 **規劃中(Phase 9)。** 讓 AI agent(**Kiro CLI / Claude CLI**)收發 serial,**而且你能在 ETTerms GUI 即時看到 AI 的每筆收發**。 +> 讓 AI agent(**Kiro CLI / Claude CLI**)收發 serial,**而且你能在 ETTerms GUI 即時看到 AI 的每筆收發**。 **關鍵設計:COM port 由 GUI 唯一持有,MCP server 不自己開 port。** 一個 COM port 同時只能被一個行程開啟;因此不讓 MCP server 自己開,而是 **GUI 當 port 的唯一擁有者**,在 GUI 內跑一支本機 **named pipe server**(`SerialBridgeServer`)。獨立的 `ETTerms.SerialMcp`(由 Kiro/Claude CLI 啟動)退化成瘦客戶端:所有 `write` / `read` 都經 pipe 轉發給 GUI,由 GUI 代為讀寫實體 port。AI 的 TX 會以 `[AI]` 標色 echo 進終端機,RX/TX 都流經 GUI channel,**你看到的就是 AI 看到的**。 @@ -287,7 +288,11 @@ wait 'login: ' | `serial_read` | waitFor?, timeoutMs? | 取出累積的 RX;可等待子字串或逾時 | | `serial_detach` | — | 解除綁定(**不會**關閉 GUI 的 port) | -在 Kiro CLI 註冊: +### 一鍵設定(推薦) + +在 **設定 → AI MCP**,對 Claude Code 或 Kiro 卡片按 **Setup**,一個按鈕就把 Serial MCP server 寫進該 CLI 的使用者層級設定檔(`~/.claude.json` / `~/.kiro/settings/mcp.json`)。你既有的其他 MCP server 會被保留,卡片並顯示用來驗證連線的 CLI 指令。按 **Remove** 即可移除。 + +### 手動設定 ```powershell kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj" @@ -377,8 +382,8 @@ ETTerms/ │ ├── Sessions/ # 連線抽象:ISessionChannel / SshChannel / SerialChannel / ShellChannel │ ├── Connections/ # 連線資料:Connection / ConnectionStore(SQLite) / CredentialVault │ ├── Scripting/ # TTL 引擎:TTLInterpreter / ScriptRunner / GroupSyncContext / Pdu -│ └── Infrastructure/ # AppLogger / AppSettings / NativeTheme -└── src/ETTerms.SerialMcp/ # 🔜 Serial MCP server(stdio)—— 讓 AI agent 直接操作 serial port +│ └── Infrastructure/ # AppLogger / AppSettings / McpRegistrar / NativeTheme +└── src/ETTerms.SerialMcp/ # Serial MCP server(stdio)—— 讓 AI agent 直接操作 serial port ``` **核心設計:** 所有連線都實作 `ISessionChannel`(`Write(byte[])` + `event DataReceived`)。`TerminalView` 與 `TTLInterpreter` 只認得這個抽象,因此 SSH / Serial / Shell 對上層完全一致——這是「同一套腳本引擎驅動多種連線」的關鍵。詳見 [ARCHITECTURE.md](ARCHITECTURE.md)。 @@ -397,18 +402,25 @@ ETTerms/ ## 📜 版本紀錄 -### v0.1.0(開發中) +### v0.2.1 + +- **AI MCP 一鍵設定** —— 新增 **設定 → AI MCP** 分頁,一個按鈕就把 Serial MCP server 註冊進 Claude Code(`~/.claude.json`)或 Kiro(`~/.kiro/settings/mcp.json`);保留檔內其他既有 MCP server,卡片並顯示驗證指令 +- Publish 會自動把 `ETTerms.SerialMcp` 一併打包進 app 資料夾,註冊寫入的路徑必定存在 + +### v0.2.0 + +- **Phase 9:Serial MCP server**(`ETTerms.SerialMcp`)—— AI agent(Kiro CLI / Claude CLI)可在 serial port 收發,而你在 GUI 即時看著 +- COM port 由 GUI 唯一持有;MCP server 經本機 named pipe(`SerialBridgeServer`)橋接,自己不開 port +- 工具:`serial_list` / `serial_attach` / `serial_write` / `serial_read` / `serial_detach`;AI 的 TX 在終端機以 `[AI]` 標色 echo +- 新增 app 圖示(視窗 / 工作列 / 執行檔);標題列顯示版本號 + +### v0.1.0 - Phase 1–8 完成:視窗外殼、連線側欄、平鋪工作區、Serial / SSH / 本機 Shell (ConPTY) / SFTP - 自繪 VT100 終端機渲染 - TTL 腳本引擎(移植自 MyTeraTerm)+ Group 同步執行 - PDU 電源控制(SNMP) - Settings / About -- 打包待規劃 - -**規劃中** - -- Phase 9:Serial MCP server(`ETTerms.SerialMcp`)—— 讓 AI agent(Kiro CLI / Claude CLI)直接操作 serial port --- diff --git a/src/ETTerms/App/AboutView.cs b/src/ETTerms/App/AboutView.cs index f46bd8b..e1fd0a7 100644 --- a/src/ETTerms/App/AboutView.cs +++ b/src/ETTerms/App/AboutView.cs @@ -177,15 +177,15 @@ public sealed class AboutView : UserControl private static readonly ChangelogEntry[] Changelog = [ + new("0.2.1", new DateOnly(2026, 6, 4), "AI MCP one-click setup", + [ + "New Settings → AI MCP tab: register the Serial MCP server into Claude Code or Kiro with one click.", + "Lets AI agents drive the serial port for you.", + ]), new("0.2.0", new DateOnly(2026, 6, 4), "Beta Version Release", [ - "Phase 9: Serial MCP server — AI agents can send/receive serial while you watch live in the GUI.", - "GUI is the sole COM-port owner; the MCP server bridges over a local named pipe (never opens the port itself).", - "MCP tools: serial_list / serial_attach / serial_write / serial_read / serial_detach.", - "AI-sourced output is echoed into the terminal tagged [AI] so you see exactly what the AI sees.", - "New app icon for the window title bar, taskbar and executable; version now shown in the title bar.", - "About page now displays the app icon.", - "Beta Version: expect bugs and missing features. Feedback welcome!", + "AI agents can now send/receive on the serial port while you watch it live in the GUI.", + "New app icon and version shown in the title bar.", ]), new("0.1.0", new DateOnly(2026, 6, 3), "Initial Release", [ diff --git a/src/ETTerms/App/SettingsView.cs b/src/ETTerms/App/SettingsView.cs index d9a556f..e7b15b6 100644 --- a/src/ETTerms/App/SettingsView.cs +++ b/src/ETTerms/App/SettingsView.cs @@ -5,7 +5,7 @@ using ETTerms.Scripting.Pdu; namespace ETTerms.App; -/// Settings page with tabs: Terminal / PDU. +/// Settings page with tabs: Terminal / PDU / AI MCP. public sealed class SettingsView : UserControl { public SettingsView() @@ -20,19 +20,17 @@ public sealed class SettingsView : UserControl Padding = new Padding(4, 4, 4, 0), WrapContents = false }; - var terminalPage = BuildTerminalTab(); - var pduPage = BuildPduTab(); - terminalPage.Dock = DockStyle.Fill; - pduPage.Dock = DockStyle.Fill; - pduPage.Visible = false; - var body = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack }; - body.Controls.Add(terminalPage); - body.Controls.Add(pduPage); + var pages = new List(); Button? activeBtn = null; Button MakeTab(string text, Panel page) { + page.Dock = DockStyle.Fill; + page.Visible = false; + body.Controls.Add(page); + pages.Add(page); + var b = new Button { Text = text, AutoSize = false, Width = 80, Height = 26, FlatStyle = FlatStyle.Flat, @@ -43,8 +41,7 @@ public sealed class SettingsView : UserControl b.FlatAppearance.MouseOverBackColor = Theme.Hover; b.Click += (_, _) => { - terminalPage.Visible = page == terminalPage; - pduPage.Visible = page == pduPage; + foreach (var p in pages) p.Visible = p == page; if (activeBtn != null) activeBtn.BackColor = Theme.TabBack; b.BackColor = Theme.TabActiveBack; activeBtn = b; @@ -52,17 +49,16 @@ public sealed class SettingsView : UserControl return b; } - var termBtn = MakeTab("Terminal", terminalPage); - var pduBtn = MakeTab("PDU", pduPage); + var termBtn = MakeTab("Terminal", BuildTerminalTab()); tabBar.Controls.Add(termBtn); - tabBar.Controls.Add(pduBtn); - - // Set initial active - termBtn.BackColor = Theme.TabActiveBack; - activeBtn = termBtn; + tabBar.Controls.Add(MakeTab("PDU", BuildPduTab())); + tabBar.Controls.Add(MakeTab("AI MCP", BuildAiMcpTab())); Controls.Add(body); Controls.Add(tabBar); + + // Set initial active tab + termBtn.PerformClick(); } // ═══ Terminal Tab ═══ @@ -275,6 +271,166 @@ public sealed class SettingsView : UserControl } } + // ═══ AI MCP Tab ═══ + private Panel BuildAiMcpTab() + { + var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) }; + + var flow = new FlowLayoutPanel + { + Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, + WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true + }; + + flow.Controls.Add(new Label + { + Text = "AI MCP Integration", AutoSize = true, + ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4) + }); + flow.Controls.Add(new Label + { + Text = "One-click register the ETTerms Serial MCP server into your AI CLI's user-level\n" + + "config. ETTerms keeps sole ownership of the COM port; the AI drives serial through\n" + + "a local named pipe. Open a Serial session in ETTerms first, then the AI can attach.", + AutoSize = false, Width = 600, Height = 56, + ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 8) + }); + + // Resolved MCP server exe + var exe = McpRegistrar.ResolveServerExe(); + var exists = McpRegistrar.ServerExeExists(); + flow.Controls.Add(new Label + { + Text = $"MCP server: {exe}", + AutoSize = false, Width = 600, Height = 20, + ForeColor = exists ? Theme.SerialColor : Color.FromArgb(210, 150, 120), + Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 2) + }); + if (!exists) + { + flow.Controls.Add(new Label + { + Text = "⚠ Not found yet — publish the app (or build ETTerms.SerialMcp). Setup still writes this expected path.", + AutoSize = false, Width = 600, Height = 20, + ForeColor = Color.FromArgb(210, 150, 120), Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 4) + }); + } + + flow.Controls.Add(MakeSpacer(10)); + flow.Controls.Add(BuildMcpTargetCard(McpTarget.Claude)); + flow.Controls.Add(MakeSpacer(10)); + flow.Controls.Add(BuildMcpTargetCard(McpTarget.Kiro)); + + page.Controls.Add(flow); + return page; + } + + /// 單一 AI 目標(Claude / Kiro)的設定卡:狀態 + Setup / Remove + CLI 驗證指令。 + private Panel BuildMcpTargetCard(McpTarget target) + { + var card = new Panel + { + Width = 600, Height = 196, BackColor = Theme.TabBack, + Padding = new Padding(14), Margin = new Padding(0, 0, 0, 4) + }; + var col = new FlowLayoutPanel + { + Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, + WrapContents = false, BackColor = Theme.TabBack, AutoSize = false + }; + + var title = new Label + { + Text = McpRegistrar.DisplayName(target), AutoSize = true, + ForeColor = Theme.Text, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 2) + }; + var status = new Label { AutoSize = true, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 2) }; + var pathLbl = new Label + { + Text = $"Config: {McpRegistrar.ConfigPath(target)}", + AutoSize = false, Width = 560, Height = 18, + ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 6) + }; + + var setupBtn = MakeButton("Setup", Theme.Accent); + var removeBtn = MakeButton("Remove", Color.FromArgb(210, 120, 120)); + setupBtn.Margin = new Padding(0, 0, 8, 0); + var btnRow = new FlowLayoutPanel + { + FlowDirection = FlowDirection.LeftToRight, AutoSize = true, + WrapContents = false, BackColor = Theme.TabBack, Margin = new Padding(0, 0, 0, 8) + }; + btnRow.Controls.Add(setupBtn); + btnRow.Controls.Add(removeBtn); + + var verifyLbl = new Label + { + Text = "Verify in your CLI:", AutoSize = true, + ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 2) + }; + var verifyBox = new TextBox + { + Multiline = true, ReadOnly = true, Width = 560, Height = 56, + BackColor = Color.FromArgb(20, 20, 24), ForeColor = Color.FromArgb(200, 200, 200), + BorderStyle = BorderStyle.FixedSingle, Font = new Font("Cascadia Mono", 9f), + Text = McpRegistrar.VerifyHint(target) + }; + + void Refresh() + { + bool reg = McpRegistrar.IsRegistered(target); + status.Text = reg ? "● Configured" : "○ Not configured"; + status.ForeColor = reg ? Theme.SerialColor : Theme.TextDim; + removeBtn.Enabled = reg; + } + + setupBtn.Click += (_, _) => + { + try + { + McpRegistrar.Register(target); + Refresh(); + MessageBox.Show(this, + $"{McpRegistrar.DisplayName(target)} is now configured.\n\n" + + "Restart your AI CLI (or open a new session), then run the verify command shown below.", + "AI MCP", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Failed to write config:\n{ex.Message}", + "AI MCP", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + }; + + removeBtn.Click += (_, _) => + { + try + { + McpRegistrar.Unregister(target); + Refresh(); + MessageBox.Show(this, + $"Removed from {McpRegistrar.DisplayName(target)}.\nRestart your AI CLI for it to take effect.", + "AI MCP", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Failed to update config:\n{ex.Message}", + "AI MCP", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + }; + + Refresh(); + + col.Controls.Add(title); + col.Controls.Add(status); + col.Controls.Add(pathLbl); + col.Controls.Add(btnRow); + col.Controls.Add(verifyLbl); + col.Controls.Add(verifyBox); + card.Controls.Add(col); + return card; + } + // ── Helpers ── private const int LabelWidth = 150; // 標籤欄固定寬度 private const int InputWidth = 200; // 所有輸入框統一寬度 diff --git a/src/ETTerms/ETTerms.csproj b/src/ETTerms/ETTerms.csproj index b871262..baa5995 100644 --- a/src/ETTerms/ETTerms.csproj +++ b/src/ETTerms/ETTerms.csproj @@ -10,7 +10,7 @@ ETTerms - 0.2.0 + 0.2.1 ETTerms ETTerms Project @@ -32,4 +32,19 @@ + + + + <_McpRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier) + <_McpRid Condition="'$(RuntimeIdentifier)' == ''">win-x64 + + + + + \ No newline at end of file diff --git a/src/ETTerms/Infrastructure/McpRegistrar.cs b/src/ETTerms/Infrastructure/McpRegistrar.cs new file mode 100644 index 0000000..bb495c4 --- /dev/null +++ b/src/ETTerms/Infrastructure/McpRegistrar.cs @@ -0,0 +1,171 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ETTerms.Infrastructure; + +/// 支援一鍵設定 MCP 的 AI CLI 目標。 +public enum McpTarget { Claude, Kiro } + +/// +/// 把 ETTerms 的 Serial MCP server(ETTerms.SerialMcp)一鍵註冊 / 移除到 +/// 各 AI CLI 的「使用者層級」MCP 設定檔。採 read-modify-write,保留檔內其他既有伺服器。 +/// +/// - Claude Code:~/.claude.json 頂層 mcpServers,entry 需 type:"stdio"。 +/// - Kiro:%USERPROFILE%\.kiro\settings\mcp.json 頂層 mcpServers。 +/// +public static class McpRegistrar +{ + /// 註冊到各 CLI 時用的 MCP server 名稱。 + public const string ServerName = "etterms-serial"; + + public static string DisplayName(McpTarget t) => t switch + { + McpTarget.Claude => "Claude Code", + McpTarget.Kiro => "Kiro", + _ => t.ToString() + }; + + /// 該 AI CLI 的使用者層級 MCP 設定檔路徑。 + public static string ConfigPath(McpTarget t) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return t switch + { + McpTarget.Claude => Path.Combine(home, ".claude.json"), + McpTarget.Kiro => Path.Combine(home, ".kiro", "settings", "mcp.json"), + _ => throw new ArgumentOutOfRangeException(nameof(t)) + }; + } + + /// 給使用者在 CLI 確認是否設定成功的指令(多行)。 + public static string VerifyHint(McpTarget t) => t switch + { + McpTarget.Claude => + "claude mcp list\r\n" + + $"# 應看到:{ServerName} ✓ Connected\r\n" + + $"# 細節: claude mcp get {ServerName}", + McpTarget.Kiro => + "kiro-cli mcp list\r\n" + + $"kiro-cli mcp status --name {ServerName}\r\n" + + "# 或在 Kiro IDE:點 ghost 圖示開 MCP Servers 面板查看狀態", + _ => "" + }; + + /// 找出 ETTerms.SerialMcp 執行檔路徑(找不到回傳最可能的位置作為註冊值)。 + public static string ResolveServerExe() + { + var baseDir = AppContext.BaseDirectory; + var candidates = new List + { + Path.Combine(baseDir, "ETTerms.SerialMcp", "ETTerms.SerialMcp.exe"), // 發佈版(子資料夾) + Path.Combine(baseDir, "ETTerms.SerialMcp.exe"), // 同層 + }; + + // 開發版 fallback:src\ETTerms\bin\\net8.0-windows → src\ETTerms.SerialMcp\bin\\net8.0 + try + { + var binCfg = new DirectoryInfo(baseDir); // ...\net8.0-windows + var config = binCfg.Parent?.Name ?? "Debug"; // Debug / Release + var srcDir = binCfg.Parent?.Parent?.Parent?.Parent; // ...\src + if (srcDir != null) + candidates.Add(Path.Combine(srcDir.FullName, "ETTerms.SerialMcp", "bin", config, "net8.0", "ETTerms.SerialMcp.exe")); + } + catch { /* 路徑推導失敗就略過開發版 fallback */ } + + foreach (var c in candidates) + if (File.Exists(c)) return c; + return candidates[0]; // 都找不到 → 回發佈版預期位置 + } + + public static bool ServerExeExists() => File.Exists(ResolveServerExe()); + + /// 該目標是否已註冊 etterms-serial。 + public static bool IsRegistered(McpTarget t) + { + try + { + var path = ConfigPath(t); + if (!File.Exists(path)) return false; + var root = JsonNode.Parse(File.ReadAllText(path)) as JsonObject; + return (root?["mcpServers"] as JsonObject)?[ServerName] != null; + } + catch { return false; } + } + + /// 註冊(或更新)etterms-serial 到該目標設定檔。 + public static void Register(McpTarget t) + { + var path = ConfigPath(t); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + var root = LoadRoot(path); + if (root["mcpServers"] is not JsonObject servers) + { + servers = new JsonObject(); + root["mcpServers"] = servers; + } + servers[ServerName] = BuildEntry(t); + WriteRoot(path, root); + AppLogger.Info($"MCP registered to {DisplayName(t)} at {path}"); + } + + /// 從該目標設定檔移除 etterms-serial。 + public static void Unregister(McpTarget t) + { + var path = ConfigPath(t); + if (!File.Exists(path)) return; + var root = LoadRoot(path); + if (root["mcpServers"] is JsonObject servers && servers.Remove(ServerName)) + { + WriteRoot(path, root); + AppLogger.Info($"MCP unregistered from {DisplayName(t)} at {path}"); + } + } + + private static JsonObject BuildEntry(McpTarget t) + { + var exe = ResolveServerExe(); + return t switch + { + // Claude Code:stdio server 需 type 欄位 + McpTarget.Claude => new JsonObject + { + ["type"] = "stdio", + ["command"] = exe, + ["args"] = new JsonArray() + }, + // Kiro:local server,附 env / disabled / autoApprove 預設 + McpTarget.Kiro => new JsonObject + { + ["command"] = exe, + ["args"] = new JsonArray(), + ["env"] = new JsonObject(), + ["disabled"] = false, + ["autoApprove"] = new JsonArray() + }, + _ => throw new ArgumentOutOfRangeException(nameof(t)) + }; + } + + /// 讀入設定檔為可變 JSON 物件;不存在回空物件。檔案存在但格式錯誤則丟例外(不覆蓋使用者資料)。 + private static JsonObject LoadRoot(string path) + { + if (!File.Exists(path)) return new JsonObject(); + var text = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(text)) return new JsonObject(); + if (JsonNode.Parse(text) is JsonObject obj) return obj; + throw new InvalidDataException($"{path} 不是有效的 JSON 物件,為避免覆蓋資料已中止。請手動檢查該檔。"); + } + + /// 原子寫回(先寫 .tmp 再 replace),避免半寫壞檔。 + private static void WriteRoot(string path, JsonObject root) + { + var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + var tmp = path + ".tmp"; + File.WriteAllText(tmp, json); + if (File.Exists(path)) File.Replace(tmp, path, null); + else File.Move(tmp, path); + } +}