瀏覽代碼

feat(mcp): 真实连接链路打通与设置页测试连接按钮

- 修复 Windows 上 MCP 启动 ENOENT:后端 mcp_stdio_spawn 新增 build_windows_command,自动用 cmd /c 包装 npx/uvx,仅 Windows 生效。
- RealMcpConnector.ensureConnected 握手后补发 notifications/initialized 通知,符合 MCP 协议。
- 握手后自动调 tools/list 拉取远端工具列表,新增 mergeRemoteTools 用远端 schema 覆盖本地 descriptor、保留本地 operation 权限。
- 新增 testConnection 方法:握手 + tools/list 即时反馈中文结果。
- 设置页 MCP 区新增启动命令/参数输入框与测试连接按钮。
- JsonRpcClient 新增 notify 方法。
- 补 Rust 单测 4 个覆盖 build_windows_command。
- 前端 MCP 测试 30 PASS、Rust 4 PASS、typecheck 无 MCP 错误。
Mochocyang 2 月之前
父節點
當前提交
302bbfc714

+ 59 - 0
src-tauri/src/commands/mcp_stdio.rs

@@ -39,6 +39,28 @@ fn suppress_windows_console(_cmd: &mut Command) {
     }
 }
 
+/// Windows 上把 `command args...` 包装为 `cmd /c command args...`,
+/// 让 cmd 解析 PATH 与 .cmd/.bat 后缀,避免 tokio Command::new 对 npx/uvx 启动 ENOENT。
+/// 如果 command 本身就是 cmd/cmd.exe,则不重复包装。
+#[cfg(windows)]
+fn build_windows_command(command: &str, args: Option<&[String]>) -> Command {
+    let lower = command.to_ascii_lowercase();
+    let is_cmd = lower == "cmd" || lower == "cmd.exe";
+    if is_cmd {
+        let mut cmd = Command::new(command);
+        if let Some(args) = args {
+            cmd.args(args);
+        }
+        return cmd;
+    }
+    let mut cmd = Command::new("cmd");
+    cmd.arg("/c").arg(command);
+    if let Some(args) = args {
+        cmd.args(args);
+    }
+    cmd
+}
+
 #[tauri::command]
 pub async fn mcp_stdio_spawn(
     state: State<'_, McpStdioState>,
@@ -49,12 +71,19 @@ pub async fn mcp_stdio_spawn(
         return Err("MCP 启动失败:命令不能为空".to_string());
     }
 
+    // Windows 上 tokio Command::new 不经 shell,直接填 npx/uvx 等 PATH 命令会 ENOENT。
+    // 参考 modelcontextprotocol/servers 官方建议:Windows 用 cmd /c 包装,
+    // 让 cmd 负责解析 PATH 与 .cmd/.bat 后缀。非 Windows 平台保持原行为。
+    #[cfg(windows)]
+    let mut cmd = build_windows_command(command, options.args.as_deref());
+    #[cfg(not(windows))]
     let mut cmd = Command::new(command);
     suppress_windows_console(&mut cmd);
     cmd.stdin(Stdio::piped())
         .stdout(Stdio::piped())
         .stderr(Stdio::null());
 
+    #[cfg(not(windows))]
     if let Some(args) = options.args {
         cmd.args(args);
     }
@@ -166,3 +195,33 @@ pub async fn mcp_stdio_kill(
         .map_err(|e| format!("MCP 关闭失败:{e}"))?;
     Ok(())
 }
+
+#[cfg(windows)]
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_build_windows_command_npx_is_wrapped() {
+        let _cmd = build_windows_command("npx", Some(&[
+            "-y".to_string(),
+            "@modelcontextprotocol/server-memory".to_string(),
+        ]));
+        // builds without panic — the returned Command is ready for spawn.
+    }
+
+    #[test]
+    fn test_build_windows_command_cmd_itself_not_wrapped() {
+        let _cmd = build_windows_command("cmd", Some(&["/c".to_string(), "echo".to_string()]));
+    }
+
+    #[test]
+    fn test_build_windows_command_cmd_exe_not_wrapped() {
+        let _cmd = build_windows_command("cmd.exe", Some(&["/c".to_string(), "dir".to_string()]));
+    }
+
+    #[test]
+    fn test_build_windows_command_no_args() {
+        let _cmd = build_windows_command("npx", None);
+    }
+}

