From 308fb8fbb488d0e9e1e3378a5d009511cb2ab78a Mon Sep 17 00:00:00 2001 From: sunmeang Date: Sun, 7 Jun 2026 00:04:25 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20MCP=20?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Vex-main.code-workspace | 8 + ...36\347\216\260\346\226\271\346\241\210.md" | 1352 +++++++++++++++++ src/Vex/App.axaml.cs | 6 + .../Core/Messaging/EditorSelectionQuery.cs | 10 + src/Vex/Core/Messaging/ShellActionKind.cs | 3 +- src/Vex/Core/Models/AppSettings.cs | 14 + src/Vex/Core/Services/AppSettingsStore.cs | 23 +- src/Vex/I18n/Language.cs | 22 + src/Vex/I18n/en-US.json | 22 + src/Vex/I18n/ja-JP.json | 22 + src/Vex/I18n/zh-CN.json | 22 + src/Vex/I18n/zh-Hant.json | 22 + src/Vex/Modules/Mcp/Models/McpAccessScope.cs | 8 + src/Vex/Modules/Mcp/Models/McpContracts.cs | 113 ++ .../Modules/Mcp/Models/McpOperationRecord.cs | 11 + .../Mcp/Serialization/McpJsonContext.cs | 44 + .../Mcp/Services/IMcpOperationAuditService.cs | 10 + .../IMcpOperationConfirmationService.cs | 6 + .../Modules/Mcp/Services/IMcpServerHost.cs | 12 + .../Mcp/Services/IMcpToolDispatcher.cs | 11 + .../Mcp/Services/McpOperationAuditService.cs | 31 + .../McpOperationConfirmationService.cs | 55 + src/Vex/Modules/Mcp/Services/McpServerHost.cs | 270 ++++ .../Modules/Mcp/Services/McpTokenGenerator.cs | 13 + .../Modules/Mcp/Services/McpToolDispatcher.cs | 663 ++++++++ .../McpOperationConfirmationWindow.axaml | 68 + .../McpOperationConfirmationWindow.axaml.cs | 44 + .../Shell/Services/ShellActionCoordinator.cs | 22 +- .../Shell/ViewModels/MainWindowViewModel.cs | 30 + .../Shell/ViewModels/McpSettingsViewModel.cs | 221 +++ .../ViewModels/ShellTitleMenuViewModel.cs | 2 + .../Modules/Shell/Views/MainWindow.axaml.cs | 15 +- .../Shell/Views/McpSettingsWindow.axaml | 117 ++ .../Shell/Views/McpSettingsWindow.axaml.cs | 25 + .../Shell/Views/ShellTitleMenuView.axaml | 1 + .../Services/MarkdownEditorController.cs | 18 +- .../Workspace/Views/MarkdownEditorView.axaml | 2 +- .../Views/MarkdownEditorView.axaml.cs | 2 + 38 files changed, 3330 insertions(+), 10 deletions(-) create mode 100644 Vex-main.code-workspace create mode 100644 "docs/MCP\345\212\237\350\203\275\345\256\236\347\216\260\346\226\271\346\241\210.md" create mode 100644 src/Vex/Core/Messaging/EditorSelectionQuery.cs create mode 100644 src/Vex/Modules/Mcp/Models/McpAccessScope.cs create mode 100644 src/Vex/Modules/Mcp/Models/McpContracts.cs create mode 100644 src/Vex/Modules/Mcp/Models/McpOperationRecord.cs create mode 100644 src/Vex/Modules/Mcp/Serialization/McpJsonContext.cs create mode 100644 src/Vex/Modules/Mcp/Services/IMcpOperationAuditService.cs create mode 100644 src/Vex/Modules/Mcp/Services/IMcpOperationConfirmationService.cs create mode 100644 src/Vex/Modules/Mcp/Services/IMcpServerHost.cs create mode 100644 src/Vex/Modules/Mcp/Services/IMcpToolDispatcher.cs create mode 100644 src/Vex/Modules/Mcp/Services/McpOperationAuditService.cs create mode 100644 src/Vex/Modules/Mcp/Services/McpOperationConfirmationService.cs create mode 100644 src/Vex/Modules/Mcp/Services/McpServerHost.cs create mode 100644 src/Vex/Modules/Mcp/Services/McpTokenGenerator.cs create mode 100644 src/Vex/Modules/Mcp/Services/McpToolDispatcher.cs create mode 100644 src/Vex/Modules/Mcp/Views/McpOperationConfirmationWindow.axaml create mode 100644 src/Vex/Modules/Mcp/Views/McpOperationConfirmationWindow.axaml.cs create mode 100644 src/Vex/Modules/Shell/ViewModels/McpSettingsViewModel.cs create mode 100644 src/Vex/Modules/Shell/Views/McpSettingsWindow.axaml create mode 100644 src/Vex/Modules/Shell/Views/McpSettingsWindow.axaml.cs diff --git a/Vex-main.code-workspace b/Vex-main.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/Vex-main.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git "a/docs/MCP\345\212\237\350\203\275\345\256\236\347\216\260\346\226\271\346\241\210.md" "b/docs/MCP\345\212\237\350\203\275\345\256\236\347\216\260\346\226\271\346\241\210.md" new file mode 100644 index 0000000..b4606a4 --- /dev/null +++ "b/docs/MCP\345\212\237\350\203\275\345\256\236\347\216\260\346\226\271\346\241\210.md" @@ -0,0 +1,1352 @@ +# Vex MCP 功能实现方案 + +## 1. 目标 + +在 Vex 内置一个 MCP Server,使支持 MCP 的 AI 客户端可以像用户一样读取、创建、编辑、保存和预览 Markdown 文档。第一期以“当前打开的文档”和“已授权的本地文档路径”为核心,不做复杂自动化脚本和任意文件系统控制。 + +核心目标: + +1. 在帮助菜单的“更新日志”前增加“MCP 设置”入口。 +2. 新增符合当前主题风格的 MCP 设置窗体,用于设置监听地址、端口、启用状态和授权 token。 +3. 将文档常用操作暴露为 MCP tools。 +4. 所有新增序列化、工具注册、协议处理逻辑保持 Native AOT 兼容。 +5. AI 编辑文档后,编辑器、预览、大纲和状态栏实时更新。 +6. 默认安全、可关闭、可审计,避免 AI 任意读写用户磁盘。 + +## 2. 协议与形态 + +### 2.1 推荐形态 + +Vex 作为内置 MCP Server 运行,AI 客户端作为 MCP Client 连接 Vex。 + +推荐传输: + +- 第一期:Streamable HTTP,本机监听 `127.0.0.1`,默认端口建议 `17891`。 +- 备用:SSE 可作为兼容选项,但不作为第一期主路径。 +- 不建议第一期实现 stdio,因为 Vex 是桌面 GUI 进程,stdio 生命周期和桌面应用不自然。 + +连接地址示例: + +```text +http://127.0.0.1:17891/mcp +``` + +认证方式: + +```http +Authorization: Bearer +``` + +### 2.2 协议对象 + +第一期只实现 MCP tools,不实现 resources、prompts、sampling。 + +原因: + +- 当前需求是“让 AI 操作软件编写读取文档”,tools 足够表达动作。 +- resources 适合只读上下文枚举,后续可扩展为“当前文档”“最近文档”“大纲”等资源。 +- prompts 暂无必要。 +- sampling 会让 Vex 反向调用模型,当前不需要。 + +## 3. UI 设计 + +### 3.1 菜单入口 + +位置: + +```text +帮助 -> MCP 设置 -> 更新日志 -> 新手引导 -> 鸣谢 -> ... +``` + +也就是在 `ShellTitleMenuView.axaml` 中 `Changelog` 前增加菜单项。 + +建议新增本地化键: + +- `McpSettings` +- `McpSettingsTitle` +- `McpServerEnabled` +- `McpServerAddress` +- `McpServerPort` +- `McpAuthorizationToken` +- `McpGenerateToken` +- `McpCopyEndpoint` +- `McpConnectionStatus` +- `McpAllowedWorkspace` +- `McpRequireConfirmation` +- `McpSave` +- `McpCancel` + +### 3.2 设置窗体 + +新增窗体: + +```text +src/Vex/Modules/Shell/Views/McpSettingsWindow.axaml +src/Vex/Modules/Shell/Views/McpSettingsWindow.axaml.cs +src/Vex/Modules/Shell/ViewModels/McpSettingsViewModel.cs +``` + +窗体风格: + +- 使用现有 `VexPanelBackgroundBrush`、`VexPanelBorderBrush`、`TextBlockDefaultForeground`、按钮样式和输入框样式。 +- 不做营销式说明页,只做紧凑设置表单。 +- 推荐尺寸:`640 x 460`,居中打开。 +- 使用 `x:DataType` 和 compiled binding。 + +建议布局: + +```text +标题:MCP 设置 + +[ ] 启用 MCP Server + +监听地址 [127.0.0.1 ] +端口 [17891 ] +Token [vex_mcp_32_bytes_token_here] [生成] [复制] +Endpoint http://127.0.0.1:17891/mcp [复制] + +允许访问范围 +(*) 仅当前文档 +( ) 当前打开文件夹 +( ) 自定义目录 [路径] [选择] + +[x] 修改、删除、打开外部路径前需要确认 +[x] 只允许本机连接 + +状态:未启动 / 运行中 / 端口占用 / Token 缺失 + +[保存] [取消] +``` + +### 3.3 人工确认弹窗 + +第一期建议新增一个轻量确认窗口或复用现有错误/确认 overlay。 + +需要确认的 MCP 操作: + +- 修改当前文档。 +- 覆盖整个文档。 +- 删除文档。 +- 重命名文档。 +- 打开非当前工作区路径。 + +只读操作不需要确认。 + +保存当前文档不需要确认;AI 调用保存工具时可以直接保存。另存为也不弹确认,但仍必须通过访问范围、扩展名和路径安全校验。 + +确认文案应显示: + +- 客户端来源。 +- 工具名。 +- 目标文件。 +- 改动摘要。 + +## 4. 配置模型 + +扩展 `AppSettings`: + +```csharp +public bool? IsMcpServerEnabled { get; init; } + +public string? McpServerHost { get; init; } + +public int? McpServerPort { get; init; } + +public string? McpAuthorizationToken { get; init; } + +public string? McpAllowedWorkspacePath { get; init; } + +public string? McpAccessScope { get; init; } + +public bool? McpRequireConfirmation { get; init; } +``` + +默认值: + +- `IsMcpServerEnabled = false` +- `McpServerHost = "127.0.0.1"` +- `McpServerPort = 17891` +- `McpAccessScope = "CurrentDocument"` +- `McpRequireConfirmation = true` + +Token 生成: + +- 使用 `RandomNumberGenerator.GetBytes(32)`。 +- 保存为 Base64Url 或 hex。 +- 窗体内默认直接显示,方便用户复制到 MCP 客户端。 + +安全补充: + +- 第一期不支持 `0.0.0.0`,除非用户明确切换高级选项。 +- 地址必须限制为 loopback。 +- Token 为空时不允许启动。 + +## 5. 服务分层 + +新增模块建议放在 `Modules/Mcp`,避免挤进 Shell 或 Workspace。 + +建议文件结构: + +```text +src/Vex/Modules/Mcp/McpModule.cs +src/Vex/Modules/Mcp/Services/IMcpServerHost.cs +src/Vex/Modules/Mcp/Services/McpServerHost.cs +src/Vex/Modules/Mcp/Services/IMcpToolDispatcher.cs +src/Vex/Modules/Mcp/Services/McpToolDispatcher.cs +src/Vex/Modules/Mcp/Services/IMcpDocumentTools.cs +src/Vex/Modules/Mcp/Services/McpDocumentTools.cs +src/Vex/Modules/Mcp/Services/IMcpAuthorizationService.cs +src/Vex/Modules/Mcp/Services/McpAuthorizationService.cs +src/Vex/Modules/Mcp/Services/IMcpOperationConfirmationService.cs +src/Vex/Modules/Mcp/Services/McpOperationConfirmationService.cs +src/Vex/Modules/Mcp/Models/McpSettings.cs +src/Vex/Modules/Mcp/Models/McpToolContracts.cs +src/Vex/Modules/Mcp/Serialization/McpJsonContext.cs +``` + +注册方式: + +- 在 `App.ConfigureModuleCatalog` 增加 `McpModule`。 +- 在 `McpModule.RegisterTypes` 注册 MCP 相关服务。 +- 在应用启动后根据设置决定是否启动 server。 +- 设置保存后动态重启 server。 + +## 6. MCP Server 实现路线 + +### 6.1 推荐实现方式 + +优先使用官方或稳定的 .NET MCP SDK,但必须先验证 Native AOT: + +1. 是否支持 source-generated System.Text.Json。 +2. 是否通过反射扫描 `[McpServerTool]` 注册工具。 +3. 是否依赖 dynamic code、Expression compile 或运行时类型发现。 + +如果 SDK 的工具注册依赖反射,第一期不要使用自动扫描。改用手写 `tools/list` 和 `tools/call` 分发器。 + +### 6.2 AOT 优先的手写协议方案 + +为了最小化 AOT 风险,第一期推荐: + +- 使用 Kestrel 或 `HttpListener` 暴露本机 HTTP endpoint,具体选择待确认。 +- 使用 `System.Text.Json` source generation 处理协议 DTO。 +- 手写 JSON-RPC 请求解析。 +- 手写 MCP tools/list 返回。 +- 通过 `switch` 分发 `tools/call`。 +- 所有工具输入输出使用 sealed record DTO。 + +优点: + +- 不依赖反射工具扫描。 +- 工具列表稳定可控。 +- AOT 警告容易定位。 +- 后续可以替换为成熟 SDK。 + +风险: + +- 需要实现 MCP 基础消息格式。 +- Streamable HTTP 细节需要严格跟随规范。 + +折中策略: + +- 第一期先支持常见 MCP 客户端能调用的 `initialize`、`tools/list`、`tools/call`、`ping`。 +- 对未知 method 返回 JSON-RPC error。 +- 不实现复杂 session 恢复。 + +### 6.3 Kestrel 与 HttpListener 对比 + +当前先不确定最终方案,等确认后再进入实现。 + +#### Kestrel + +优点: + +- ASP.NET Core 推荐 HTTP Server,跨平台支持 Windows、Linux、macOS。 +- 性能、并发连接、HTTPS、HTTP/1.1、HTTP/2、WebSocket 和中间件扩展能力更完整。 +- 更容易接入依赖注入、配置、日志、认证中间件和后续 Streamable HTTP 细节。 +- 后续如果扩展 MCP resources、长连接、SSE 或更多 endpoint,工程扩展性更好。 + +缺点: + +- 需要引入或启用 ASP.NET Core hosting 相关依赖,桌面应用体积和发布复杂度会上升。 +- Native AOT 下要额外验证 ASP.NET Core hosting、路由、JSON 选项和日志链路是否引入新的 IL 警告。 +- 生命周期要和 Avalonia 桌面进程协调,启动、停止、端口占用和异常恢复都要处理好。 +- 对第一期“本机单 endpoint + 少量 JSON-RPC 方法”来说能力偏多。 + +适用判断: + +- 如果希望后续长期维护 MCP、支持更多协议细节、更多工具和更强认证能力,Kestrel 更稳。 +- 如果最看重 Native AOT 可控和最小依赖,Kestrel 需要先做发布验证后再定。 + +#### HttpListener + +优点: + +- API 简单,适合本机 loopback 的轻量 HTTP 服务。 +- 不需要引入 ASP.NET Core hosting,新增依赖少,方案更贴近第一期最小闭环。 +- 手写 request/response 更直接,AOT 风险点集中在少量协议 DTO 和请求处理代码。 +- 启停逻辑较轻,适合桌面应用内置小型控制端口。 + +缺点: + +- 能力较基础,HTTP pipeline、认证、日志、异常处理中间件都要自己写。 +- 跨平台和权限行为需要逐平台验证,尤其是 URL prefix、端口监听和系统支持情况。 +- 长连接、Streamable HTTP 细节、SSE、并发请求取消等能力需要自行补齐。 +- 未来如果 MCP endpoint 变复杂,维护成本可能高于 Kestrel。 + +适用判断: + +- 如果第一期只做本机 `127.0.0.1`、Bearer token、`initialize`、`tools/list`、`tools/call`、`ping`,HttpListener 更轻。 +- 如果要追求协议扩展和长期演进,HttpListener 可能会很快显得手工痕迹太重。 + +#### 待选择 + +建议在开始编码前二选一: + +- 选择 Kestrel:优先工程扩展性和协议演进。 +- 选择 HttpListener:优先最小依赖、AOT 可控和快速落地。 + +#### 当前选择 + +截至 2026-06-06,第一期实现已选择 `HttpListener`。 + +选择原因: + +- 当前 MCP 入口只需要本机 loopback、Bearer token 和少量 JSON-RPC 方法。 +- 不引入 ASP.NET Core hosting,新增依赖少,Native AOT 风险面更小。 +- 所有协议 DTO 和工具输入输出都可以保持手写分发和 source-generated `System.Text.Json`。 + +当前折中: + +- 仅实现轻量 HTTP JSON-RPC 调用,不实现 SSE、长连接 session 恢复和复杂 Streamable HTTP 扩展。 +- 后续如果要做更完整的 MCP transport、更多 endpoint 或更强认证能力,再评估切换 Kestrel。 + +## 7. 工具清单 + +工具语义命名统一使用 `vex.` 前缀;实际对外暴露给 AI / OpenAI function calling 的工具名统一使用下划线格式,例如 `vex_get_current_document`。原因是 OpenAI function tool 名称要求匹配 `^[a-zA-Z0-9_-]+$`,不能包含 `.`。服务端会把旧的 `vex.xxx` 调用自动兼容为 `vex_xxx`。 + +### 7.1 只读工具 + +#### vex.get_current_document + +读取当前文档。 + +输入: + +```json +{} +``` + +输出: + +```json +{ + "filePath": "D:/docs/demo.md", + "fileName": "demo.md", + "markdown": "# demo", + "isDirty": true, + "encoding": "utf-8" +} +``` + +#### vex.get_document_outline + +读取当前文档大纲。 + +输入: + +```json +{} +``` + +输出: + +```json +{ + "items": [ + { "level": 1, "title": "标题", "line": 1 } + ] +} +``` + +#### vex.get_selection + +读取当前选区。 + +输入: + +```json +{} +``` + +输出: + +```json +{ + "text": "选中文本", + "startOffset": 10, + "length": 4 +} +``` + +#### vex.list_open_documents + +第一期如果 Vex 仍是单活动文档,可以返回当前文档和文件列表选中项;后续多文档再扩展。 + +### 7.2 编辑工具 + +#### vex.replace_current_document + +整体替换当前 Markdown。 + +输入: + +```json +{ + "markdown": "# 新内容", + "reason": "重写文档结构" +} +``` + +行为: + +- 更新 `MainWindowViewModel.Markdown`。 +- 触发现有 `WorkspaceDocumentState.UpdateDocument`。 +- 编辑器和预览实时刷新。 +- 默认需要人工确认。 + +#### vex.apply_text_edit + +按 offset 应用文本编辑。 + +输入: + +```json +{ + "startOffset": 10, + "length": 5, + "replacement": "新文本", + "reason": "修正文案" +} +``` + +行为: + +- 校验 offset 范围。 +- 调用现有编辑控制器或新增统一文档编辑服务。 +- 保留 undo/redo 能力是理想目标;如果第一期不能进入 AvaloniaEdit undo 栈,需要在文档中标明。 + +#### vex.insert_text + +在当前位置或指定 offset 插入文本。 + +输入: + +```json +{ + "offset": 128, + "text": "插入内容" +} +``` + +#### vex.replace_selection + +替换当前选区。 + +输入: + +```json +{ + "text": "替换内容" +} +``` + +### 7.3 文件工具 + +#### vex.open_document + +打开允许范围内的文档。 + +输入: + +```json +{ + "path": "D:/docs/demo.md", + "encodingName": "utf-8" +} +``` + +要求: + +- 路径必须在允许范围内。 +- 扩展名必须由 `IDocumentService.IsSupportedDocumentPath` 接受。 +- 非当前工作区默认需要确认。 + +#### vex.save_current_document + +保存当前文档。 + +输入: + +```json +{} +``` + +#### vex.save_current_document_as + +保存到指定路径。 + +输入: + +```json +{ + "path": "D:/docs/new.md" +} +``` + +要求: + +- 路径必须在允许范围内。 +- 目标存在时需要确认。 + +### 7.4 预览工具 + +#### vex.refresh_preview + +刷新预览。 + +输入: + +```json +{} +``` + +#### vex.get_rendered_html + +返回当前 Markdown 渲染后的 HTML。 + +输入: + +```json +{} +``` + +输出: + +```json +{ + "html": "

