feat: AI MCP one-click setup in Settings (v0.2.1)
Add Settings -> AI MCP tab with McpRegistrar to one-click register/remove the Serial MCP server into Claude Code (~/.claude.json) and Kiro (~/.kiro/settings/mcp.json); read-modify-write preserves other servers, atomic write-back. Each card shows a CLI verify command. Add PublishSerialMcp MSBuild target so a single GUI publish auto-bundles ETTerms.SerialMcp into the \ETTerms.SerialMcp\ subfolder, aligning with McpRegistrar.ResolveServerExe(). Bump version to 0.2.1. Update ARCHITECTURE/CLAUDE/README/README.zh-TW and About changelog.
This commit is contained in:
@@ -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",
|
||||
[
|
||||
|
||||
+174
-18
@@ -5,7 +5,7 @@ using ETTerms.Scripting.Pdu;
|
||||
|
||||
namespace ETTerms.App;
|
||||
|
||||
/// <summary>Settings page with tabs: Terminal / PDU.</summary>
|
||||
/// <summary>Settings page with tabs: Terminal / PDU / AI MCP.</summary>
|
||||
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<Panel>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>單一 AI 目標(Claude / Kiro)的設定卡:狀態 + Setup / Remove + CLI 驗證指令。</summary>
|
||||
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; // 所有輸入框統一寬度
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<AssemblyName>ETTerms</AssemblyName>
|
||||
|
||||
<!-- 版本資訊 -->
|
||||
<Version>0.2.0</Version>
|
||||
<Version>0.2.1</Version>
|
||||
<Product>ETTerms</Product>
|
||||
<Company>ETTerms Project</Company>
|
||||
|
||||
@@ -32,4 +32,19 @@
|
||||
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
發佈 GUI 時,自動把 Serial MCP server 一併發佈到 <publish>\ETTerms.SerialMcp\ 子資料夾。
|
||||
這樣單一 `dotnet publish src\ETTerms` 就會產生完整自洽的 bundle,
|
||||
且 McpRegistrar.ResolveServerExe() 解析的 <ETTerms.exe>\ETTerms.SerialMcp\ETTerms.SerialMcp.exe 必定存在。
|
||||
刻意放子資料夾:與 GUI 的相依 dll 隔離,避免互相覆蓋。
|
||||
-->
|
||||
<Target Name="PublishSerialMcp" AfterTargets="Publish">
|
||||
<PropertyGroup>
|
||||
<_McpRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</_McpRid>
|
||||
<_McpRid Condition="'$(RuntimeIdentifier)' == ''">win-x64</_McpRid>
|
||||
</PropertyGroup>
|
||||
<Message Importance="high" Text="[ETTerms] Publishing ETTerms.SerialMcp -> $(PublishDir)ETTerms.SerialMcp" />
|
||||
<Exec Command="dotnet publish "$(MSBuildThisFileDirectory)..\ETTerms.SerialMcp\ETTerms.SerialMcp.csproj" -c $(Configuration) -r $(_McpRid) --self-contained false -o "$(PublishDir)ETTerms.SerialMcp"" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ETTerms.Infrastructure;
|
||||
|
||||
/// <summary>支援一鍵設定 MCP 的 AI CLI 目標。</summary>
|
||||
public enum McpTarget { Claude, Kiro }
|
||||
|
||||
/// <summary>
|
||||
/// 把 ETTerms 的 Serial MCP server(<c>ETTerms.SerialMcp</c>)一鍵註冊 / 移除到
|
||||
/// 各 AI CLI 的「使用者層級」MCP 設定檔。採 read-modify-write,保留檔內其他既有伺服器。
|
||||
///
|
||||
/// - Claude Code:<c>~/.claude.json</c> 頂層 <c>mcpServers</c>,entry 需 <c>type:"stdio"</c>。
|
||||
/// - Kiro:<c>%USERPROFILE%\.kiro\settings\mcp.json</c> 頂層 <c>mcpServers</c>。
|
||||
/// </summary>
|
||||
public static class McpRegistrar
|
||||
{
|
||||
/// <summary>註冊到各 CLI 時用的 MCP server 名稱。</summary>
|
||||
public const string ServerName = "etterms-serial";
|
||||
|
||||
public static string DisplayName(McpTarget t) => t switch
|
||||
{
|
||||
McpTarget.Claude => "Claude Code",
|
||||
McpTarget.Kiro => "Kiro",
|
||||
_ => t.ToString()
|
||||
};
|
||||
|
||||
/// <summary>該 AI CLI 的使用者層級 MCP 設定檔路徑。</summary>
|
||||
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))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>給使用者在 CLI 確認是否設定成功的指令(多行)。</summary>
|
||||
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 面板查看狀態",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
/// <summary>找出 ETTerms.SerialMcp 執行檔路徑(找不到回傳最可能的位置作為註冊值)。</summary>
|
||||
public static string ResolveServerExe()
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var candidates = new List<string>
|
||||
{
|
||||
Path.Combine(baseDir, "ETTerms.SerialMcp", "ETTerms.SerialMcp.exe"), // 發佈版(子資料夾)
|
||||
Path.Combine(baseDir, "ETTerms.SerialMcp.exe"), // 同層
|
||||
};
|
||||
|
||||
// 開發版 fallback:src\ETTerms\bin\<cfg>\net8.0-windows → src\ETTerms.SerialMcp\bin\<cfg>\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());
|
||||
|
||||
/// <summary>該目標是否已註冊 etterms-serial。</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>註冊(或更新)etterms-serial 到該目標設定檔。</summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>從該目標設定檔移除 etterms-serial。</summary>
|
||||
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))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>讀入設定檔為可變 JSON 物件;不存在回空物件。檔案存在但格式錯誤則丟例外(不覆蓋使用者資料)。</summary>
|
||||
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 物件,為避免覆蓋資料已中止。請手動檢查該檔。");
|
||||
}
|
||||
|
||||
/// <summary>原子寫回(先寫 .tmp 再 replace),避免半寫壞檔。</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user