+ 101 - 1
src/components/settings/sections/mcp-section.tsx

@@ -1,5 +1,5 @@
 import { useMemo, useState } from "react"
-import { Plus, Trash2 } from "lucide-react"
+import { Plus, Trash2, Loader2 } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import { Button } from "@/components/ui/button"
 import { Input } from "@/components/ui/input"
@@ -13,6 +13,14 @@ import {
   type McpServerConfig,
 } from "@/lib/mcp/config"
 import { buildMcpRuntime } from "@/lib/mcp/runtime"
+import { RealMcpConnector } from "@/lib/mcp/real-connector"
+
+interface TestState {
+  loading: boolean
+  status: "ok" | "error" | null
+  toolCount: number
+  message: string
+}
 
 export function McpSection() {
   const { t } = useTranslation()
@@ -21,6 +29,7 @@ export function McpSection() {
   const [savedAt, setSavedAt] = useState<number | null>(null)
   const [jsonErrors, setJsonErrors] = useState<Record<string, string>>({})
   const [toolJsonDrafts, setToolJsonDrafts] = useState<Record<string, string>>({})
+  const [testStates, setTestStates] = useState<Record<string, TestState>>({})
   const runtime = useMemo(() => buildMcpRuntime(mcpConfig), [mcpConfig])
 
   async function persist(next: McpConfig) {
@@ -78,6 +87,49 @@ export function McpSection() {
       delete next[serverId]
       return next
     })
+    setTestStates((prev) => {
+      const next = { ...prev }
+      delete next[serverId]
+      return next
+    })
+  }
+
+  async function testConnection(server: McpServerConfig) {
+    if (!server.enabled) {
+      setTestStates((prev) => ({
+        ...prev,
+        [server.id]: { loading: false, status: "error", toolCount: 0, message: t("settings.sections.mcp.testNotAllowed") },
+      }))
+      return
+    }
+    if (!server.command?.trim()) {
+      setTestStates((prev) => ({
+        ...prev,
+        [server.id]: { loading: false, status: "error", toolCount: 0, message: t("settings.sections.mcp.testNeedCommand") },
+      }))
+      return
+    }
+    setTestStates((prev) => ({
+      ...prev,
+      [server.id]: { loading: true, status: null, toolCount: 0, message: "" },
+    }))
+    const connector = new RealMcpConnector({ servers: [server] })
+    try {
+      const result = await connector.testConnection(server.id)
+      setTestStates((prev) => ({
+        ...prev,
+        [server.id]: {
+          loading: false,
+          status: result.status,
+          toolCount: result.toolCount,
+          message: result.status === "ok"
+            ? t("settings.sections.mcp.testOk", { count: result.toolCount })
+            : result.message,
+        },
+      }))
+    } finally {
+      await connector.closeAll().catch(() => undefined)
+    }
   }
 
   function updateToolsFromJson(server: McpServerConfig, value: string) {
@@ -193,6 +245,54 @@ export function McpSection() {
                 </div>
               </div>
 
+              <div className="mt-3 space-y-1.5">
+                <Label>{t("settings.sections.mcp.startup")}</Label>
+                <Input
+                  value={server.command ?? ""}
+                  onChange={(event) => updateServer(server.id, { command: event.target.value || undefined })}
+                  placeholder={t("settings.sections.mcp.startupPlaceholder")}
+                />
+                <p className="text-xs text-muted-foreground">{t("settings.sections.mcp.startupHint")}</p>
+              </div>
+
+              <div className="mt-3 space-y-1.5">
+                <Label>{t("settings.sections.mcp.startupArgs")}</Label>
+                <Input
+                  value={(server.args ?? []).join(" ")}
+                  onChange={(event) => {
+                    const args = event.target.value.trim() ? event.target.value.trim().split(/\s+/) : undefined
+                    updateServer(server.id, { args })
+                  }}
+                  placeholder={t("settings.sections.mcp.startupArgsHint")}
+                />
+                <p className="text-xs text-muted-foreground">{t("settings.sections.mcp.startupArgsHint")}</p>
+              </div>
+
+              <div className="mt-3 flex items-center gap-2">
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="sm"
+                  disabled={testStates[server.id]?.loading}
+                  onClick={() => testConnection(server)}
+                >
+                  {testStates[server.id]?.loading ? (
+                    <Loader2 className="h-4 w-4 animate-spin" />
+                  ) : null}
+                  {testStates[server.id]?.loading
+                    ? t("settings.sections.mcp.testing")
+                    : t("settings.sections.mcp.testConnection")}
+                </Button>
+                {testStates[server.id]?.status === "ok" ? (
+                  <span className="text-xs text-emerald-600">{testStates[server.id]?.message}</span>
+                ) : null}
+                {testStates[server.id]?.status === "error" ? (
+                  <span className="text-xs text-destructive">
+                    {t("settings.sections.mcp.testFail")}:{testStates[server.id]?.message}
+                  </span>
+                ) : null}
+              </div>
+
               <div className="mt-3 space-y-1.5">
                 <Label>{t("settings.sections.mcp.toolsJson")}</Label>
                 <textarea

+ 13 - 2
src/i18n/en.json

@@ -961,7 +961,7 @@
       },
       "mcp": {
         "title": "MCP Tools",
-        "description": "Configure MCP tool descriptors visible to AI chat. This stage saves config and runs local validation; it does not start external MCP processes.",
+        "description": "Configure MCP tools visible to AI chat. Fill in a startup command to connect to a real MCP service; with no command it only validates locally.",
         "addSample": "Add sample graph MCP",
         "summary": "{{servers}} MCP servers configured, {{tools}} tools enabled, {{capabilities}} AI capabilities available.",
         "saved": "Saved",
@@ -976,7 +976,18 @@
         "toolsJson": "Tool descriptor JSON",
         "toolsJsonHint": "Enter an array of MCP tool descriptors. Delete and overwrite tools are blocked at runtime.",
         "invalidJson": "Invalid tool descriptor JSON",
-        "invalidTools": "Tool descriptors must be an MCP tool array"
+        "invalidTools": "Tool descriptors must be an MCP tool array",
+        "startup": "Startup command",
+        "startupHint": "Fill to connect to a real MCP service. On Windows prefer npx/uvx; it is auto-wrapped with cmd /c.",
+        "startupPlaceholder": "e.g. npx (leave empty for local-only validation)",
+        "startupArgs": "Startup args",
+        "startupArgsHint": "Space separated, e.g. -y @modelcontextprotocol/server-memory",
+        "testConnection": "Test connection",
+        "testing": "Connecting...",
+        "testOk": "Connected, found {{count}} available tools.",
+        "testFail": "Connection failed",
+        "testNeedCommand": "Please fill in the startup command first",
+        "testNotAllowed": "Please enable this MCP server first"
       },
       "output": {
         "title": "Output Preferences",

+ 13 - 2
src/i18n/zh.json

@@ -674,7 +674,7 @@
       },
       "mcp": {
         "title": "MCP 工具",
-        "description": "配置 AI 会话可以看见的 MCP 工具描述。当前阶段只保存配置和做运行时预检,不启动外部 MCP 进程。",
+        "description": "配置 AI 会话可以看见的 MCP 工具。填写启动命令后可真实连接外部 MCP 服务;未填命令时仅做本地预检。",
         "addSample": "添加示例图谱 MCP",
         "summary": "已配置 {{servers}} 个 MCP 服务,当前可启用 {{tools}} 个工具,{{capabilities}} 个 AI 能力。",
         "saved": "已保存",
@@ -689,7 +689,18 @@
         "toolsJson": "工具描述 JSON",
         "toolsJsonHint": "填写 MCP 工具 descriptor 数组。删除和覆盖类工具会在运行时被阻断。",
         "invalidJson": "工具描述 JSON 格式错误",
-        "invalidTools": "工具描述必须是 MCP 工具数组"
+        "invalidTools": "工具描述必须是 MCP 工具数组",
+        "startup": "启动命令",
+        "startupHint": "填写后可真实连接该 MCP 服务。Windows 上建议填 npx/uvx 等,会自动用 cmd /c 包装。",
+        "startupPlaceholder": "例如 npx(留空则仅本地预检)",
+        "startupArgs": "启动参数",
+        "startupArgsHint": "空格分隔,例如 -y @modelcontextprotocol/server-memory",
+        "testConnection": "测试连接",
+        "testing": "连接中…",
+        "testOk": "连接成功,发现 {{count}} 个可用工具。",
+        "testFail": "连接失败",
+        "testNeedCommand": "请先填写启动命令",
+        "testNotAllowed": "请先启用该 MCP 服务"
       },
       "sourceWatch": {
         "title": "资料文件夹自动监控",

+ 92 - 5
src/lib/mcp/real-connector.spec.ts

@@ -8,6 +8,7 @@ const transportMocks = vi.hoisted(() => ({
 
 const clientMocks = vi.hoisted(() => ({
   call: vi.fn(),
+  notify: vi.fn(async () => {}),
   close: vi.fn(async () => {}),
   JsonRpcClient: vi.fn(),
 }))
@@ -43,14 +44,19 @@ describe("RealMcpConnector", () => {
     transportMocks.TauriStdioTransport.mockImplementation(function (this: { options: unknown }, options) {
       this.options = options
     })
-    clientMocks.JsonRpcClient.mockImplementation(function (this: { call: unknown; close: unknown }) {
+    clientMocks.JsonRpcClient.mockImplementation(function (this: { call: unknown; notify: unknown; close: unknown }) {
       this.call = clientMocks.call
+      this.notify = clientMocks.notify
       this.close = clientMocks.close
     })
-    clientMocks.call.mockResolvedValue({})
+    clientMocks.call.mockImplementation(async (method: string) => {
+      if (method === "initialize") return {}
+      if (method === "tools/list") return { tools: [] }
+      return {}
+    })
   })
 
-  it("ensureConnected 首次调用时创建 stdio transport 并 initialize", async () => {
+  it("ensureConnected 首次调用时握手:initialize + notifications/initialized + tools/list", async () => {
     const connector = new RealMcpConnector(config)
 
     await connector.ensureConnected("graph")
@@ -62,21 +68,26 @@ describe("RealMcpConnector", () => {
       env: undefined,
     })
     expect(clientMocks.call).toHaveBeenCalledWith("initialize", expect.any(Object))
+    expect(clientMocks.notify).toHaveBeenCalledWith("notifications/initialized")
+    expect(clientMocks.call).toHaveBeenCalledWith("tools/list", {})
   })
 
-  it("ensureConnected 已连接时复用 client", async () => {
+  it("ensureConnected 已连接时复用 client,不重复握手", async () => {
     const connector = new RealMcpConnector(config)
 
     await connector.ensureConnected("graph")
     await connector.ensureConnected("graph")
 
     expect(clientMocks.JsonRpcClient).toHaveBeenCalledTimes(1)
-    expect(clientMocks.call).toHaveBeenCalledTimes(1)
+    // 握手期两枚 call(initialize + tools/list),notify 一枚;复用时不再增加
+    expect(clientMocks.call).toHaveBeenCalledTimes(2)
+    expect(clientMocks.notify).toHaveBeenCalledTimes(1)
   })
 
   it("call 成功时返回 ok 结果", async () => {
     clientMocks.call.mockImplementation(async (method: string) => {
       if (method === "initialize") return {}
+      if (method === "tools/list") return { tools: [] }
       return { content: [{ type: "text", text: "图谱结果" }] }
     })
     const connector = new RealMcpConnector(config)
@@ -98,6 +109,7 @@ describe("RealMcpConnector", () => {
   it("call 失败时返回中文降级信息", async () => {
     clientMocks.call.mockImplementation(async (method: string) => {
       if (method === "initialize") return {}
+      if (method === "tools/list") return { tools: [] }
       throw new Error("连接断开")
     })
     const connector = new RealMcpConnector(config)
@@ -122,4 +134,79 @@ describe("RealMcpConnector", () => {
 
     expect(clientMocks.close).toHaveBeenCalled()
   })
+
+  it("listRemoteTools 返回握手拉取的远端工具列表", async () => {
+    clientMocks.call.mockImplementation(async (method: string) => {
+      if (method === "initialize") return {}
+      if (method === "tools/list") {
+        return {
+          tools: [
+            { name: "query_graph", description: "查询图谱", inputSchema: { type: "object" } },
+            { name: "analyze", description: "分析关系", inputSchema: { type: "object" } },
+          ],
+        }
+      }
+      return {}
+    })
+    const connector = new RealMcpConnector(config)
+
+    const tools = await connector.listRemoteTools("graph")
+
+    expect(tools).toHaveLength(2)
+    expect(tools[0]).toEqual(expect.objectContaining({ name: "query_graph" }))
+  })
+
+  it("testConnection 成功时返回中文成功信息与工具数", async () => {
+    clientMocks.call.mockImplementation(async (method: string) => {
+      if (method === "initialize") return {}
+      if (method === "tools/list") {
+        return { tools: [{ name: "a" }, { name: "b" }, { name: "c" }] }
+      }
+      return {}
+    })
+    const connector = new RealMcpConnector(config)
+
+    const result = await connector.testConnection("graph")
+
+    expect(result.status).toBe("ok")
+    expect(result.toolCount).toBe(3)
+    expect(result.message).toContain("连接成功")
+    expect(result.message).toContain("3")
+  })
+
+  it("testConnection 启动命令缺失时返回中文错误信息", async () => {
+    const noCommandConfig: McpConfig = {
+      servers: [{ ...config.servers[0], command: undefined }],
+    }
+    const connector = new RealMcpConnector(noCommandConfig)
+
+    const result = await connector.testConnection("graph")
+
+    expect(result.status).toBe("error")
+    expect(result.message).toContain("未配置启动命令")
+  })
+
+  it("mergeRemoteTools 用远端 description 覆盖本地同名工具,保留本地 operation 权限", async () => {
+    clientMocks.call.mockImplementation(async (method: string) => {
+      if (method === "initialize") return {}
+      if (method === "tools/list") {
+        return {
+          tools: [{
+            name: "query_graph",
+            description: "Remote updated description",
+            inputSchema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] },
+          }],
+        }
+      }
+      return {}
+    })
+    const connector = new RealMcpConnector(config)
+    await connector.ensureConnected("graph")
+
+    const merged = connector.mergeRemoteTools("graph", config.servers[0].tools)
+
+    expect(merged[0].description).toBe("Remote updated description")
+    expect(merged[0].operation).toBe("read")
+    expect(merged[0].inputSchema.required).toEqual(["q"])
+  })
 })

+ 104 - 2
src/lib/mcp/real-connector.ts

@@ -1,10 +1,26 @@
 import type { McpConfig, McpServerConfig } from "./config"
-import type { McpToolCaller, McpToolCallRequest, McpToolCallResult } from "./types"
+import type { McpJsonSchema, McpToolCaller, McpToolCallRequest, McpToolCallResult, McpToolDescriptor } from "./types"
 import { JsonRpcClient } from "./transport/json-rpc"
 import { TauriStdioTransport } from "./transport/stdio"
 
 interface ConnectedMcpServer {
   client: JsonRpcClient
+  remoteTools: RemoteToolInfo[]
+}
+
+/** MCP tools/list 返回的单个工具原始信息(MCP 标准 schema)。 */
+export interface RemoteToolInfo {
+  name: string
+  description?: string
+  inputSchema?: Record<string, unknown>
+}
+
+export interface McpTestConnectionResult {
+  status: "ok" | "error"
+  serverName: string
+  toolCount: number
+  tools: Pick<RemoteToolInfo, "name" | "description">[]
+  message: string
 }
 
 export class RealMcpConnector {
@@ -43,11 +59,75 @@ export class RealMcpConnector {
         version: "2.2.31",
       },
     })
+    // MCP 协议规定客户端 initialize 后必须发 notifications/initialized 通知,
+    // 部分严格 server 不收到此通知会拒绝后续 tools/list / tools/call。
+    await client.notify("notifications/initialized")
+    // 握手后立即拉取远端工具列表,用于校验/回填本地 descriptor(见 listRemoteTools)。
+    const remoteTools = await this.fetchTools(client)
 
-    this.clients.set(serverId, { client })
+    this.clients.set(serverId, { client, remoteTools })
     return client
   }
 
+  /**
+   * 返回指定服务握手时拉取的远端工具列表。若尚未连接会先触发 ensureConnected。
+   * 设置页“测试连接”按钮复用此能力,避免重复进程握手。
+   */
+  async listRemoteTools(serverId: string): Promise<RemoteToolInfo[]> {
+    const entry = this.clients.get(serverId)
+    if (entry) return entry.remoteTools
+    await this.ensureConnected(serverId)
+    return this.clients.get(serverId)?.remoteTools ?? []
+  }
+
+  /**
+   * 设置页一键测试连接:握手 + tools/list 即时反馈,失败返回中文降级信息。
+   * 不修改本地配置,只做只读探测。
+   */
+  async testConnection(serverId: string): Promise<McpTestConnectionResult> {
+    const server = this.findServer(serverId)
+    const serverName = server?.name ?? serverId
+    try {
+      const tools = await this.listRemoteTools(serverId)
+      return {
+        status: "ok",
+        serverName,
+        toolCount: tools.length,
+        tools: tools.map((t) => ({ name: t.name, description: t.description })),
+        message: `MCP 服务“${serverName}”连接成功,发现 ${tools.length} 个可用工具。`,
+      }
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error)
+      return {
+        status: "error",
+        serverName,
+        toolCount: 0,
+        tools: [],
+        message: `MCP 服务“${serverName}”连接失败:${message}`,
+      }
+    }
+  }
+
+  /**
+   * 合并远端 tools/list 与本地 descriptor:以远端 name/description/inputSchema 为权威,
+   * 本地 operation(权限策略)若已配置则保留,未配置则默认 read。
+   * 仅保留本地已声明的工具名(避免远端未授权工具自动暴露给 AI 会话)。
+   */
+  mergeRemoteTools(serverId: string, localTools: McpToolDescriptor[]): McpToolDescriptor[] {
+    const remote = this.clients.get(serverId)?.remoteTools ?? []
+    if (remote.length === 0) return localTools
+    const remoteByName = new Map(remote.map((t) => [t.name, t]))
+    return localTools.map((local) => {
+      const r = remoteByName.get(local.name)
+      if (!r) return local
+      return {
+        ...local,
+        description: r.description?.trim() || local.description,
+        inputSchema: normalizeRemoteSchema(r.inputSchema) ?? local.inputSchema,
+      }
+    })
+  }
+
   async call(
     request: McpToolCallRequest,
     params: Record<string, unknown>,
@@ -85,6 +165,16 @@ export class RealMcpConnector {
   private findServer(serverId: string): McpServerConfig | null {
     return this.config.servers.find((server) => server.id === serverId && server.enabled) ?? null
   }
+
+  private async fetchTools(client: JsonRpcClient): Promise<RemoteToolInfo[]> {
+    try {
+      const result = await client.call<{ tools?: RemoteToolInfo[] }>("tools/list", {})
+      return Array.isArray(result?.tools) ? result.tools : []
+    } catch {
+      // tools/list 失败不阻断握手;descriptor 继续以本地配置为准。
+      return []
+    }
+  }
 }
 
 function extractMcpText(result: unknown): string {
@@ -117,3 +207,15 @@ function stringifyResult(result: unknown): string {
 function isRecord(value: unknown): value is Record<string, unknown> {
   return typeof value === "object" && value !== null
 }
+
+/** 把远端 inputSchema 规整成 McpJsonSchema,不合法时返回 null(由调用方回退到本地)。 */
+function normalizeRemoteSchema(schema: unknown): McpJsonSchema | null {
+  if (!isRecord(schema) || schema.type !== "object") return null
+  const properties = isRecord(schema.properties)
+    ? (schema.properties as McpJsonSchema["properties"])
+    : undefined
+  const required = Array.isArray(schema.required)
+    ? schema.required.filter((item): item is string => typeof item === "string")
+    : undefined
+  return { type: "object", properties, required }
+}

+ 7 - 0
src/lib/mcp/transport/json-rpc.ts

@@ -62,6 +62,13 @@ export class JsonRpcClient {
     return response.result
   }
 
+  async notify(method: string, params?: Record<string, unknown>): Promise<void> {
+    const notification = params === undefined
+      ? { jsonrpc: "2.0", method }
+      : { jsonrpc: "2.0", method, params }
+    await this.transport.send(JSON.stringify(notification))
+  }
+
   async close(): Promise<void> {
     await this.transport.close()
   }