...

" +} +``` + +用途: + +- AI 可以检查渲染效果。 +- 不直接操作 UI。 + +### 7.5 状态工具 + +#### vex.get_app_status + +返回应用状态。 + +输出: + +```json +{ + "version": "1.1.2.1", + "theme": "dark", + "typography": "Simple", + "currentFilePath": "D:/docs/demo.md", + "isDirty": true +} +``` + +### 7.6 界面操作工具 + +当前 Vex 菜单包含: + +- 文件:新建、新窗口、打开文件、打开文件夹、快速打开、最近文档、编码重开、复制到公众号/知乎/掘金、保存、另存为、保存全部、属性、打开位置、删除、导出、打印、关闭。 +- 编辑:撤销、重做、剪切、复制、粘贴、全选、查找、替换。 +- 段落:正文、标题 1-6、表格、代码块、数学块、引用、有序列表、无序列表、任务列表、分割线。 +- 格式:加粗、斜体、行内代码、链接、图片、清除格式。 +- 视图:刷新预览、切换侧边栏、大纲、文档列表、源码模式、显示行号、显示状态栏、字数统计、全屏、置顶。 +- 帮助:主题色、排版主题、紧凑布局、语言、MCP 设置、更新日志、新手引导、鸣谢、官网、反馈、关于。 + +第一期只公开基础且低风险的界面操作,不把所有菜单都交给 AI。删除、打印、新窗口、打开外部网站、反馈入口、全屏、置顶等更偏用户意图或系统级行为,默认不公开。 + +#### vex.ui_get_state + +读取当前界面状态。 + +输出: + +```json +{ + "theme": "dark", + "typography": "Simple", + "cultureName": "zh-CN", + "isCompactLayout": false, + "isSidebarVisible": true, + "selectedSidebarTab": "files", + "isPreviewVisible": true, + "isSourceMode": false, + "showLineNumbers": true, + "isStatusBarVisible": true +} +``` + +#### vex.ui_set_theme + +设置主题色。 + +输入: + +```json +{ + "themeKey": "dark" +} +``` + +允许值: + +- `system` +- `light` +- `dark` +- `aquatic` +- `desert` +- `dusk` +- `night-sky` + +#### vex.ui_set_typography + +设置 Markdown 排版主题。 + +输入: + +```json +{ + "typographyKey": "Simple" +} +``` + +允许值来自当前 `ShellAppearanceViewModel.TypographyOptions`,包括 Basic、Simple、OrangeHeart、InkBlack、TechnologyBlue 等现有排版主题。 + +#### vex.ui_set_language + +设置界面语言。 + +输入: + +```json +{ + "cultureName": "zh-CN" +} +``` + +允许值: + +- `zh-CN` +- `zh-Hant` +- `en-US` +- `ja-JP` + +#### vex.ui_set_layout + +设置基础布局状态。 + +输入: + +```json +{ + "sidebarVisible": true, + "statusBarVisible": true, + "sourceMode": false, + "lineNumbersVisible": true, + "compactLayout": false +} +``` + +说明: + +- 字段可选,只修改传入字段。 +- 不开放全屏和置顶。 +- `sourceMode=true` 会按现有逻辑隐藏侧边栏和预览;`sourceMode=false` 恢复进入源码模式前的布局。 + +#### vex.ui_show_sidebar_tab + +切换侧边栏页签。 + +输入: + +```json +{ + "tab": "outline" +} +``` + +允许值: + +- `files` +- `outline` + +#### vex.ui_open_panel + +打开基础面板或帮助窗口。 + +输入: + +```json +{ + "panel": "find" +} +``` + +允许值: + +- `find` +- `replace` +- `properties` +- `wordCount` +- `mcpSettings` +- `changelog` +- `thanks` +- `about` + +说明: + +- `website`、`feedback` 默认不开放,避免 AI 主动打开外部网页。 +- `onboardingGuide` 默认不开放,避免打断当前工作流。 + +#### vex.ui_refresh_preview + +刷新当前预览。 + +输入: + +```json +{} +``` + +#### vex.ui_apply_editor_command + +执行常用编辑命令。 + +输入: + +```json +{ + "command": "bold" +} +``` + +允许值: + +- `undo` +- `redo` +- `copy` +- `selectAll` +- `paragraph` +- `heading1` +- `heading2` +- `heading3` +- `bold` +- `italic` +- `inlineCode` +- `link` +- `image` +- `quote` +- `orderedList` +- `unorderedList` +- `taskList` +- `codeFence` +- `mathBlock` +- `table` +- `horizontalRule` +- `clearFormatting` + +说明: + +- `cut` 和 `paste` 第一期开启前需要评估剪贴板隐私,默认不开放。 +- 这些命令复用现有 `EditorActionKind` 和编辑器服务,执行后应实时刷新预览。 + +#### vex.ui_export_current_document + +导出当前文档。 + +输入: + +```json +{ + "format": "HTML" +} +``` + +允许值: + +- `HTML` +- `PDF` +- `PNG` +- `Word` + +说明: + +- 该工具会触发现有保存文件选择流程,仍由用户选择输出位置。 +- 不允许 AI 静默导出到任意路径;如需静默导出,应放到第二期并增加路径授权。 + +#### vex.ui_copy_rendered_html + +复制面向平台的富 HTML。 + +输入: + +```json +{ + "target": "wechat" +} +``` + +允许值: + +- `wechat` +- `zhihu` +- `juejin` + +说明: + +- 复用当前“复制到公众号/知乎/掘金”菜单能力。 +- 会写入剪贴板,第一期建议需要确认,避免覆盖用户剪贴板。 + +### 7.7 不建议第一期公开的界面操作 + +以下功能第一期不暴露给 AI: + +- 删除文件。 +- 打印。 +- 新窗口。 +- 全屏。 +- 置顶。 +- 打开官网。 +- 打开反馈。 +- 新手引导。 +- 清空最近文档。 +- 打开文件所在位置。 +- 剪切、粘贴。 + +原因: + +- 这些操作要么有破坏性,要么影响系统窗口状态,要么会访问外部应用或剪贴板。 +- 后续可以逐项加入,但应单独加确认和审计。 + +## 8. 实时编辑与预览 + +当前 Vex 已经有文档状态通道: + +```text +WorkspaceDocumentState.UpdateDocument +MarkdownDocumentChangedCommand +MarkdownPreviewViewModel +MarkdownEditorViewModel +``` + +第一期实现原则: + +1. MCP 编辑工具不要直接改文件后悄悄返回。 +2. 必须更新当前 UI 文档状态。 +3. 文档状态变化必须走现有事件通道。 +4. 预览刷新继续复用现有 Markdown preview 绑定。 + +建议新增统一编辑服务: + +```text +IWorkspaceDocumentEditService +WorkspaceDocumentEditService +``` + +职责: + +- `ReplaceDocument(string markdown)` +- `ApplyTextEdit(int startOffset, int length, string replacement)` +- `InsertText(int offset, string text)` +- `ReplaceSelection(string text)` +- `SaveCurrentAsync()` +- 保证所有 UI 状态更新在 Avalonia UI 线程执行。 + +线程要求: + +- MCP HTTP 请求在后台线程进入。 +- 所有触碰 ViewModel、AvaloniaEdit、窗口状态的操作用 `Dispatcher.UIThread.InvokeAsync`。 +- 文件 I/O 可在后台执行,但结果应用回 UI 线程。 + +实时预览: + +- 整体替换或局部编辑后立即更新 `Markdown` 属性。 +- `MarkdownDocumentChangedCommand` 触发预览 ViewModel。 +- 不需要新增单独的“预览渲染循环”。 + +## 9. 安全策略 + +### 9.1 访问范围 + +第一期提供三档: + +- `CurrentDocument`:只能读取/修改当前文档。 +- `CurrentFolder`:只能访问当前打开文件夹。 +- `CustomFolder`:只能访问用户指定目录。 + +路径校验: + +- 使用 `Path.GetFullPath` 规范化。 +- 禁止空路径、相对逃逸、UNC 远程路径,除非后续显式支持。 +- 对比根目录时使用平台对应的 `StringComparer`。 +- 只允许 `.md`、`.markdown`、`.mdown`、`.txt`。 + +### 9.2 授权 + +- 所有 MCP 请求必须带 Bearer token。 +- Token 不在日志明文输出。 +- 设置窗口可重新生成 token。 +- Token 变更后立即重启 server 或刷新认证服务。 + +### 9.3 确认与审计 + +新增审计模型: + +```text +McpOperationRecord +``` + +字段: + +- 时间。 +- 工具名。 +- 目标路径。 +- 操作类型。 +- 是否确认。 +- 成功/失败。 +- 错误摘要。 + +第一期可只保留内存最近 100 条,后续再落盘。 + +## 10. AOT 兼容要求 + +新增代码必须满足: + +1. 不使用反射扫描工具方法。 +2. 不使用 `dynamic`。 +3. 不使用 `Expression.Compile`。 +4. 不使用 Newtonsoft.Json。 +5. 所有 MCP DTO 都纳入 `McpJsonContext`。 +6. 不用 `[UnconditionalSuppressMessage]` 压制新增 IL 警告。 +7. 构建时至少验证: + +```powershell +dotnet build Vex.slnx -c Release --no-incremental +dotnet publish src\Vex\Vex.csproj -c Release -f net10.0-windows -r win-x64 /p:PublishProfile=FolderProfile__win-x64 +``` + +建议 `McpJsonContext`: + +```csharp +[JsonSerializable(typeof(JsonRpcRequest))] +[JsonSerializable(typeof(JsonRpcResponse))] +[JsonSerializable(typeof(ToolsListResult))] +[JsonSerializable(typeof(ToolCallRequest))] +[JsonSerializable(typeof(GetCurrentDocumentResult))] +[JsonSerializable(typeof(ApplyTextEditInput))] +internal sealed partial class McpJsonContext : JsonSerializerContext; +``` + +工具 schema 不要运行时反射生成,直接定义静态 JSON 或强类型 schema DTO。 + +## 11. 本地化 + +需要更新: + +```text +src/Vex/I18n/Language.cs +src/Vex/I18n/zh-CN.json +src/Vex/I18n/zh-Hant.json +src/Vex/I18n/en-US.json +src/Vex/I18n/ja-JP.json +``` + +如果 `Language.cs` 由 T4 生成,则按现有生成流程更新,不手写破坏生成约定。 + +## 12. 第一阶段实施步骤 + +### 阶段 A:设置 UI 与配置 + +1. 扩展 `AppSettings` 和 `AppSettingsStore`。 +2. 增加 MCP 本地化键。 +3. 在帮助菜单中增加“MCP 设置”。 +4. 创建 `McpSettingsWindow` 和 `McpSettingsViewModel`。 +5. 支持保存、生成 token、复制 endpoint。 + +验收: + +- 菜单位置正确。 +- 窗体主题一致。 +- 设置重启后保留。 +- token 在窗体内直接显示,便于复制。 + +### 阶段 B:文档编辑服务 + +1. 新增 `IWorkspaceDocumentEditService`。 +2. 将当前文档读取、整体替换、局部编辑、保存动作集中到服务。 +3. 确保 UI 线程切换正确。 +4. MCP 未接入前也可以由内部调用验证。 + +验收: + +- 调用服务替换 Markdown 后,编辑器和预览同时更新。 +- 局部编辑后文档 dirty 状态正确。 + +### 阶段 C:MCP Server 基础协议 + +1. 新增 `McpModule`。 +2. 新增 `McpServerHost`。 +3. 实现 token 校验。 +4. 实现 `initialize`、`ping`、`tools/list`、`tools/call`。 +5. 手写第一批 tools schema。 + +验收: + +- 未启用时不监听端口。 +- token 错误返回 401。 +- token 正确可列出 tools。 + +### 阶段 D:工具接入 + +优先实现: + +1. `vex.get_current_document` +2. `vex.replace_current_document` +3. `vex.apply_text_edit` +4. `vex.open_document` +5. `vex.save_current_document` +6. `vex.get_document_outline` +7. `vex.get_rendered_html` +8. `vex.get_app_status` +9. `vex.ui_get_state` +10. `vex.ui_set_theme` +11. `vex.ui_set_typography` +12. `vex.ui_set_language` +13. `vex.ui_set_layout` +14. `vex.ui_show_sidebar_tab` +15. `vex.ui_open_panel` +16. `vex.ui_apply_editor_command` + +验收: + +- AI 可读取当前文档。 +- AI 可修改当前文档并实时看到预览更新。 +- AI 可保存当前文档。 +- AI 可切换基础主题、排版、语言和布局。 +- AI 可打开查找、替换、属性、字数统计、MCP 设置、更新日志、鸣谢和关于。 +- 越权路径被拒绝。 + +### 阶段 E:AOT 验证 + +1. Release build。 +2. Windows Native AOT publish。 +3. 启动 AOT 产物。 +4. 连接 MCP。 +5. 调用只读和写入工具。 + +验收: + +- 新增 MCP 代码不引入新的 IL 警告。 +- AOT 产物中 MCP 设置窗体可打开。 +- AOT 产物中 MCP server 可启动和调用。 + +## 13. 常见风险与规避 + +### 13.1 MCP SDK 反射注册风险 + +风险: + +SDK 通过 attribute 反射扫描工具,Native AOT 下可能丢方法或产生 IL 警告。 + +规避: + +第一期手写 `tools/list` 和 `tools/call`。 + +### 13.2 UI 线程风险 + +风险: + +后台 HTTP 请求直接改 ViewModel 或 AvaloniaEdit,导致线程异常。 + +规避: + +统一通过 `Dispatcher.UIThread.InvokeAsync`。 + +### 13.3 AI 任意改文件风险 + +风险: + +模型调用工具删除或覆盖用户文件。 + +规避: + +默认仅当前文档,写入需确认,路径白名单。 + +### 13.4 实时预览不同步 + +风险: + +MCP 只写文件不更新 UI。 + +规避: + +所有编辑工具必须走 `IWorkspaceDocumentEditService`,不允许直接 `File.WriteAllText`。 + +### 13.5 端口冲突 + +风险: + +默认端口被占用导致启动失败。 + +规避: + +设置窗体展示状态,允许修改端口。 + +## 14. 验收清单 + +- [x] 帮助菜单中“更新日志”前出现“MCP 设置”。 +- [x] MCP 设置窗体符合当前主题。 +- [x] 可设置 host、port、token、启用状态、访问范围。 +- [x] 未设置 token 无法启动。 +- [x] `tools/list` 返回稳定工具列表,并提供静态 input schema。 +- [x] `tools/list` 对外返回 OpenAI function calling 兼容的下划线工具名,避免 `.` 导致 `invalid_request_error`。 +- [x] `vex.get_current_document` 可读取当前文档。 +- [x] `vex.replace_current_document` 可实时更新编辑器和预览。 +- [x] `vex.apply_text_edit` 可局部编辑。 +- [x] `vex.insert_text` 可插入文本。 +- [x] `vex.replace_selection` 可替换当前选区。 +- [x] `vex.save_current_document` 可保存。 +- [x] `vex.save_current_document` 不弹出确认。 +- [x] `vex.get_document_outline` 可读取大纲。 +- [x] `vex.get_rendered_html` 可读取当前渲染 HTML。 +- [x] `vex.get_app_status` 可读取应用状态。 +- [x] `vex.get_operation_audit` 可读取最近 MCP 操作审计。 +- [x] `vex.ui_get_state` 可读取界面状态。 +- [x] `vex.ui_set_theme` 可切换主题色。 +- [x] `vex.ui_set_typography` 可切换排版主题。 +- [x] `vex.ui_set_language` 可切换语言。 +- [x] `vex.ui_set_layout` 可切换基础布局。 +- [x] `vex.ui_show_sidebar_tab` 可切换侧边栏页签。 +- [x] `vex.ui_open_panel` 可打开基础面板。 +- [x] `vex.ui_apply_editor_command` 可执行常用编辑命令。 +- [x] `vex.ui_export_current_document` 可触发现有导出流程,输出位置仍由用户选择。 +- [x] `vex.ui_copy_rendered_html` 可复制面向平台的富 HTML,并默认需要确认。 +- [x] 不向 AI 暴露删除、打印、新窗口、全屏、置顶、官网、反馈、新手引导、清空最近文档、打开文件所在位置、剪切、粘贴。 +- [x] 越权路径被拒绝。 +- [x] 修改、打开外部路径、复制富 HTML 默认弹出确认;保存当前文档不确认。 +- [x] Release 构建通过。 +- [x] Windows Native AOT 发布通过。 +- [x] 新增代码不使用反射工具扫描和反射 JSON 序列化。 +- [ ] AOT 产物中 MCP 设置窗体人工打开验证。 +- [ ] AOT 产物中 MCP server 人工连接调用验证。 + +## 15. 推荐第一期范围 + +第一期只做“AI 操作当前文档”的完整闭环: + +1. MCP 设置窗体。 +2. 本机 Streamable HTTP endpoint。 +3. Bearer token。 +4. tools/list、tools/call。 +5. 当前文档读取。 +6. 整体替换、局部替换、插入文本。 +7. 保存当前文档。 +8. 当前大纲和 HTML 预览读取。 +9. 基础界面操作:读取界面状态、切换主题、切换排版、切换语言、切换布局、打开基础面板、执行常用编辑命令。 +10. 修改确认;保存当前文档不确认。 +11. Native AOT 发布验证。 + +第二期再扩展: + +- MCP resources。 +- 最近文档资源。 +- 多文档管理。 +- 文件夹批量扫描。 +- 更细粒度 diff 预览。 +- 外部客户端连接配置导出。 +- 操作审计落盘。 + +## 16. 当前实施进度与逻辑推演 + +更新时间:2026-06-06。 + +### 16.1 已落地文件 + +MCP 设置与菜单: + +- `src/Vex/Modules/Shell/Views/McpSettingsWindow.axaml` +- `src/Vex/Modules/Shell/Views/McpSettingsWindow.axaml.cs` +- `src/Vex/Modules/Shell/ViewModels/McpSettingsViewModel.cs` +- `src/Vex/Modules/Shell/Views/ShellTitleMenuView.axaml` +- `src/Vex/Modules/Shell/ViewModels/ShellTitleMenuViewModel.cs` +- `src/Vex/Modules/Shell/Services/ShellActionCoordinator.cs` + +MCP 协议与工具: + +- `src/Vex/Modules/Mcp/Services/McpServerHost.cs` +- `src/Vex/Modules/Mcp/Services/IMcpServerHost.cs` +- `src/Vex/Modules/Mcp/Services/McpToolDispatcher.cs` +- `src/Vex/Modules/Mcp/Services/IMcpToolDispatcher.cs` +- `src/Vex/Modules/Mcp/Models/McpContracts.cs` +- `src/Vex/Modules/Mcp/Serialization/McpJsonContext.cs` + +安全、确认与审计: + +- `src/Vex/Modules/Mcp/Services/IMcpOperationConfirmationService.cs` +- `src/Vex/Modules/Mcp/Services/McpOperationConfirmationService.cs` +- `src/Vex/Modules/Mcp/Views/McpOperationConfirmationWindow.axaml` +- `src/Vex/Modules/Mcp/Views/McpOperationConfirmationWindow.axaml.cs` +- `src/Vex/Modules/Mcp/Services/IMcpOperationAuditService.cs` +- `src/Vex/Modules/Mcp/Services/McpOperationAuditService.cs` +- `src/Vex/Modules/Mcp/Models/McpOperationRecord.cs` + +编辑器选区: + +- `src/Vex/Core/Messaging/EditorSelectionQuery.cs` +- `src/Vex/Modules/Workspace/Services/MarkdownEditorController.cs` + +配置与本地化: + +- `src/Vex/Core/Models/AppSettings.cs` +- `src/Vex/Core/Services/AppSettingsStore.cs` +- `src/Vex/I18n/Language.cs` +- `src/Vex/I18n/zh-CN.json` +- `src/Vex/I18n/zh-Hant.json` +- `src/Vex/I18n/en-US.json` +- `src/Vex/I18n/ja-JP.json` + +### 16.2 当前工具清单 + +对外工具名使用下划线格式;旧点号格式调用由服务端兼容归一化。 + +只读工具: + +- `vex_get_current_document` +- `vex_get_document_outline` +- `vex_get_selection` +- `vex_get_rendered_html` +- `vex_get_app_status` +- `vex_get_operation_audit` +- `vex_ui_get_state` + +编辑工具: + +- `vex_replace_current_document` +- `vex_apply_text_edit` +- `vex_insert_text` +- `vex_replace_selection` + +文件工具: + +- `vex_open_document` +- `vex_save_current_document` + +界面工具: + +- `vex_ui_set_theme` +- `vex_ui_set_typography` +- `vex_ui_set_language` +- `vex_ui_set_layout` +- `vex_ui_show_sidebar_tab` +- `vex_ui_open_panel` +- `vex_ui_refresh_preview` +- `vex_ui_apply_editor_command` +- `vex_ui_export_current_document` +- `vex_ui_copy_rendered_html` + +### 16.3 逻辑推演结果 + +协议推演: + +- `initialize`、`ping`、`tools/list`、`tools/call` 均返回 JSON-RPC response。 +- `notifications/initialized` 和其他无 `id` notification 返回 HTTP `204 No Content`,避免对 notification 返回错误 response。 +- 有 `id` 的未知 method 返回 JSON-RPC `-32601`。 +- `tools/list` 的 schema 是静态 JSON 文本,启动时解析为 `JsonElement`,不通过反射生成。 + +安全推演: + +- 未启用 MCP 时不监听端口。 +- Token 为空时不允许启动。 +- 监听地址限制为 `127.0.0.1`、`localhost`、`::1`。 +- Bearer token 校验失败返回 `401`。 +- `open_document` 做扩展名校验和授权路径校验。 +- `CurrentDocument` 只允许当前文档路径。 +- `CurrentFolder` 当前实现解释为“当前文档所在目录”,不是独立的“已打开文件夹状态”。 +- `CustomFolder` 只允许用户指定目录内文件。 +- UNC 路径默认拒绝。 +- 删除、打印、新窗口、全屏、置顶、外部网页、反馈、新手引导、剪切、粘贴等未暴露给 AI。 + +确认推演: + +- 文档编辑、打开文档、复制富 HTML 默认需要确认。 +- 保存当前文档不确认,符合用户要求。 +- 导出当前文档走现有保存位置选择流程,AI 不可静默指定输出路径。 +- 只读工具不确认。 + +审计推演: + +- 工具调用成功、失败、取消都会记录最近 100 条内存审计。 +- 审计记录不包含 token、Markdown 正文、替换文本或选区文本。 +- `vex.get_operation_audit` 本身也会产生一条只读审计记录。 + +实时预览推演: + +- MCP 编辑工具更新 `MainWindowViewModel.Markdown`。 +- `Markdown` setter 会刷新文档派生状态、工作区文档状态和预览绑定。 +- 当前实现没有单独抽出 `IWorkspaceDocumentEditService`,而是复用 `MainWindowViewModel` 的 MCP 专用入口;第一期可以接受,但后续应考虑抽服务以改善可测试性。 + +AOT 推演: + +- 新增 MCP DTO 都纳入 `McpJsonContext`。 +- MCP 工具分发使用 `switch`,没有反射扫描工具方法。 +- MCP JSON 序列化调用都显式传入 source-generated `JsonTypeInfo`。 +- 未新增 Newtonsoft.Json、`dynamic`、`Expression.Compile` 或 IL warning suppression。 + +### 16.4 已验证命令 + +已通过: + +```powershell +dotnet build src\Vex\Vex.csproj -c Debug -f net10.0-windows --no-incremental +dotnet build Vex.slnx -c Release --no-incremental +dotnet publish src\Vex\Vex.csproj -c Release -f net10.0-windows -r win-x64 /p:PublishProfile=FolderProfile__win-x64 +``` + +已知输出: + +- 普通构建仍有既有 `NU1507` 包源映射警告。 +- Native AOT publish 仍有 Avalonia、ReactiveUI、Prism、DryIoc、Ursa 等既有依赖链 trim/AOT 警告。 +- 新增 MCP 代码未出现 `System.Text.Json` 反射序列化类告警。 + +### 16.5 仍需继续推进 + +优先级高: + +- 启动 AOT 产物,人工打开 MCP 设置窗体。 +- 用真实 MCP/HTTP 客户端连接 `http://127.0.0.1:17891/mcp/`,验证 `initialize`、`tools/list`、只读工具、写入工具、确认弹窗、保存和越权拒绝。 +- 验证 `HttpListener` 在 `::1` 和 Windows 普通用户权限下的行为。 + +优先级中: + +- 增加 `vex.save_current_document_as`,但必须先实现路径授权和目标已存在确认。 +- 抽出 `IWorkspaceDocumentEditService`,降低 `McpToolDispatcher` 对 `MainWindowViewModel` 的耦合。 +- 将 MCP server 状态变化绑定到设置窗体实时显示,而不是只在保存后刷新一次。 +- 为 MCP 操作审计增加设置窗体内的只读查看入口。 + +优先级低: + +- MCP resources。 +- 最近文档资源。 +- 多文档管理。 +- 文件夹批量扫描。 +- 更细粒度 diff 预览。 +- 操作审计落盘。 diff --git a/src/Vex/App.axaml.cs b/src/Vex/App.axaml.cs index adad342..c173af5 100644 --- a/src/Vex/App.axaml.cs +++ b/src/Vex/App.axaml.cs @@ -14,6 +14,7 @@ using Vex.Modules.Appearance.Services; using Vex.Modules.Help; using Vex.Modules.Help.Services; +using Vex.Modules.Mcp.Services; using Vex.Modules.Shell; using Vex.Modules.Shell.Services; using Vex.Modules.Shell.ViewModels; @@ -89,6 +90,10 @@ protected override void RegisterTypes(IContainerRegistry containerRegistry) containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); @@ -105,6 +110,7 @@ protected override void RegisterTypes(IContainerRegistry containerRegistry) containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); diff --git a/src/Vex/Core/Messaging/EditorSelectionQuery.cs b/src/Vex/Core/Messaging/EditorSelectionQuery.cs new file mode 100644 index 0000000..94230a8 --- /dev/null +++ b/src/Vex/Core/Messaging/EditorSelectionQuery.cs @@ -0,0 +1,10 @@ +using CodeWF.EventBus; + +namespace Vex.Core.Messaging; + +public sealed class EditorSelectionQuery : Query +{ + public override EditorSelectionInfo Result { get; set; } = new(string.Empty, 0, 0); +} + +public sealed record EditorSelectionInfo(string Text, int StartOffset, int Length); diff --git a/src/Vex/Core/Messaging/ShellActionKind.cs b/src/Vex/Core/Messaging/ShellActionKind.cs index 94f5234..70b1b51 100644 --- a/src/Vex/Core/Messaging/ShellActionKind.cs +++ b/src/Vex/Core/Messaging/ShellActionKind.cs @@ -22,5 +22,6 @@ public enum ShellActionKind RefreshPreview, ShowFindPanel, ShowReplacePanel, - WordCount + WordCount, + ShowMcpSettings } diff --git a/src/Vex/Core/Models/AppSettings.cs b/src/Vex/Core/Models/AppSettings.cs index 40eeea2..6d0fcbe 100644 --- a/src/Vex/Core/Models/AppSettings.cs +++ b/src/Vex/Core/Models/AppSettings.cs @@ -29,4 +29,18 @@ public sealed record AppSettings public double? WindowWidth { get; init; } public double? WindowHeight { get; init; } + + public bool? IsMcpServerEnabled { get; init; } + + public string? McpServerHost { get; init; } + + public int? McpServerPort { get; init; } + + public string? McpAuthorizationToken { get; init; } + + public string? McpAccessScope { get; init; } + + public string? McpAllowedWorkspacePath { get; init; } + + public bool? McpRequireConfirmation { get; init; } } diff --git a/src/Vex/Core/Services/AppSettingsStore.cs b/src/Vex/Core/Services/AppSettingsStore.cs index 1954f15..5f16995 100644 --- a/src/Vex/Core/Services/AppSettingsStore.cs +++ b/src/Vex/Core/Services/AppSettingsStore.cs @@ -19,6 +19,13 @@ public sealed class AppSettingsStore : IAppSettingsStore private const string HasSeenOnboardingGuide = nameof(AppSettings.HasSeenOnboardingGuide); private const string WindowWidth = nameof(AppSettings.WindowWidth); private const string WindowHeight = nameof(AppSettings.WindowHeight); + private const string IsMcpServerEnabled = nameof(AppSettings.IsMcpServerEnabled); + private const string McpServerHost = nameof(AppSettings.McpServerHost); + private const string McpServerPort = nameof(AppSettings.McpServerPort); + private const string McpAuthorizationToken = nameof(AppSettings.McpAuthorizationToken); + private const string McpAccessScope = nameof(AppSettings.McpAccessScope); + private const string McpAllowedWorkspacePath = nameof(AppSettings.McpAllowedWorkspacePath); + private const string McpRequireConfirmation = nameof(AppSettings.McpRequireConfirmation); private readonly object _syncRoot = new(); private AppSettings? _settings; @@ -63,7 +70,14 @@ private static AppSettings Load() ShowLineNumbers = Get(configPath, ShowLineNumbers), HasSeenOnboardingGuide = Get(configPath, HasSeenOnboardingGuide), WindowWidth = Get(configPath, WindowWidth), - WindowHeight = Get(configPath, WindowHeight) + WindowHeight = Get(configPath, WindowHeight), + IsMcpServerEnabled = Get(configPath, IsMcpServerEnabled), + McpServerHost = Get(configPath, McpServerHost), + McpServerPort = Get(configPath, McpServerPort), + McpAuthorizationToken = Get(configPath, McpAuthorizationToken), + McpAccessScope = Get(configPath, McpAccessScope), + McpAllowedWorkspacePath = Get(configPath, McpAllowedWorkspacePath), + McpRequireConfirmation = Get(configPath, McpRequireConfirmation) }; } @@ -86,6 +100,13 @@ private static void Save(AppSettings settings) AppConfigHelper.Set(configPath, HasSeenOnboardingGuide, settings.HasSeenOnboardingGuide); AppConfigHelper.Set(configPath, WindowWidth, settings.WindowWidth); AppConfigHelper.Set(configPath, WindowHeight, settings.WindowHeight); + AppConfigHelper.Set(configPath, IsMcpServerEnabled, settings.IsMcpServerEnabled); + AppConfigHelper.Set(configPath, McpServerHost, settings.McpServerHost); + AppConfigHelper.Set(configPath, McpServerPort, settings.McpServerPort); + AppConfigHelper.Set(configPath, McpAuthorizationToken, settings.McpAuthorizationToken); + AppConfigHelper.Set(configPath, McpAccessScope, settings.McpAccessScope); + AppConfigHelper.Set(configPath, McpAllowedWorkspacePath, settings.McpAllowedWorkspacePath); + AppConfigHelper.Set(configPath, McpRequireConfirmation, settings.McpRequireConfirmation); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Xml.XmlException) { diff --git a/src/Vex/I18n/Language.cs b/src/Vex/I18n/Language.cs index 0004d33..574ffa7 100644 --- a/src/Vex/I18n/Language.cs +++ b/src/Vex/I18n/Language.cs @@ -134,6 +134,28 @@ public static class VexL public static readonly string LanguageZhHant = "Vex.VexL.LanguageZhHant"; public static readonly string LanguageEnUs = "Vex.VexL.LanguageEnUs"; public static readonly string LanguageJaJp = "Vex.VexL.LanguageJaJp"; + public static readonly string McpSettings = "Vex.VexL.McpSettings"; + public static readonly string McpSettingsTitle = "Vex.VexL.McpSettingsTitle"; + public static readonly string McpSettingsDescription = "Vex.VexL.McpSettingsDescription"; + public static readonly string McpServerEnabled = "Vex.VexL.McpServerEnabled"; + public static readonly string McpServerAddress = "Vex.VexL.McpServerAddress"; + public static readonly string McpServerPort = "Vex.VexL.McpServerPort"; + public static readonly string McpAuthorizationToken = "Vex.VexL.McpAuthorizationToken"; + public static readonly string McpGenerateToken = "Vex.VexL.McpGenerateToken"; + public static readonly string McpCopyToken = "Vex.VexL.McpCopyToken"; + public static readonly string McpCopyEndpoint = "Vex.VexL.McpCopyEndpoint"; + public static readonly string McpAllowedWorkspace = "Vex.VexL.McpAllowedWorkspace"; + public static readonly string McpScopeCurrentDocument = "Vex.VexL.McpScopeCurrentDocument"; + public static readonly string McpScopeCurrentFolder = "Vex.VexL.McpScopeCurrentFolder"; + public static readonly string McpScopeCustomFolder = "Vex.VexL.McpScopeCustomFolder"; + public static readonly string McpCustomFolderWatermark = "Vex.VexL.McpCustomFolderWatermark"; + public static readonly string McpRequireConfirmation = "Vex.VexL.McpRequireConfirmation"; + public static readonly string McpSave = "Vex.VexL.McpSave"; + public static readonly string McpCancel = "Vex.VexL.McpCancel"; + public static readonly string McpOperationConfirmationTitle = "Vex.VexL.McpOperationConfirmationTitle"; + public static readonly string McpOperationConfirmationMessage = "Vex.VexL.McpOperationConfirmationMessage"; + public static readonly string McpOperationConfirm = "Vex.VexL.McpOperationConfirm"; + public static readonly string McpOperationRejected = "Vex.VexL.McpOperationRejected"; public static readonly string Changelog = "Vex.VexL.Changelog"; public static readonly string QuickStart = "Vex.VexL.QuickStart"; public static readonly string OnboardingGuide = "Vex.VexL.OnboardingGuide"; diff --git a/src/Vex/I18n/en-US.json b/src/Vex/I18n/en-US.json index 1b59629..31550dc 100644 --- a/src/Vex/I18n/en-US.json +++ b/src/Vex/I18n/en-US.json @@ -129,6 +129,28 @@ "LanguageZhHant": "繁體中文", "LanguageEnUs": "English", "LanguageJaJp": "日本語", + "McpSettings": "MCP Settings", + "McpSettingsTitle": "MCP Settings", + "McpSettingsDescription": "Allow AI clients to read and operate the current document through the local MCP endpoint.", + "McpServerEnabled": "Enable MCP Server", + "McpServerAddress": "Address", + "McpServerPort": "Port", + "McpAuthorizationToken": "Authorization Token", + "McpGenerateToken": "Generate", + "McpCopyToken": "Copy Token", + "McpCopyEndpoint": "Copy Address", + "McpAllowedWorkspace": "Allowed Scope", + "McpScopeCurrentDocument": "Current document only", + "McpScopeCurrentFolder": "Current open folder", + "McpScopeCustomFolder": "Custom folder", + "McpCustomFolderWatermark": "Custom folder path", + "McpRequireConfirmation": "Confirm before modifying, deleting, or opening external paths", + "McpSave": "Save", + "McpCancel": "Cancel", + "McpOperationConfirmationTitle": "Confirm MCP Operation", + "McpOperationConfirmationMessage": "An AI client is requesting an operation that can affect the current document or open a path.", + "McpOperationConfirm": "Run", + "McpOperationRejected": "MCP operation rejected.", "Changelog": "Changelog", "QuickStart": "Quick Start", "OnboardingGuide": "New User Guide", diff --git a/src/Vex/I18n/ja-JP.json b/src/Vex/I18n/ja-JP.json index d7d87de..6364628 100644 --- a/src/Vex/I18n/ja-JP.json +++ b/src/Vex/I18n/ja-JP.json @@ -129,6 +129,28 @@ "LanguageZhHant": "繁體中文", "LanguageEnUs": "English", "LanguageJaJp": "日本語", + "McpSettings": "MCP 設定", + "McpSettingsTitle": "MCP 設定", + "McpSettingsDescription": "ローカル MCP エンドポイントを通じて AI クライアントが現在の文書を読み取り、操作できるようにします。", + "McpServerEnabled": "MCP Server を有効化", + "McpServerAddress": "待受アドレス", + "McpServerPort": "ポート", + "McpAuthorizationToken": "認証 Token", + "McpGenerateToken": "生成", + "McpCopyToken": "Token をコピー", + "McpCopyEndpoint": "アドレスをコピー", + "McpAllowedWorkspace": "アクセス許可範囲", + "McpScopeCurrentDocument": "現在の文書のみ", + "McpScopeCurrentFolder": "現在開いているフォルダー", + "McpScopeCustomFolder": "カスタムフォルダー", + "McpCustomFolderWatermark": "カスタムフォルダーのパス", + "McpRequireConfirmation": "変更、削除、外部パスを開く前に確認する", + "McpSave": "保存", + "McpCancel": "キャンセル", + "McpOperationConfirmationTitle": "MCP 操作の確認", + "McpOperationConfirmationMessage": "AI クライアントが現在の文書に影響する操作、またはパスを開く操作を要求しています。", + "McpOperationConfirm": "実行", + "McpOperationRejected": "MCP 操作を拒否しました。", "Changelog": "変更履歴", "QuickStart": "クイックスタート", "OnboardingGuide": "初回ガイド", diff --git a/src/Vex/I18n/zh-CN.json b/src/Vex/I18n/zh-CN.json index 503ea1c..f040df7 100644 --- a/src/Vex/I18n/zh-CN.json +++ b/src/Vex/I18n/zh-CN.json @@ -129,6 +129,28 @@ "LanguageZhHant": "繁體中文", "LanguageEnUs": "English", "LanguageJaJp": "日本語", + "McpSettings": "MCP 设置", + "McpSettingsTitle": "MCP 设置", + "McpSettingsDescription": "允许 AI 客户端通过本机 MCP 接口读取和操作当前文档。", + "McpServerEnabled": "启用 MCP Server", + "McpServerAddress": "监听地址", + "McpServerPort": "端口", + "McpAuthorizationToken": "授权 Token", + "McpGenerateToken": "生成", + "McpCopyToken": "复制 Token", + "McpCopyEndpoint": "复制地址", + "McpAllowedWorkspace": "允许访问范围", + "McpScopeCurrentDocument": "仅当前文档", + "McpScopeCurrentFolder": "当前打开文件夹", + "McpScopeCustomFolder": "自定义目录", + "McpCustomFolderWatermark": "自定义目录路径", + "McpRequireConfirmation": "修改、删除、打开外部路径前需要确认", + "McpSave": "保存", + "McpCancel": "取消", + "McpOperationConfirmationTitle": "确认 MCP 操作", + "McpOperationConfirmationMessage": "AI 客户端请求执行会影响当前文档或打开路径的操作。", + "McpOperationConfirm": "执行", + "McpOperationRejected": "已拒绝 MCP 操作。", "Changelog": "更新日志", "QuickStart": "快速开始(引导)", "OnboardingGuide": "新手引导", diff --git a/src/Vex/I18n/zh-Hant.json b/src/Vex/I18n/zh-Hant.json index ca9fc23..28289fc 100644 --- a/src/Vex/I18n/zh-Hant.json +++ b/src/Vex/I18n/zh-Hant.json @@ -129,6 +129,28 @@ "LanguageZhHant": "繁體中文", "LanguageEnUs": "English", "LanguageJaJp": "日本語", + "McpSettings": "MCP 設定", + "McpSettingsTitle": "MCP 設定", + "McpSettingsDescription": "允許 AI 用戶端透過本機 MCP 介面讀取和操作目前文件。", + "McpServerEnabled": "啟用 MCP Server", + "McpServerAddress": "監聽位址", + "McpServerPort": "連接埠", + "McpAuthorizationToken": "授權 Token", + "McpGenerateToken": "產生", + "McpCopyToken": "複製 Token", + "McpCopyEndpoint": "複製位址", + "McpAllowedWorkspace": "允許存取範圍", + "McpScopeCurrentDocument": "僅目前文件", + "McpScopeCurrentFolder": "目前開啟資料夾", + "McpScopeCustomFolder": "自訂目錄", + "McpCustomFolderWatermark": "自訂目錄路徑", + "McpRequireConfirmation": "修改、刪除、開啟外部路徑前需要確認", + "McpSave": "儲存", + "McpCancel": "取消", + "McpOperationConfirmationTitle": "確認 MCP 操作", + "McpOperationConfirmationMessage": "AI 用戶端要求執行會影響目前文件或開啟路徑的操作。", + "McpOperationConfirm": "執行", + "McpOperationRejected": "已拒絕 MCP 操作。", "Changelog": "更新日誌", "QuickStart": "快速開始(引導)", "OnboardingGuide": "新手引導", diff --git a/src/Vex/Modules/Mcp/Models/McpAccessScope.cs b/src/Vex/Modules/Mcp/Models/McpAccessScope.cs new file mode 100644 index 0000000..6c436ba --- /dev/null +++ b/src/Vex/Modules/Mcp/Models/McpAccessScope.cs @@ -0,0 +1,8 @@ +namespace Vex.Modules.Mcp.Models; + +public static class McpAccessScope +{ + public const string CurrentDocument = "CurrentDocument"; + public const string CurrentFolder = "CurrentFolder"; + public const string CustomFolder = "CustomFolder"; +} diff --git a/src/Vex/Modules/Mcp/Models/McpContracts.cs b/src/Vex/Modules/Mcp/Models/McpContracts.cs new file mode 100644 index 0000000..a837a07 --- /dev/null +++ b/src/Vex/Modules/Mcp/Models/McpContracts.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Vex.Modules.Mcp.Models; + +public sealed record JsonRpcRequest( + [property: JsonPropertyName("jsonrpc")] string? JsonRpc, + [property: JsonPropertyName("id")] JsonElement? Id, + [property: JsonPropertyName("method")] string? Method, + [property: JsonPropertyName("params")] JsonElement? Params); + +public sealed record JsonRpcResponse( + [property: JsonPropertyName("jsonrpc")] string JsonRpc, + [property: JsonPropertyName("id")] JsonElement? Id, + [property: JsonPropertyName("result")] JsonElement? Result = null, + [property: JsonPropertyName("error")] JsonRpcError? Error = null); + +public sealed record JsonRpcError( + [property: JsonPropertyName("code")] int Code, + [property: JsonPropertyName("message")] string Message); + +public sealed record McpInitializeResult( + [property: JsonPropertyName("protocolVersion")] string ProtocolVersion, + [property: JsonPropertyName("serverInfo")] McpServerInfo ServerInfo, + [property: JsonPropertyName("capabilities")] McpCapabilities Capabilities); + +public sealed record McpServerInfo( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("version")] string Version); + +public sealed record McpCapabilities( + [property: JsonPropertyName("tools")] McpToolsCapability Tools); + +public sealed record McpToolsCapability( + [property: JsonPropertyName("listChanged")] bool ListChanged); + +public sealed record McpToolsListResult( + [property: JsonPropertyName("tools")] IReadOnlyList Tools); + +public sealed record McpToolDescription( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("inputSchema")] JsonElement InputSchema); + +public sealed record McpToolCallParams( + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("arguments")] JsonElement? Arguments); + +public sealed record McpToolCallResult( + [property: JsonPropertyName("content")] IReadOnlyList Content, + [property: JsonPropertyName("isError")] bool IsError = false); + +public sealed record McpContentItem( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("text")] string Text); + +public sealed record EmptyInput; + +public sealed record ReplaceCurrentDocumentInput(string Markdown, string? Reason); + +public sealed record ApplyTextEditInput(int StartOffset, int Length, string Replacement, string? Reason); + +public sealed record InsertTextInput(int? Offset, string Text); + +public sealed record ReplaceSelectionInput(string Text, string? Reason); + +public sealed record OpenDocumentInput(string Path, string? EncodingName); + +public sealed record UiSetThemeInput(string ThemeKey); + +public sealed record UiSetTypographyInput(string TypographyKey); + +public sealed record UiSetLanguageInput(string CultureName); + +public sealed record UiSetLayoutInput(bool? SidebarVisible, bool? StatusBarVisible, bool? SourceMode, bool? LineNumbersVisible, bool? CompactLayout); + +public sealed record UiShowSidebarTabInput(string Tab); + +public sealed record UiOpenPanelInput(string Panel); + +public sealed record UiApplyEditorCommandInput(string Command); + +public sealed record ExportCurrentDocumentInput(string Format); + +public sealed record CopyRenderedHtmlInput(string Target); + +public sealed record CurrentDocumentResult(string? FilePath, string FileName, string Markdown, bool IsDirty, string Encoding); + +public sealed record OutlineResult(IReadOnlyList Items); + +public sealed record OutlineItemResult(int Level, string Title, int Line); + +public sealed record SelectionResult(string Text, int StartOffset, int Length); + +public sealed record AppStatusResult(string Version, string? Theme, string? Typography, string? CurrentFilePath, bool IsDirty); + +public sealed record UiStateResult( + string? Theme, + string? Typography, + string? CultureName, + bool IsCompactLayout, + bool IsSidebarVisible, + string SelectedSidebarTab, + bool IsPreviewVisible, + bool IsSourceMode, + bool ShowLineNumbers, + bool IsStatusBarVisible); + +public sealed record RenderedHtmlResult(string Html); + +public sealed record OperationResult(string Status, string? Detail = null); + +public sealed record OperationAuditResult(IReadOnlyList Items); diff --git a/src/Vex/Modules/Mcp/Models/McpOperationRecord.cs b/src/Vex/Modules/Mcp/Models/McpOperationRecord.cs new file mode 100644 index 0000000..e6c5e4f --- /dev/null +++ b/src/Vex/Modules/Mcp/Models/McpOperationRecord.cs @@ -0,0 +1,11 @@ +namespace Vex.Modules.Mcp.Models; + +public sealed record McpOperationRecord( + DateTimeOffset Timestamp, + string ToolName, + string Target, + string OperationType, + bool RequiresConfirmation, + bool Confirmed, + bool Succeeded, + string? Error); diff --git a/src/Vex/Modules/Mcp/Serialization/McpJsonContext.cs b/src/Vex/Modules/Mcp/Serialization/McpJsonContext.cs new file mode 100644 index 0000000..062ec80 --- /dev/null +++ b/src/Vex/Modules/Mcp/Serialization/McpJsonContext.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; +using Vex.Modules.Mcp.Models; + +namespace Vex.Modules.Mcp.Serialization; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(JsonRpcRequest))] +[JsonSerializable(typeof(JsonRpcResponse))] +[JsonSerializable(typeof(JsonRpcError))] +[JsonSerializable(typeof(McpInitializeResult))] +[JsonSerializable(typeof(McpServerInfo))] +[JsonSerializable(typeof(McpCapabilities))] +[JsonSerializable(typeof(McpToolsCapability))] +[JsonSerializable(typeof(McpToolsListResult))] +[JsonSerializable(typeof(McpToolDescription))] +[JsonSerializable(typeof(McpToolCallParams))] +[JsonSerializable(typeof(McpToolCallResult))] +[JsonSerializable(typeof(McpContentItem))] +[JsonSerializable(typeof(EmptyInput))] +[JsonSerializable(typeof(ReplaceCurrentDocumentInput))] +[JsonSerializable(typeof(ApplyTextEditInput))] +[JsonSerializable(typeof(InsertTextInput))] +[JsonSerializable(typeof(ReplaceSelectionInput))] +[JsonSerializable(typeof(OpenDocumentInput))] +[JsonSerializable(typeof(UiSetThemeInput))] +[JsonSerializable(typeof(UiSetTypographyInput))] +[JsonSerializable(typeof(UiSetLanguageInput))] +[JsonSerializable(typeof(UiSetLayoutInput))] +[JsonSerializable(typeof(UiShowSidebarTabInput))] +[JsonSerializable(typeof(UiOpenPanelInput))] +[JsonSerializable(typeof(UiApplyEditorCommandInput))] +[JsonSerializable(typeof(ExportCurrentDocumentInput))] +[JsonSerializable(typeof(CopyRenderedHtmlInput))] +[JsonSerializable(typeof(CurrentDocumentResult))] +[JsonSerializable(typeof(OutlineResult))] +[JsonSerializable(typeof(OutlineItemResult))] +[JsonSerializable(typeof(SelectionResult))] +[JsonSerializable(typeof(AppStatusResult))] +[JsonSerializable(typeof(UiStateResult))] +[JsonSerializable(typeof(RenderedHtmlResult))] +[JsonSerializable(typeof(OperationResult))] +[JsonSerializable(typeof(OperationAuditResult))] +[JsonSerializable(typeof(McpOperationRecord))] +internal sealed partial class McpJsonContext : JsonSerializerContext; diff --git a/src/Vex/Modules/Mcp/Services/IMcpOperationAuditService.cs b/src/Vex/Modules/Mcp/Services/IMcpOperationAuditService.cs new file mode 100644 index 0000000..e8fd89d --- /dev/null +++ b/src/Vex/Modules/Mcp/Services/IMcpOperationAuditService.cs @@ -0,0 +1,10 @@ +using Vex.Modules.Mcp.Models; + +namespace Vex.Modules.Mcp.Services; + +public interface IMcpOperationAuditService +{ + IReadOnlyList GetRecent(); + + void Record(McpOperationRecord record); +} diff --git a/src/Vex/Modules/Mcp/Services/IMcpOperationConfirmationService.cs b/src/Vex/Modules/Mcp/Services/IMcpOperationConfirmationService.cs new file mode 100644 index 0000000..1c5cd5f --- /dev/null +++ b/src/Vex/Modules/Mcp/Services/IMcpOperationConfirmationService.cs @@ -0,0 +1,6 @@ +namespace Vex.Modules.Mcp.Services; + +public interface IMcpOperationConfirmationService +{ + Task ConfirmAsync(string toolName, string target, string summary); +} diff --git a/src/Vex/Modules/Mcp/Services/IMcpServerHost.cs b/src/Vex/Modules/Mcp/Services/IMcpServerHost.cs new file mode 100644 index 0000000..097fc91 --- /dev/null +++ b/src/Vex/Modules/Mcp/Services/IMcpServerHost.cs @@ -0,0 +1,12 @@ +namespace Vex.Modules.Mcp.Services; + +public interface IMcpServerHost +{ + bool IsRunning { get; } + + string StatusText { get; } + + Task ApplySettingsAsync(); + + Task StopAsync(); +} diff --git a/src/Vex/Modules/Mcp/Services/IMcpToolDispatcher.cs b/src/Vex/Modules/Mcp/Services/IMcpToolDispatcher.cs new file mode 100644 index 0000000..903c47b --- /dev/null +++ b/src/Vex/Modules/Mcp/Services/IMcpToolDispatcher.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using Vex.Modules.Mcp.Models; + +namespace Vex.Modules.Mcp.Services; + +public interface IMcpToolDispatcher +{ + McpToolsListResult ListTools(); + + Task CallToolAsync(string name, JsonElement? arguments); +} diff --git a/src/Vex/Modules/Mcp/Services/McpOperationAuditService.cs b/src/Vex/Modules/Mcp/Services/McpOperationAuditService.cs new file mode 100644 index 0000000..a03cf80 --- /dev/null +++ b/src/Vex/Modules/Mcp/Services/McpOperationAuditService.cs @@ -0,0 +1,31 @@ +using Vex.Modules.Mcp.Models; + +namespace Vex.Modules.Mcp.Services; + +public sealed class McpOperationAuditService : IMcpOperationAuditService +{ + private const int MaxRecords = 100; + private readonly Lock _syncRoot = new(); + private readonly Queue _records = new(MaxRecords); + + public IReadOnlyList GetRecent() + { + lock (_syncRoot) + { + return _records.ToArray(); + } + } + + public void Record(McpOperationRecord record) + { + lock (_syncRoot) + { + while (_records.Count >= MaxRecords) + { + _records.Dequeue(); + } + + _records.Enqueue(record); + } + } +} diff --git a/src/Vex/Modules/Mcp/Services/McpOperationConfirmationService.cs b/src/Vex/Modules/Mcp/Services/McpOperationConfirmationService.cs new file mode 100644 index 0000000..7ed5220 --- /dev/null +++ b/src/Vex/Modules/Mcp/Services/McpOperationConfirmationService.cs @@ -0,0 +1,55 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Vex.Core.Services; +using Vex.Modules.Mcp.Views; +using Vex.Modules.Shell.Services; + +namespace Vex.Modules.Mcp.Services; + +public sealed class McpOperationConfirmationService : IMcpOperationConfirmationService +{ + private readonly IAppLocalizer _localizer; + private readonly IShellStatusPublisher _statusPublisher; + + public McpOperationConfirmationService(IAppLocalizer localizer, IShellStatusPublisher statusPublisher) + { + _localizer = localizer; + _statusPublisher = statusPublisher; + } + + public async Task ConfirmAsync(string toolName, string target, string summary) + { + var owner = GetMainWindow(); + var window = new McpOperationConfirmationWindow( + _localizer.Get(VexL.McpOperationConfirmationTitle), + _localizer.Get(VexL.McpOperationConfirmationMessage), + toolName, + target, + summary, + _localizer.Get(VexL.Cancel), + _localizer.Get(VexL.McpOperationConfirm)); + + if (owner is null) + { + window.Show(); + _statusPublisher.PublishResource(VexL.McpOperationRejected); + return false; + } + + var confirmed = await window.ShowDialog(owner); + if (!confirmed) + { + _statusPublisher.PublishResource(VexL.McpOperationRejected); + } + + return confirmed; + } + + private static Window? GetMainWindow() + { + return Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } mainWindow } + ? mainWindow + : null; + } +} diff --git a/src/Vex/Modules/Mcp/Services/McpServerHost.cs b/src/Vex/Modules/Mcp/Services/McpServerHost.cs new file mode 100644 index 0000000..226f874 --- /dev/null +++ b/src/Vex/Modules/Mcp/Services/McpServerHost.cs @@ -0,0 +1,270 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Vex.Core.Services; +using Vex.Modules.Mcp.Models; +using Vex.Modules.Mcp.Serialization; + +namespace Vex.Modules.Mcp.Services; + +public sealed class McpServerHost : IMcpServerHost +{ + private readonly IAppSettingsStore _settingsStore; + private readonly IMcpToolDispatcher _toolDispatcher; + private HttpListener? _listener; + private CancellationTokenSource? _cts; + private Task? _listenTask; + + public McpServerHost(IAppSettingsStore settingsStore, IMcpToolDispatcher toolDispatcher) + { + _settingsStore = settingsStore; + _toolDispatcher = toolDispatcher; + StatusText = "未启动"; + } + + public bool IsRunning { get; private set; } + + public string StatusText { get; private set; } + + public async Task ApplySettingsAsync() + { + await StopAsync(); + var settings = _settingsStore.Current; + if (settings.IsMcpServerEnabled != true) + { + IsRunning = false; + StatusText = "未启动"; + return; + } + + if (string.IsNullOrWhiteSpace(settings.McpAuthorizationToken)) + { + IsRunning = false; + StatusText = "Token 缺失"; + return; + } + + var host = string.IsNullOrWhiteSpace(settings.McpServerHost) ? "127.0.0.1" : settings.McpServerHost.Trim(); + var port = Math.Clamp(settings.McpServerPort ?? 17891, 1, 65535); + if (host is not "127.0.0.1" and not "localhost" and not "::1") + { + IsRunning = false; + StatusText = "仅允许本机地址"; + return; + } + + try + { + _cts = new CancellationTokenSource(); + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://{FormatEndpointHost(host)}:{port}/mcp/"); + _listener.Start(); + _listenTask = Task.Run(() => ListenAsync(_cts.Token)); + IsRunning = true; + StatusText = $"运行中:http://{FormatEndpointHost(host)}:{port}/mcp/"; + } + catch (Exception exception) when (exception is HttpListenerException or InvalidOperationException) + { + IsRunning = false; + StatusText = exception.Message; + await StopAsync(); + } + } + + public async Task StopAsync() + { + _cts?.Cancel(); + if (_listener is not null) + { + try + { + _listener.Stop(); + _listener.Close(); + } + catch (ObjectDisposedException) + { + } + } + + if (_listenTask is not null) + { + try + { + await _listenTask; + } + catch (OperationCanceledException) + { + } + catch (HttpListenerException) + { + } + } + + _listener = null; + _cts?.Dispose(); + _cts = null; + _listenTask = null; + IsRunning = false; + StatusText = "未启动"; + } + + private async Task ListenAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested && _listener is { IsListening: true } listener) + { + HttpListenerContext context; + try + { + context = await listener.GetContextAsync(); + } + catch (Exception exception) when (exception is ObjectDisposedException or HttpListenerException or InvalidOperationException) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + throw; + } + + _ = Task.Run(() => HandleContextAsync(context), cancellationToken); + } + } + + private async Task HandleContextAsync(HttpListenerContext context) + { + try + { + if (!context.Request.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase)) + { + await WritePlainAsync(context, 405, "Method Not Allowed"); + return; + } + + if (!IsAuthorized(context.Request)) + { + await WritePlainAsync(context, 401, "Unauthorized"); + return; + } + + var request = await JsonSerializer.DeserializeAsync( + context.Request.InputStream, + McpJsonContext.Default.JsonRpcRequest); + if (request is null || string.IsNullOrWhiteSpace(request.Method)) + { + await WriteJsonAsync(context, new JsonRpcResponse("2.0", request?.Id, Error: new JsonRpcError(-32600, "Invalid request"))); + return; + } + + var response = await DispatchAsync(request); + if (response is null) + { + WriteNoContent(context); + return; + } + + await WriteJsonAsync(context, response); + } + catch (Exception exception) + { + await WriteJsonAsync(context, new JsonRpcResponse("2.0", null, Error: new JsonRpcError(-32603, exception.Message))); + } + } + + private async Task DispatchAsync(JsonRpcRequest request) + { + var isNotification = request.Id is null; + if (isNotification && !request.Method!.StartsWith("notifications/", StringComparison.Ordinal)) + { + return null; + } + + switch (request.Method) + { + case "initialize": + return new JsonRpcResponse( + "2.0", + request.Id, + ToJsonElement( + new McpInitializeResult( + "2025-06-18", + new McpServerInfo("Vex", "1.0"), + new McpCapabilities(new McpToolsCapability(false))), + McpJsonContext.Default.McpInitializeResult)); + case "ping": + return new JsonRpcResponse("2.0", request.Id, ToJsonElement(new OperationResult("ok"), McpJsonContext.Default.OperationResult)); + case "tools/list": + return new JsonRpcResponse("2.0", request.Id, ToJsonElement(_toolDispatcher.ListTools(), McpJsonContext.Default.McpToolsListResult)); + case "tools/call": + var call = request.Params?.Deserialize(McpJsonContext.Default.McpToolCallParams); + if (call?.Name is not { Length: > 0 } name) + { + return new JsonRpcResponse("2.0", request.Id, Error: new JsonRpcError(-32602, "Tool name is required.")); + } + + return new JsonRpcResponse( + "2.0", + request.Id, + ToJsonElement(await _toolDispatcher.CallToolAsync(name, call.Arguments), McpJsonContext.Default.McpToolCallResult)); + case "notifications/initialized": + return null; + default: + if (isNotification) + { + return null; + } + + return new JsonRpcResponse("2.0", request.Id, Error: new JsonRpcError(-32601, $"Unknown method: {request.Method}")); + } + } + + private bool IsAuthorized(HttpListenerRequest request) + { + var token = _settingsStore.Current.McpAuthorizationToken; + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + var authorization = request.Headers["Authorization"]; + return authorization?.Equals($"Bearer {token}", StringComparison.Ordinal) == true; + } + + private static async Task WriteJsonAsync(HttpListenerContext context, JsonRpcResponse response) + { + context.Response.StatusCode = 200; + context.Response.ContentType = "application/json; charset=utf-8"; + await JsonSerializer.SerializeAsync(context.Response.OutputStream, response, McpJsonContext.Default.JsonRpcResponse); + context.Response.Close(); + } + + private static async Task WritePlainAsync(HttpListenerContext context, int statusCode, string text) + { + context.Response.StatusCode = statusCode; + context.Response.ContentType = "text/plain; charset=utf-8"; + var bytes = Encoding.UTF8.GetBytes(text); + context.Response.ContentLength64 = bytes.Length; + await context.Response.OutputStream.WriteAsync(bytes); + context.Response.Close(); + } + + private static void WriteNoContent(HttpListenerContext context) + { + context.Response.StatusCode = 204; + context.Response.ContentLength64 = 0; + context.Response.Close(); + } + + private static JsonElement ToJsonElement(T value, JsonTypeInfo typeInfo) + { + return JsonSerializer.SerializeToElement(value, typeInfo); + } + + private static string FormatEndpointHost(string host) + { + return host.Contains(':', StringComparison.Ordinal) && !host.StartsWith("[", StringComparison.Ordinal) + ? $"[{host}]" + : host; + } +} diff --git a/src/Vex/Modules/Mcp/Services/McpTokenGenerator.cs b/src/Vex/Modules/Mcp/Services/McpTokenGenerator.cs new file mode 100644 index 0000000..d5375fd --- /dev/null +++ b/src/Vex/Modules/Mcp/Services/McpTokenGenerator.cs @@ -0,0 +1,13 @@ +using System.Security.Cryptography; + +namespace Vex.Modules.Mcp.Services; + +public static class McpTokenGenerator +{ + public static string Generate() + { + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/src/Vex/Modules/Mcp/Services/McpToolDispatcher.cs b/src/Vex/Modules/Mcp/Services/McpToolDispatcher.cs new file mode 100644 index 0000000..14ce17e --- /dev/null +++ b/src/Vex/Modules/Mcp/Services/McpToolDispatcher.cs @@ -0,0 +1,663 @@ +using System.Reflection; +using System.Text; +using System.Text.Json; +using Avalonia.Threading; +using CodeWF.EventBus; +using Markdig; +using Vex.Core.Messaging; +using Vex.Core.Services; +using Vex.Modules.Mcp.Models; +using Vex.Modules.Mcp.Serialization; +using Vex.Modules.Shell.ViewModels; + +namespace Vex.Modules.Mcp.Services; + +public sealed class McpToolDispatcher : IMcpToolDispatcher +{ + private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); + private static readonly StringComparer PathComparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + private static readonly StringComparison PathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + private static readonly McpToolDescription[] Tools = + [ + Tool("vex_get_current_document", "读取当前文档。"), + Tool("vex_get_document_outline", "读取当前文档大纲。"), + Tool("vex_get_selection", "读取当前选区。"), + Tool("vex_replace_current_document", "整体替换当前 Markdown。", ReplaceDocumentSchema), + Tool("vex_apply_text_edit", "按 offset 应用文本编辑。", ApplyTextEditSchema), + Tool("vex_insert_text", "插入文本。", InsertTextSchema), + Tool("vex_replace_selection", "替换当前选区。", ReplaceSelectionSchema), + Tool("vex_open_document", "打开授权范围内的文档。", OpenDocumentSchema), + Tool("vex_save_current_document", "保存当前文档。"), + Tool("vex_refresh_preview", "刷新预览。"), + Tool("vex_get_rendered_html", "返回当前 Markdown 渲染后的 HTML。"), + Tool("vex_get_app_status", "读取应用状态。"), + Tool("vex_get_operation_audit", "读取最近 MCP 操作审计记录。"), + Tool("vex_ui_get_state", "读取界面状态。"), + Tool("vex_ui_set_theme", "设置主题色。", UiSetThemeSchema), + Tool("vex_ui_set_typography", "设置 Markdown 排版主题。", UiSetTypographySchema), + Tool("vex_ui_set_language", "设置界面语言。", UiSetLanguageSchema), + Tool("vex_ui_set_layout", "设置基础布局状态。", UiSetLayoutSchema), + Tool("vex_ui_show_sidebar_tab", "切换侧边栏页签。", UiShowSidebarTabSchema), + Tool("vex_ui_open_panel", "打开基础面板。", UiOpenPanelSchema), + Tool("vex_ui_refresh_preview", "刷新当前预览。"), + Tool("vex_ui_apply_editor_command", "执行常用编辑命令。", UiApplyEditorCommandSchema), + Tool("vex_ui_export_current_document", "导出当前文档,输出位置仍由用户选择。", ExportCurrentDocumentSchema), + Tool("vex_ui_copy_rendered_html", "复制面向平台的富 HTML。", CopyRenderedHtmlSchema) + ]; + + private const string EmptySchema = """{"type":"object","properties":{},"additionalProperties":false}"""; + private const string ReplaceDocumentSchema = """{"type":"object","properties":{"markdown":{"type":"string"},"reason":{"type":"string"}},"required":["markdown"],"additionalProperties":false}"""; + private const string ApplyTextEditSchema = """{"type":"object","properties":{"startOffset":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0},"replacement":{"type":"string"},"reason":{"type":"string"}},"required":["startOffset","length","replacement"],"additionalProperties":false}"""; + private const string InsertTextSchema = """{"type":"object","properties":{"offset":{"type":"integer","minimum":0},"text":{"type":"string"}},"required":["text"],"additionalProperties":false}"""; + private const string ReplaceSelectionSchema = """{"type":"object","properties":{"text":{"type":"string"},"reason":{"type":"string"}},"required":["text"],"additionalProperties":false}"""; + private const string OpenDocumentSchema = """{"type":"object","properties":{"path":{"type":"string"},"encodingName":{"type":"string"}},"required":["path"],"additionalProperties":false}"""; + private const string UiSetThemeSchema = """{"type":"object","properties":{"themeKey":{"type":"string","enum":["system","light","dark","aquatic","desert","dusk","night-sky"]}},"required":["themeKey"],"additionalProperties":false}"""; + private const string UiSetTypographySchema = """{"type":"object","properties":{"typographyKey":{"type":"string"}},"required":["typographyKey"],"additionalProperties":false}"""; + private const string UiSetLanguageSchema = """{"type":"object","properties":{"cultureName":{"type":"string","enum":["zh-CN","zh-Hant","en-US","ja-JP"]}},"required":["cultureName"],"additionalProperties":false}"""; + private const string UiSetLayoutSchema = """{"type":"object","properties":{"sidebarVisible":{"type":"boolean"},"statusBarVisible":{"type":"boolean"},"sourceMode":{"type":"boolean"},"lineNumbersVisible":{"type":"boolean"},"compactLayout":{"type":"boolean"}},"additionalProperties":false}"""; + private const string UiShowSidebarTabSchema = """{"type":"object","properties":{"tab":{"type":"string","enum":["files","outline"]}},"required":["tab"],"additionalProperties":false}"""; + private const string UiOpenPanelSchema = """{"type":"object","properties":{"panel":{"type":"string","enum":["find","replace","properties","wordCount","mcpSettings","changelog","thanks","about"]}},"required":["panel"],"additionalProperties":false}"""; + private const string UiApplyEditorCommandSchema = """{"type":"object","properties":{"command":{"type":"string","enum":["undo","redo","copy","selectAll","paragraph","heading1","heading2","heading3","bold","italic","inlineCode","link","image","quote","orderedList","unorderedList","taskList","codeFence","mathBlock","table","horizontalRule","clearFormatting"]}},"required":["command"],"additionalProperties":false}"""; + private const string ExportCurrentDocumentSchema = """{"type":"object","properties":{"format":{"type":"string","enum":["HTML","PDF","PNG","Word"]}},"required":["format"],"additionalProperties":false}"""; + private const string CopyRenderedHtmlSchema = """{"type":"object","properties":{"target":{"type":"string","enum":["wechat","zhihu","juejin"]}},"required":["target"],"additionalProperties":false}"""; + + private readonly MainWindowViewModel _shell; + private readonly IMarkdownOutlineService _outlineService; + private readonly IDocumentService _documentService; + private readonly IAppSettingsStore _settingsStore; + private readonly IMcpOperationConfirmationService _confirmationService; + private readonly IMcpOperationAuditService _auditService; + + public McpToolDispatcher( + MainWindowViewModel shell, + IMarkdownOutlineService outlineService, + IDocumentService documentService, + IAppSettingsStore settingsStore, + IMcpOperationConfirmationService confirmationService, + IMcpOperationAuditService auditService) + { + _shell = shell; + _outlineService = outlineService; + _documentService = documentService; + _settingsStore = settingsStore; + _confirmationService = confirmationService; + _auditService = auditService; + } + + public McpToolsListResult ListTools() => new(Tools); + + public async Task CallToolAsync(string name, JsonElement? arguments) + { + var toolName = NormalizeToolName(name); + try + { + var result = await Dispatcher.UIThread.InvokeAsync(async () => await CallToolOnUiThreadAsync(toolName, arguments)); + RecordAudit(toolName, result, error: null); + return TextResult(SerializeToolResult(result)); + } + catch (Exception exception) + { + RecordAudit(toolName, result: null, exception.Message); + return TextResult(exception.Message, isError: true); + } + } + + private async Task CallToolOnUiThreadAsync(string name, JsonElement? arguments) + { + switch (name) + { + case "vex_get_current_document": + return GetCurrentDocument(); + case "vex_get_document_outline": + return GetOutline(); + case "vex_get_selection": + return GetSelection(); + case "vex_replace_current_document": + return await ReplaceCurrentDocumentAsync(Read(arguments, McpJsonContext.Default.ReplaceCurrentDocumentInput)); + case "vex_apply_text_edit": + return await ApplyTextEditAsync(Read(arguments, McpJsonContext.Default.ApplyTextEditInput)); + case "vex_insert_text": + return await InsertTextAsync(Read(arguments, McpJsonContext.Default.InsertTextInput)); + case "vex_replace_selection": + return await ReplaceSelectionAsync(Read(arguments, McpJsonContext.Default.ReplaceSelectionInput)); + case "vex_open_document": + return await OpenDocumentAsync(Read(arguments, McpJsonContext.Default.OpenDocumentInput)); + case "vex_save_current_document": + await _shell.SaveAsync(); + return new OperationResult("ok", "saved"); + case "vex_refresh_preview": + case "vex_ui_refresh_preview": + _shell.RefreshPreview(); + return new OperationResult("ok", "preview refreshed"); + case "vex_get_rendered_html": + return new RenderedHtmlResult(Markdig.Markdown.ToHtml(_shell.Markdown, MarkdownPipeline)); + case "vex_get_app_status": + return GetAppStatus(); + case "vex_get_operation_audit": + return GetOperationAudit(); + case "vex_ui_get_state": + return GetUiState(); + case "vex_ui_set_theme": + return SetTheme(Read(arguments, McpJsonContext.Default.UiSetThemeInput)); + case "vex_ui_set_typography": + return SetTypography(Read(arguments, McpJsonContext.Default.UiSetTypographyInput)); + case "vex_ui_set_language": + return SetLanguage(Read(arguments, McpJsonContext.Default.UiSetLanguageInput)); + case "vex_ui_set_layout": + return SetLayout(Read(arguments, McpJsonContext.Default.UiSetLayoutInput)); + case "vex_ui_show_sidebar_tab": + return ShowSidebarTab(Read(arguments, McpJsonContext.Default.UiShowSidebarTabInput)); + case "vex_ui_open_panel": + return await OpenPanelAsync(Read(arguments, McpJsonContext.Default.UiOpenPanelInput)); + case "vex_ui_apply_editor_command": + return ApplyEditorCommand(Read(arguments, McpJsonContext.Default.UiApplyEditorCommandInput)); + case "vex_ui_export_current_document": + return await ExportCurrentDocumentAsync(Read(arguments, McpJsonContext.Default.ExportCurrentDocumentInput)); + case "vex_ui_copy_rendered_html": + return await CopyRenderedHtmlAsync(Read(arguments, McpJsonContext.Default.CopyRenderedHtmlInput)); + default: + throw new InvalidOperationException($"Unknown tool: {name}"); + } + } + + private CurrentDocumentResult GetCurrentDocument() + { + var document = _shell.GetCurrentDocumentSnapshot(); + return new CurrentDocumentResult( + document.FilePath, + document.FileName, + _shell.Markdown, + _shell.DocumentInfo.IsModified, + GetEncodingDisplayName(document.Encoding)); + } + + private OutlineResult GetOutline() + { + return new OutlineResult(_outlineService.BuildOutline(_shell.Markdown) + .Select(item => new OutlineItemResult(item.Level, item.Title, item.Line)) + .ToArray()); + } + + private static SelectionResult GetSelection() + { + var selection = CodeWF.EventBus.EventBus.Default.Query(new EditorSelectionQuery()); + return new SelectionResult(selection.Text, selection.StartOffset, selection.Length); + } + + private async Task ReplaceCurrentDocumentAsync(ReplaceCurrentDocumentInput input) + { + if (!await ConfirmIfRequiredAsync( + "vex_replace_current_document", + GetCurrentTargetName(), + input.Reason ?? "replace current markdown")) + { + return new OperationResult("canceled", "operation rejected"); + } + + _shell.ReplaceMarkdownFromMcp(input.Markdown); + return new OperationResult("ok", "document replaced"); + } + + private async Task ApplyTextEditAsync(ApplyTextEditInput input) + { + if (!await ConfirmIfRequiredAsync( + "vex_apply_text_edit", + GetCurrentTargetName(), + input.Reason ?? $"offset {input.StartOffset}, length {input.Length}")) + { + return new OperationResult("canceled", "operation rejected"); + } + + _shell.ApplyTextEditFromMcp(input.StartOffset, input.Length, input.Replacement); + return new OperationResult("ok", "text edit applied"); + } + + private async Task InsertTextAsync(InsertTextInput input) + { + if (!await ConfirmIfRequiredAsync( + "vex_insert_text", + GetCurrentTargetName(), + $"insert {input.Text.Length} characters")) + { + return new OperationResult("canceled", "operation rejected"); + } + + var offset = input.Offset ?? _shell.Markdown.Length; + _shell.ApplyTextEditFromMcp(offset, 0, input.Text); + return new OperationResult("ok", "text inserted"); + } + + private async Task ReplaceSelectionAsync(ReplaceSelectionInput input) + { + var selection = CodeWF.EventBus.EventBus.Default.Query(new EditorSelectionQuery()); + if (selection.Length <= 0) + { + throw new InvalidOperationException("No active editor selection."); + } + + if (!await ConfirmIfRequiredAsync( + "vex_replace_selection", + GetCurrentTargetName(), + input.Reason ?? $"replace selection at offset {selection.StartOffset}")) + { + return new OperationResult("canceled", "operation rejected"); + } + + _shell.ApplyTextEditFromMcp(selection.StartOffset, selection.Length, input.Text); + return new OperationResult("ok", "selection replaced"); + } + + private async Task OpenDocumentAsync(OpenDocumentInput input) + { + if (string.IsNullOrWhiteSpace(input.Path)) + { + throw new InvalidOperationException("Document path is required."); + } + + if (!_documentService.IsSupportedDocumentPath(input.Path)) + { + throw new InvalidOperationException("Unsupported document path."); + } + + var fullPath = ResolveAuthorizedDocumentPath(input.Path); + if (!await ConfirmIfRequiredAsync("vex_open_document", fullPath, "open document")) + { + return new OperationResult("canceled", "operation rejected"); + } + + await _shell.OpenPathFromMcpAsync(fullPath, input.EncodingName); + return new OperationResult("ok", "document opened"); + } + + private string ResolveAuthorizedDocumentPath(string path) + { + var fullPath = Path.GetFullPath(path); + if (fullPath.StartsWith(@"\\", StringComparison.Ordinal)) + { + throw new InvalidOperationException("UNC paths are not allowed."); + } + + var settings = _settingsStore.Current; + var scope = settings.McpAccessScope; + if (scope == McpAccessScope.CurrentFolder) + { + var currentFolder = _shell.DocumentInfo.CurrentFilePath is { Length: > 0 } currentPath + ? Path.GetDirectoryName(currentPath) + : null; + EnsureUnderRoot(fullPath, currentFolder, "Current document folder is unavailable."); + return fullPath; + } + + if (scope == McpAccessScope.CustomFolder) + { + EnsureUnderRoot(fullPath, settings.McpAllowedWorkspacePath, "Custom MCP folder is unavailable."); + return fullPath; + } + + var currentDocumentPath = _shell.DocumentInfo.CurrentFilePath; + if (string.IsNullOrWhiteSpace(currentDocumentPath) || !PathComparer.Equals(fullPath, Path.GetFullPath(currentDocumentPath))) + { + throw new InvalidOperationException("Path is outside the current document scope."); + } + + return fullPath; + } + + private static void EnsureUnderRoot(string fullPath, string? rootPath, string missingRootMessage) + { + if (string.IsNullOrWhiteSpace(rootPath)) + { + throw new InvalidOperationException(missingRootMessage); + } + + var root = Path.GetFullPath(rootPath); + if (!Directory.Exists(root)) + { + throw new InvalidOperationException("Authorized folder does not exist."); + } + + var normalizedRoot = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + if (!PathComparer.Equals(fullPath, root) && !fullPath.StartsWith(normalizedRoot, PathComparison)) + { + throw new InvalidOperationException("Path is outside the authorized folder."); + } + } + + private Task ConfirmIfRequiredAsync(string toolName, string target, string summary) + { + return _settingsStore.Current.McpRequireConfirmation == false + ? Task.FromResult(true) + : _confirmationService.ConfirmAsync(toolName, target, summary); + } + + private void RecordAudit(string toolName, object? result, string? error) + { + var operationResult = result as OperationResult; + var succeeded = error is null && operationResult?.Status != "canceled"; + var confirmed = error is null && operationResult?.Status != "canceled"; + var requiresConfirmation = ToolRequiresConfirmation(toolName); + _auditService.Record(new McpOperationRecord( + DateTimeOffset.Now, + toolName, + ResolveAuditTarget(toolName), + ResolveOperationType(toolName), + requiresConfirmation, + !requiresConfirmation || confirmed, + succeeded, + error ?? (operationResult?.Status == "canceled" ? operationResult.Detail : null))); + } + + private string ResolveAuditTarget(string toolName) + { + return toolName switch + { + "vex_ui_copy_rendered_html" => "clipboard", + "vex_ui_set_theme" or "vex_ui_set_typography" or "vex_ui_set_language" or "vex_ui_set_layout" + or "vex_ui_show_sidebar_tab" or "vex_ui_open_panel" or "vex_ui_refresh_preview" + or "vex_ui_apply_editor_command" => "ui", + _ => GetCurrentTargetName() + }; + } + + private static string ResolveOperationType(string toolName) + { + return toolName switch + { + "vex_get_current_document" or "vex_get_document_outline" or "vex_get_selection" + or "vex_get_rendered_html" or "vex_get_app_status" or "vex_ui_get_state" => "read", + "vex_replace_current_document" or "vex_apply_text_edit" or "vex_insert_text" + or "vex_replace_selection" => "edit", + "vex_open_document" => "open", + "vex_save_current_document" => "save", + "vex_ui_export_current_document" => "export", + "vex_ui_copy_rendered_html" => "clipboard", + _ => "ui" + }; + } + + private static bool ToolRequiresConfirmation(string toolName) + { + return toolName is "vex_replace_current_document" + or "vex_apply_text_edit" + or "vex_insert_text" + or "vex_replace_selection" + or "vex_open_document" + or "vex_ui_copy_rendered_html"; + } + + private string GetCurrentTargetName() + { + var document = _shell.GetCurrentDocumentSnapshot(); + return document.FilePath ?? document.FileName; + } + + private AppStatusResult GetAppStatus() + { + return new AppStatusResult( + Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown", + _shell.Appearance.SelectedTheme?.Key, + _shell.Appearance.SelectedTypography?.Key, + _shell.DocumentInfo.CurrentFilePath, + _shell.DocumentInfo.IsModified); + } + + private OperationAuditResult GetOperationAudit() + { + return new OperationAuditResult(_auditService.GetRecent()); + } + + private UiStateResult GetUiState() + { + return new UiStateResult( + _shell.Appearance.SelectedTheme?.Key, + _shell.Appearance.SelectedTypography?.Key, + _shell.Appearance.SelectedLanguage?.CultureName, + _shell.Appearance.IsCompactLayout, + _shell.Layout.IsSidebarVisible, + _shell.Navigation.SelectedSideTabIndex == 1 ? "outline" : "files", + _shell.Layout.IsPreviewVisible, + _shell.Layout.IsSourceMode, + _shell.EditorDisplay.ShowLineNumbers, + _shell.Layout.IsStatusBarVisible); + } + + private OperationResult SetTheme(UiSetThemeInput input) + { + _shell.Appearance.SelectThemeByKey(input.ThemeKey); + return new OperationResult("ok", "theme updated"); + } + + private OperationResult SetTypography(UiSetTypographyInput input) + { + _shell.Appearance.SelectTypographyByKey(input.TypographyKey); + return new OperationResult("ok", "typography updated"); + } + + private OperationResult SetLanguage(UiSetLanguageInput input) + { + _shell.Appearance.SelectLanguageByCulture(input.CultureName); + return new OperationResult("ok", "language updated"); + } + + private OperationResult SetLayout(UiSetLayoutInput input) + { + if (input.SidebarVisible is { } sidebar) + { + _shell.Layout.IsSidebarVisible = sidebar; + } + + if (input.StatusBarVisible is { } statusBar) + { + _shell.Layout.IsStatusBarVisible = statusBar; + } + + if (input.LineNumbersVisible is { } lineNumbers) + { + _shell.EditorDisplay.ShowLineNumbers = lineNumbers; + } + + if (input.CompactLayout is { } compact) + { + _shell.Appearance.IsCompactLayout = compact; + } + + if (input.SourceMode is { } sourceMode && sourceMode != _shell.Layout.IsSourceMode) + { + _shell.Layout.ToggleSourceMode(); + } + + return new OperationResult("ok", "layout updated"); + } + + private OperationResult ShowSidebarTab(UiShowSidebarTabInput input) + { + if (input.Tab.Equals("outline", StringComparison.OrdinalIgnoreCase)) + { + _shell.Layout.ShowOutline(); + } + else + { + _shell.Layout.ShowFiles(); + } + + return new OperationResult("ok", "sidebar tab updated"); + } + + private async Task OpenPanelAsync(UiOpenPanelInput input) + { + switch (NormalizeToolToken(input.Panel)) + { + case "find": + _shell.ShowFindPanel(); + break; + case "replace": + _shell.ShowReplacePanel(); + break; + case "properties": + _shell.ShowProperties(); + break; + case "wordcount": + _shell.WordCount(); + break; + case "changelog": + await _shell.Help.OpenHelpTopic("changelog"); + break; + case "thanks": + await _shell.Help.OpenHelpTopic("thanks"); + break; + case "about": + await _shell.Help.OpenHelpTopic("about"); + break; + case "mcpsettings": + CodeWF.EventBus.EventBus.Default.Publish(new ShellActionCommand(ShellActionKind.ShowMcpSettings)); + break; + default: + throw new InvalidOperationException($"Unsupported panel: {input.Panel}"); + } + + return new OperationResult("ok", "panel opened"); + } + + private async Task ExportCurrentDocumentAsync(ExportCurrentDocumentInput input) + { + if (NormalizeToolToken(input.Format) is not ("html" or "pdf" or "png" or "word")) + { + throw new InvalidOperationException($"Unsupported export format: {input.Format}"); + } + + await _shell.Export(input.Format); + return new OperationResult("ok", "export requested"); + } + + private async Task CopyRenderedHtmlAsync(CopyRenderedHtmlInput input) + { + if (NormalizeToolToken(input.Target) is not ("wechat" or "zhihu" or "juejin")) + { + throw new InvalidOperationException($"Unsupported copy target: {input.Target}"); + } + + if (!await ConfirmIfRequiredAsync("vex_ui_copy_rendered_html", "clipboard", $"copy rendered HTML for {input.Target}")) + { + return new OperationResult("canceled", "operation rejected"); + } + + await _shell.CopyHtml(input.Target); + return new OperationResult("ok", "rendered HTML copied"); + } + + private static OperationResult ApplyEditorCommand(UiApplyEditorCommandInput input) + { + if (!TryMapEditorAction(input.Command, out var action)) + { + throw new InvalidOperationException($"Unsupported editor command: {input.Command}"); + } + + CodeWF.EventBus.EventBus.Default.Publish(new EditorActionCommand(action)); + return new OperationResult("ok", "editor command applied"); + } + + private static bool TryMapEditorAction(string command, out EditorActionKind action) + { + var normalized = NormalizeToolToken(command); + action = normalized switch + { + "undo" => EditorActionKind.Undo, + "redo" => EditorActionKind.Redo, + "copy" => EditorActionKind.Copy, + "selectall" => EditorActionKind.SelectAll, + "paragraph" => EditorActionKind.Paragraph, + "heading1" => EditorActionKind.Heading1, + "heading2" => EditorActionKind.Heading2, + "heading3" => EditorActionKind.Heading3, + "bold" => EditorActionKind.Bold, + "italic" => EditorActionKind.Italic, + "inlinecode" => EditorActionKind.InlineCode, + "link" => EditorActionKind.Link, + "image" => EditorActionKind.Image, + "quote" => EditorActionKind.Quote, + "orderedlist" => EditorActionKind.OrderedList, + "unorderedlist" => EditorActionKind.UnorderedList, + "tasklist" => EditorActionKind.TaskList, + "codefence" => EditorActionKind.CodeFence, + "mathblock" => EditorActionKind.MathBlock, + "table" => EditorActionKind.Table, + "horizontalrule" => EditorActionKind.HorizontalRule, + "clearformatting" => EditorActionKind.ClearFormatting, + _ => default + }; + return normalized is "undo" or "redo" or "copy" or "selectall" or "paragraph" + or "heading1" or "heading2" or "heading3" or "bold" or "italic" or "inlinecode" + or "link" or "image" or "quote" or "orderedlist" or "unorderedlist" or "tasklist" + or "codefence" or "mathblock" or "table" or "horizontalrule" or "clearformatting"; + } + + private static T Read(JsonElement? arguments, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo) + { + if (arguments is null || arguments.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return JsonSerializer.Deserialize("{}", typeInfo)!; + } + + return arguments.Value.Deserialize(typeInfo)!; + } + + private static McpToolCallResult TextResult(string text, bool isError = false) + { + return new McpToolCallResult([new McpContentItem("text", text)], isError); + } + + private static string SerializeToolResult(object result) + { + return result switch + { + CurrentDocumentResult value => JsonSerializer.Serialize(value, McpJsonContext.Default.CurrentDocumentResult), + OutlineResult value => JsonSerializer.Serialize(value, McpJsonContext.Default.OutlineResult), + SelectionResult value => JsonSerializer.Serialize(value, McpJsonContext.Default.SelectionResult), + AppStatusResult value => JsonSerializer.Serialize(value, McpJsonContext.Default.AppStatusResult), + UiStateResult value => JsonSerializer.Serialize(value, McpJsonContext.Default.UiStateResult), + RenderedHtmlResult value => JsonSerializer.Serialize(value, McpJsonContext.Default.RenderedHtmlResult), + OperationResult value => JsonSerializer.Serialize(value, McpJsonContext.Default.OperationResult), + OperationAuditResult value => JsonSerializer.Serialize(value, McpJsonContext.Default.OperationAuditResult), + _ => JsonSerializer.Serialize(new OperationResult("ok", result.ToString()), McpJsonContext.Default.OperationResult) + }; + } + + private static McpToolDescription Tool(string name, string description, string inputSchema = EmptySchema) + { + using var document = JsonDocument.Parse(inputSchema); + return new McpToolDescription(name, description, document.RootElement.Clone()); + } + + private static string NormalizeToolName(string name) + { + return name.Replace('.', '_'); + } + + private static string NormalizeToolToken(string value) + { + return value.Replace("-", string.Empty, StringComparison.Ordinal) + .Replace("_", string.Empty, StringComparison.Ordinal) + .ToLowerInvariant(); + } + + private static string GetEncodingDisplayName(Encoding encoding) + { + if (encoding is UTF8Encoding { Preamble.Length: > 0 }) + { + return "UTF-8 BOM"; + } + + if (encoding.CodePage == Encoding.UTF8.CodePage) + { + return "UTF-8"; + } + + return encoding.WebName.ToUpperInvariant(); + } +} diff --git a/src/Vex/Modules/Mcp/Views/McpOperationConfirmationWindow.axaml b/src/Vex/Modules/Mcp/Views/McpOperationConfirmationWindow.axaml new file mode 100644 index 0000000..0cc413e --- /dev/null +++ b/src/Vex/Modules/Mcp/Views/McpOperationConfirmationWindow.axaml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + +