Files
qiming/qimingclaw/crates/agent-electron-client/docs/features/local-mcp-configuration.md

24 KiB
Raw Permalink Blame History

本地 MCP 配置特性实现方案

架构分析

当前架构流程

┌─────────────────────────────────────────────────────────────┐
│                    ACP 协议请求                              │
│                   (来自 GUI Agent Server)                    │
├─────────────────────────────────────────────────────────────┤
│  request.agent_config.context_servers                        │
│       ↓                                                      │
│  unifiedAgent.ensureEngineForRequest()                       │
│       ↓                                                      │
│  mcp.syncMcpConfigToProxyAndReload()                        │
│       ↓                                                      │
│  mcpProxyManager.getAgentMcpConfig()                        │
│       ↓                                                      │
│  通过 engine.init() 传递给 ACP agent                          │
└─────────────────────────────────────────────────────────────┘

目标架构

                    ┌─────────────┐
                    │  用户界面    │
                    │ 配置 MCP 服务器 │
                    └──────┬──────┘
                           ↓
                    ┌─────────────┐
                    │  SQLite     │
                    │ 持久化存储   │
                    └──────┬──────┘
                           ↓
                    ┌─────────────┐
                    │ 本地 MCP 配置  │ ← 用户启用的服务器
                    └──────┬──────┘
                           ↓
                    ┌─────────────┐
                    │  合并配置    │ ← 合并到 ACP 请求
                    └──────┬──────┘
                           ↓
                    ┌─────────────┐
                    │  ACP agent  │ ← 最终传递给 agent
                    └─────────────┘

实现方案

一、数据模型设计

// 本地 MCP 服务器配置类型
interface LocalMcpServer {
  id: string;                    // 唯一标识符
  name: string;                  // 显示名称
  command?: string;             // 对于 stdio 服务器
  args?: string[];
  env?: Record<string, string>;
  url?: string;                 // 对于远程服务器
  transport?: "sse" | "streamable-http";
  headers?: Record<string, string>;
  authToken?: string;
  enabled: boolean;             // 用户可启用/禁用
  allowTools?: string[];       // 工具白名单
  denyTools?: string[];        // 工具黑名单
  toolCount?: number;          // 缓存的工具数量
  tools?: McpToolInfo[];       // 发现的工具列表
  lastUpdated?: number;        // 时间戳
  source?: "user" | "builtin"; // 分类用
}

interface McpToolInfo {
  name: string;
  description?: string;
  inputSchema?: object;
  annotations?: object;
}

二、服务层实现

创建 src/main/services/packages/localMcpService.ts:

import { getDb } from "../../db";
import type { McpServerEntry } from "./mcp";

export interface LocalMcpServer {
  id: string;
  name: string;
  command?: string;
  args?: string[];
  env?: Record<string, string>;
  url?: string;
  transport?: "sse" | "streamable-http";
  headers?: Record<string, string>;
  authToken?: string;
  enabled: boolean;
  allowTools?: string[];
  denyTools?: string[];
  toolCount?: number;
  tools?: McpToolInfo[];
  lastUpdated?: number;
  source?: "user" | "builtin";
}

export interface McpToolInfo {
  name: string;
  description?: string;
  inputSchema?: object;
  annotations?: object;
}

class LocalMcpService {
  /**
   * 获取所有本地 MCP 服务器
   */
  getLocalMcpServers(): LocalMcpServer[] {
    const db = getDb();
    if (!db) return [];
    
    const result = db
      .prepare("SELECT * FROM local_mcp_servers ORDER BY name")
      .all() as any[];
    
    return result.map((row) => this.parseRow(row));
  }

  /**
   * 根据 ID 获取单个服务器
   */
  getLocalMcpServer(id: string): LocalMcpServer | undefined {
    const db = getDb();
    if (!db) return undefined;
    
    const row = db
      .prepare("SELECT * FROM local_mcp_servers WHERE id = ?")
      .get() as any;
    
    return row ? this.parseRow(row) : undefined;
  }

  /**
   * 添加或更新本地 MCP 服务器
   */
  upsertLocalMcpServer(server: LocalMcpServer): void {
    const db = getDb();
    if (!db) return;
    
    db.prepare(`
      INSERT OR REPLACE INTO local_mcp_servers 
      (id, name, command, args, env, url, transport, headers, auth_token, 
       enabled, allow_tools, deny_tools, tool_count, tools, last_updated, source)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).run(
      server.id,
      server.name,
      server.command || null,
      JSON.stringify(server.args),
      JSON.stringify(server.env),
      server.url || null,
      server.transport || null,
      JSON.stringify(server.headers),
      server.authToken || null,
      server.enabled ? 1 : 0,
      JSON.stringify(server.allowTools),
      JSON.stringify(server.denyTools),
      server.toolCount || 0,
      JSON.stringify(server.tools),
      Date.now(),
      server.source || "user"
    );
  }

  /**
   * 删除本地 MCP 服务器
   */
  removeLocalMcpServer(id: string): void {
    const db = getDb();
    if (!db) return;
    
    db.prepare("DELETE FROM local_mcp_servers WHERE id = ?").run(id);
  }

  /**
   * 启用/禁用本地 MCP 服务器
   */
  toggleLocalMcpServer(id: string, enabled: boolean): void {
    const db = getDb();
    if (!db) return;
    
    db.prepare("UPDATE local_mcp_servers SET enabled = ? WHERE id = ?")
      .run(enabled ? 1 : 0, id);
  }

  /**
   * 更新已发现的工具
   */
  updateServerTools(
    id: string,
    tools: McpToolInfo[],
    toolCount: number,
  ): void {
    const db = getDb();
    if (!db) return;
    
    db.prepare(`
      UPDATE local_mcp_servers 
      SET tools = ?, tool_count = ?
      WHERE id = ?
    `).run(JSON.stringify(tools), toolCount, id);
  }

  /**
   * 获取启用的服务器,转换为 McpServerEntry 格式
   */
  getEnabledServersAsEntries(): Record<string, McpServerEntry> {
    const servers = this.getLocalMcpServers();
    const result: Record<string, McpServerEntry> = {};
    
    for (const server of servers) {
      if (!server.enabled) continue;
      
      if (server.url) {
        // 远程服务器
        result[server.name] = {
          url: server.url,
          transport: server.transport,
          headers: server.headers,
          authToken: server.authToken,
          allowTools: server.allowTools,
          denyTools: server.denyTools,
        } as McpServerEntry;
      } else if (server.command) {
        // Stdio 服务器
        result[server.name] = {
          command: server.command,
          args: server.args,
          env: server.env,
          allowTools: server.allowTools,
          denyTools: server.denyTools,
        } as McpServerEntry;
      }
    }
    
    return result;
  }

  /**
   * 解析数据库行
   */
  private parseRow(row: any): LocalMcpServer {
    return {
      id: row.id,
      name: row.name,
      command: row.command,
      args: row.args ? JSON.parse(row.args) : [],
      env: row.env ? JSON.parse(row.env) : {},
      url: row.url,
      transport: row.transport,
      headers: row.headers ? JSON.parse(row.headers) : {},
      authToken: row.authToken,
      enabled: row.enabled === 1,
      allowTools: row.allow_tools ? JSON.parse(row.allow_tools) : [],
      denyTools: row.deny_tools ? JSON.parse(row.deny_tools) : [],
      toolCount: row.tool_count,
      tools: row.tools ? JSON.parse(row.tools) : [],
      lastUpdated: row.last_updated,
      source: row.source,
    };
  }
}

export const localMcpService = new LocalMcpService();

三、数据库迁移

创建 src/main/db/migrations/003-add-local-mcp-servers.ts:

export const migration = (db: any) => {
  db.exec(`
    CREATE TABLE IF NOT EXISTS local_mcp_servers (
      id TEXT PRIMARY KEY,
      name TEXT NOT NULL UNIQUE,
      command TEXT,
      args TEXT,
      env TEXT,
      url TEXT,
      transport TEXT,
      headers TEXT,
      auth_token TEXT,
      enabled INTEGER DEFAULT 1,
      allow_tools TEXT,
      deny_tools TEXT,
      tool_count INTEGER DEFAULT 0,
      tools TEXT,
      last_updated INTEGER,
      source TEXT DEFAULT 'user'
    )
  `);
  
  // 创建索引以加速查询
  db.exec(`
    CREATE INDEX IF NOT EXISTS idx_local_mcp_enabled 
    ON local_mcp_servers(enabled)
  `);
  
  db.exec(`
    CREATE INDEX IF NOT EXISTS idx_local_mcp_source 
    ON local_mcp_servers(source)
  `);
};

src/main/db/index.ts 中注册迁移:

import { migration as m003 } from "./migrations/003-add-local-mcp-servers";

// 在迁移列表中添加
const migrations = [
  // ... 现有迁移
  { id: "003-add-local-mcp-servers", fn: m003 },
];

四、IPC 处理器

创建 src/main/ipc/handlers/mcp-local.ts:

import { ipcMain } from "electron";
import { localMcpService, type LocalMcpServer } from "../../services/packages/localMcpService";

export function registerMcpLocalHandlers(): void {
  // 获取所有服务器列表
  ipcMain.handle("mcp-local:list", () => {
    return localMcpService.getLocalMcpServers();
  });

  // 获取单个服务器
  ipcMain.handle("mcp-local:get", (_, id: string) => {
    return localMcpService.getLocalMcpServer(id);
  });

  // 添加新服务器
  ipcMain.handle("mcp-local:add", (_, server: LocalMcpServer) => {
    // 生成 UUID 如果没有提供
    if (!server.id) {
      server.id = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
    }
    localMcpService.upsertLocalMcpServer(server);
    return server;
  });

  // 更新服务器
  ipcMain.handle("mcp-local:update", (_, server: LocalMcpServer) => {
    localMcpService.upsertLocalMcpServer(server);
    return server;
  });

  // 删除服务器
  ipcMain.handle("mcp-local:remove", (_, id: string) => {
    localMcpService.removeLocalMcpServer(id);
  });

  // 切换启用/禁用状态
  ipcMain.handle("mcp-local:toggle", (_, id: string, enabled: boolean) => {
    localMcpService.toggleLocalMcpServer(id, enabled);
  });

  // 获取已启用的服务器
  ipcMain.handle("mcp-local:get-enabled", () => {
    const servers = localMcpService.getLocalMcpServers();
    return servers.filter((s) => s.enabled);
  });
}

在主进程入口注册:

// src/main/main.ts 或类似文件
import { registerMcpLocalHandlers } from "./ipc/handlers/mcp-local";

registerMcpLocalHandlers();

五、Preload 脚本扩展

添加 TypeScript 类型声明到 preload:

// src/main/preload.ts

contextBridge.exposeInMainWorld("electron", {
  // ... 现有 API
  
  mcpLocal: {
    list: () => ipcRenderer.invoke("mcp-local:list"),
    get: (id: string) => ipcRenderer.invoke("mcp-local:get", id),
    add: (server: LocalMcpServer) =>
      ipcRenderer.invoke("mcp-local:add", server),
    update: (server: LocalMcpServer) =>
      ipcRenderer.invoke("mcp-local:update", server),
    remove: (id: string) => ipcRenderer.invoke("mcp-local:remove", id),
    toggle: (
      id: string,
      enabled: boolean,
    ) => ipcRenderer.invoke("mcp-local:toggle", id, enabled),
    getEnabled: () => ipcRenderer.invoke("mcp-local:get-enabled"),
  },
});

添加全局类型声明:

// src/types/global.d.ts

interface ElectronAPI {
  // ... 现有接口
  
  mcpLocal: {
    list: () => Promise<LocalMcpServer[]>;
    get: (id: string) => Promise<LocalMcpServer | undefined>;
    add: (server: LocalMcpServer) => Promise<LocalMcpServer>;
    update: (server: LocalMcpServer) => Promise<LocalMcpServer>;
    remove: (id: string) => Promise<void>;
    toggle: (id: string, enabled: boolean) => Promise<void>;
    getEnabled: () => Promise<LocalMcpServer[]>;
  };
}

interface LocalMcpServer {
  id: string;
  name: string;
  command?: string;
  args?: string[];
  env?: Record<string, string>;
  url?: string;
  transport?: "sse" | "streamable-http";
  headers?: Record<string, string>;
  authToken?: string;
  enabled: boolean;
  allowTools?: string[];
  denyTools?: string[];
  toolCount?: number;
  tools?: McpToolInfo[];
  lastUpdated?: number;
  source?: "user" | "builtin";
}

interface McpToolInfo {
  name: string;
  description?: string;
  inputSchema?: object;
  annotations?: object;
}

declare global {
  interface Window {
    electron: ElectronAPI;
  }
}

export {};

六、UI 组件

创建 src/components/LocalMcpSettings.tsx:

import React, { useState, useEffect } from "react";
import { Column, Row, Button, Card, SearchInput, Switch, Icon } from "@components/ui";
import type { LocalMcpServer } from "@shared/types";

export const LocalMcpSettings: React.FC = () => {
  const [servers, setServers] = useState<LocalMcpServer[]>([]);
  const [searchTerm, setSearchTerm] = useState("");
  const [isAdding, setIsAdding] = useState(false);

  // 加载服务器列表
  useEffect(() => {
    loadServers();
  }, []);

  const loadServers = async () => {
    const list = await window.electron.mcpLocal.list();
    setServers(list);
  };

  // 处理启用/禁用切换
  const handleToggle = async (id: string, enabled: boolean) => {
    await window.electron.mcpLocal.toggle(id, enabled);
    setServers((prev) =>
      prev.map((s) => (s.id === id ? { ...s, enabled } : s)),
    );
  };

  // 处理删除
  const handleDelete = async (id: string) => {
    if (window.confirm("确定要删除这个 MCP 服务器吗?")) {
      await window.electron.mcpLocal.remove(id);
      setServers((prev) => prev.filter((s) => s.id !== id));
    }
  };

  // 过滤服务器
  const filteredServers = servers.filter((server) => {
    const query = searchTerm.toLowerCase();
    return (
      server.name.toLowerCase().includes(query) ||
      server.command?.toLowerCase().includes(query) ||
      server.url?.toLowerCase().includes(query)
    );
  });

  return (
    <Column gap={16} className="mcp-settings">
      {/* 标题栏 */}
      <Row justify="space-between" align="center">
        <h2>本地 MCP 服务器</h2>
        <Button icon="plus" onClick={() => setIsAdding(true)}>
          添加服务器
        </Button>
      </Row>

      {/* 搜索框 */}
      <SearchInput
        placeholder="搜索服务器..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />

      {/* 服务器列表 */}
      <Column gap={8}>
        {filteredServers.length === 0 ? (
          <Card padding={24}>
            <p style={{ color: "#888", textAlign: "center" }}>
              {searchTerm
                ? "未找到匹配的服务器"
                : "暂无服务器,点击上方按钮添加"}
            </p>
          </Card>
        ) : (
          filteredServers.map((server) => (
            <Card key={server.id} padding={12} className="mcp-server-card">
              <Row justify="space-between" align="flex-start">
                <div style={{ flex: 1 }}>
                  <Row align="center" gap={8} mb={4}>
                    <h3 style={{ margin: 0 }}>{server.name}</h3>
                    {server.source === "builtin" && (
                      <span className="badge badge-built-in">内置</span>
                    )}
                  </Row>

                  {/* 服务器信息 */}
                  <div style={{ fontSize: "12px", color: "#666" }}>
                    {server.command ? (
                      <code>{server.command}</code>
                    ) : (
                      <span>{server.url}</span>
                    )}
                  </div>

                  {/* 工具统计 */}
                  {server.toolCount ? (
                    <div style={{ marginTop: 4 }}>
                      <span className="tool-count">{server.toolCount} 个工具</span>
                    </div>
                  ) : null}

                  {/* 工具列表预览 */}
                  {server.tools && server.tools.length > 0 ? (
                    <div className="tools-preview" style={{ marginTop: 8 }}>
                      <small>工具:</small>
                      <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
                        {server.tools.slice(0, 5).map((tool) => (
                          <span key={tool.name} className="tool-tag">
                            {tool.name}
                          </span>
                        ))}
                        {server.tools.length > 5 && (
                          <span className="tool-tag">
                            +{server.tools.length - 5}
                          </span>
                        )}
                      </div>
                    </div>
                  ) : null}
                </div>

                {/* 操作区 */}
                <Row gap={8} align="center">
                  <div className="server-status">
                    {server.enabled ? (
                      <Icon name="check-circle" color="green" />
                    ) : (
                      <Icon name="circle" color="gray" />
                    )}
                  </div>
                  <Switch
                    checked={server.enabled}
                    onChange={(checked) =>
                      handleToggle(server.id, checked)
                    }
                  />
                  <Button
                    size="small"
                    variant="danger"
                    onClick={() => handleDelete(server.id)}
                  >
                    删除
                  </Button>
                </Row>
              </Row>
            </Card>
          ))
        )}
      </Column>

      {/* 添加服务器模态框 */}
      {isAdding && <AddServerModal onClose={() => setIsAdding(false)} />}
    </Column>
  );
};

// 添加服务器模态框
const AddServerModal: React.FC<{ onClose: () => void }> = ({ onClose }) => {
  const [name, setName] = useState("");
  const [command, setCommand] = useState("");
  const [args, setArgs] = useState("");
  const [url, setUrl] = useState("");

  const handleSubmit = async () => {
    if (!name) return alert("请输入服务器名称");

    await window.electron.mcpLocal.add({
      name,
      command: command || undefined,
      args: args
        ? args
            .split(" ")
            .filter((a) => a.trim())
            .map((a) => a.trim())
        : undefined,
      url: url || undefined,
      enabled: true,
    });

    onClose();
  };

  return (
    <div className="modal-overlay">
      <div className="modal">
        <h3>添加 MCP 服务器</h3>

        <div className="form-group">
          <label>服务器名称 *</label>
          <input
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
            placeholder="my-mcp-server"
          />
        </div>

        <div className="form-group">
          <label>类型</label>
          <div className="radio-group">
            <label>
              <input
                type="radio"
                name="type"
                value="stdio"
                checked={!url}
                onChange={() => setUrl("")}
              />
              Stdio (本地命令)
            </label>
            <label>
              <input
                type="radio"
                name="type"
                value="remote"
                checked={!!url}
                onChange={() => setCommand("")}
              />
              Remote (HTTP URL)
            </label>
          </div>
        </div>

        {!url ? (
          <div className="form-group">
            <label>命令 *</label>
            <input
              type="text"
              value={command}
              onChange={(e) => setCommand(e.target.value)}
              placeholder="npx -y mcp-server-github"
            />
            <label style={{ marginTop: 8, display: "block" }}>
              参数 (可选)
              <input
                type="text"
                value={args}
                onChange={(e) => setArgs(e.target.value)}
                placeholder="--token xxx"
                style={{ width: "100%", marginTop: 4 }}
              />
            </label>
          </div>
        ) : (
          <div className="form-group">
            <label>URL *</label>
            <input
              type="url"
              value={url}
              onChange={(e) => setUrl(e.target.value)}
              placeholder="http://localhost:3001/mcp"
            />
          </div>
        )}

        <Row gap={8} justify="flex-end" mt={16}>
          <Button variant="secondary" onClick={onClose}>
            取消
          </Button>
          <Button primary onClick={handleSubmit}>
            添加
          </Button>
        </Row>
      </div>
    </div>
  );
};

七、集成到 ACP 协议流程

修改 crates/agent-electron-client/src/main/services/engines/unifiedAgent.ts:

ensureEngineForRequest 方法中,在获取 freshMcpServers 之后,添加本地 MCP 配置的合并:

// ... 在 existing code 中,获取 freshMcpServers 之后

// ==================== 新增代码开始 ====================
// 合并本地 MCP 配置 (启用的服务器)
const localMcpEntries = localMcpService.getEnabledServersAsEntries();
if (Object.keys(localMcpEntries).length > 0) {
  log.info(
    `[UnifiedAgent] 📚 Merging ${Object.keys(localMcpEntries).length} local MCP servers`,
  );

  // 将本地 MCP 同步到 proxy
  await syncMcpConfigToProxyAndReload(localMcpEntries);

  // 合并到 freshMcpServers
  freshMcpServers = {
    ...(freshMcpServers || {}),
    ...localMcpEntries,
  };
}
// ==================== 新增代码结束 ====================

// ... existing code continues

总结

需要创建/修改的文件

文件 操作 说明
src/main/services/packages/localMcpService.ts 新建 本地 MCP 服务层
src/main/db/migrations/003-add-local-mcp-servers.ts 新建 数据库迁移
src/main/ipc/handlers/mcp-local.ts 新建 IPC 处理器
src/components/LocalMcpSettings.tsx 新建 UI 组件
src/main/preload.ts 修改 添加 mcpLocal API 暴露
src/types/global.d.ts 修改 添加 TypeScript 类型
crates/agent-electron-client/src/main/services/engines/unifiedAgent.ts 修改 合并本地 MCP 到 ACP 配置
src/main/db/index.ts 修改 注册迁移

核心流程

用户配置 MCP 服务器
       ↓
  存储到 SQLite
       ↓
用户启用/禁用
       ↓
获取启用的服务器
       ↓
合并到 ACP 请求配置
       ↓
通过 proxy 传递给 agent

特性清单

  • 本地 MCP 服务器配置
  • SQLite 持久化
  • 启用/禁用控制
  • 支持 stdio 和 remote 两种类型
  • 自动合并到 ACP 协议配置
  • 与现有 MCP proxy 架构兼容

后续优化方向

  1. 工具发现功能

    • 实现 mcp-local:fetch-tools IPC 处理器
    • 支持从配置的 MCP 服务器获取工具列表
    • 缓存工具信息到数据库
  2. 工具白名单/黑名单

    • 实现 allowToolsdenyTools 配置
    • 在 UI 中提供工具选择界面
  3. 预设服务器模板

    • 添加常用 MCP 服务器预设
    • 一键导入配置
  4. 服务器健康检查

    • 定期检查 MCP 服务器是否可用
    • 显示连接状态
  5. 配置导入/导出

    • 支持将 MCP 配置导出为 JSON
    • 支持从 JSON 导入配置

文档创建时间2026-04-22 最后更新2026-04-22