mirror of
https://github.com/siyuan-note/siyuan.git
synced 2026-07-03 14:09:06 +02:00
a90a0c40c7
Signed-off-by: Daniel <845765@qq.com>
455 lines
13 KiB
Go
455 lines
13 KiB
Go
// SiYuan - Refactor your thinking
|
|
// Copyright (c) 2020-present, b3log.org
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package client
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/siyuan-note/logging"
|
|
"github.com/siyuan-note/siyuan/kernel/conf"
|
|
"github.com/siyuan-note/siyuan/kernel/mcp/tools"
|
|
"github.com/siyuan-note/siyuan/kernel/model"
|
|
)
|
|
|
|
const (
|
|
defaultMCPServerTimeout = 30 * time.Second
|
|
)
|
|
|
|
type Connection struct {
|
|
ServerName string
|
|
Session *mcp.ClientSession
|
|
Cmd *exec.Cmd
|
|
Tools int // 已注册的工具数量,供状态查询展示
|
|
}
|
|
|
|
var (
|
|
mcpMu sync.Mutex
|
|
mcpConns []Connection
|
|
mcpServers []conf.MCPServer
|
|
mcpConnecting bool // 是否有后台连接 goroutine 正在进行,防止重复启动
|
|
mcpDisconnected bool // 连接期间是否被 DisconnectMCP 要求放弃结果
|
|
)
|
|
|
|
// EnsureMCPConnected 确保 MCP server 已连接。
|
|
// 首次调用时在后台异步连接,立即返回不阻塞调用方(如 Agent 请求路径)。
|
|
// 连接完成前发起的 Agent 请求本轮可能看不到 MCP 工具,下轮即可用。
|
|
// 后续调用若已连接则直接返回;若后台连接仍在进行则也直接返回,等其完成。
|
|
func EnsureMCPConnected(servers []conf.MCPServer) {
|
|
mcpMu.Lock()
|
|
mcpServers = servers
|
|
if len(mcpConns) > 0 || mcpConnecting {
|
|
mcpMu.Unlock()
|
|
return
|
|
}
|
|
mcpConnecting = true
|
|
mcpDisconnected = false
|
|
mcpMu.Unlock()
|
|
|
|
// 后台连接:耗时操作(握手 + 拉取工具列表)不阻塞调用方。
|
|
go func() {
|
|
conns := connectServers(servers)
|
|
mcpMu.Lock()
|
|
mcpConnecting = false
|
|
if mcpDisconnected {
|
|
// 连接期间被 DisconnectMCP 要求放弃,丢弃结果并关闭刚建好的连接避免泄漏。
|
|
mcpDisconnected = false
|
|
mcpMu.Unlock()
|
|
closeConnections(conns)
|
|
return
|
|
}
|
|
mcpConns = conns
|
|
mcpMu.Unlock()
|
|
}()
|
|
}
|
|
|
|
// serverTimeout 归一化服务器配置的超时(秒),未配置或非法时回退到默认值。
|
|
func serverTimeout(server conf.MCPServer) time.Duration {
|
|
if server.Timeout > 0 {
|
|
return time.Duration(server.Timeout) * time.Second
|
|
}
|
|
return defaultMCPServerTimeout
|
|
}
|
|
|
|
// headerRoundTripper 把 server 配置的自定义 HTTP 头附加到每个出站请求上。
|
|
type headerRoundTripper struct {
|
|
base http.RoundTripper
|
|
headers map[string]string
|
|
}
|
|
|
|
func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
clone := req.Clone(req.Context())
|
|
for k, v := range h.headers {
|
|
// 对每个 header 值里的 {{secrets.NAME}}、{{vars.NAME}} 占位符插值,
|
|
// 使 MCP 服务的 Authorization 等头部可引用密钥/变量而无需明文存储。
|
|
clone.Header.Set(k, conf.ResolveSecretsVars(model.Conf.Secrets, model.Conf.Variables, v))
|
|
}
|
|
return h.base.RoundTrip(clone)
|
|
}
|
|
|
|
func DisconnectMCP() {
|
|
mcpMu.Lock()
|
|
defer mcpMu.Unlock()
|
|
closeConnections(mcpConns)
|
|
mcpConns = nil
|
|
// 若后台仍有连接 goroutine 在进行,标记其结果应被丢弃。
|
|
mcpDisconnected = true
|
|
}
|
|
|
|
func connectServers(servers []conf.MCPServer) []Connection {
|
|
var connections []Connection
|
|
|
|
for _, server := range servers {
|
|
if !server.Enabled {
|
|
continue
|
|
}
|
|
|
|
session, cmd, err := connectServer(server)
|
|
if err != nil {
|
|
logging.LogWarnf("mcp: server [%s] connect failed: %s", server.Name, err)
|
|
continue
|
|
}
|
|
|
|
listCtx, listCancel := context.WithTimeout(context.Background(), serverTimeout(server))
|
|
toolList, err := session.ListTools(listCtx, nil)
|
|
listCancel()
|
|
if err != nil {
|
|
logging.LogWarnf("mcp: server [%s] list tools failed: %s", server.Name, err)
|
|
session.Close()
|
|
if cmd != nil && cmd.Process != nil {
|
|
cmd.Process.Kill()
|
|
cmd.Wait()
|
|
}
|
|
continue
|
|
}
|
|
|
|
registered := 0
|
|
for _, t := range toolList.Tools {
|
|
tool := t
|
|
name := "mcp_" + sanitize(server.Name) + "_" + sanitize(tool.Name)
|
|
desc := tool.Description
|
|
if !strings.HasPrefix(desc, "[MCP]") {
|
|
desc = "[MCP:" + server.Name + "] " + desc
|
|
}
|
|
|
|
tools.SetTool(name, &tools.Tool{
|
|
Name: name,
|
|
Description: desc,
|
|
InputSchema: convertMCPSchema(tool.InputSchema),
|
|
Source: "mcp",
|
|
Handler: mcpToolHandler(server.Name, tool.Name, serverTimeout(server)),
|
|
})
|
|
registered++
|
|
}
|
|
|
|
connections = append(connections, Connection{
|
|
ServerName: server.Name,
|
|
Session: session,
|
|
Cmd: cmd,
|
|
Tools: registered,
|
|
})
|
|
logging.LogInfof("mcp: server [%s] connected, %d tools registered", server.Name, registered)
|
|
}
|
|
|
|
return connections
|
|
}
|
|
|
|
func closeConnections(connections []Connection) {
|
|
for _, conn := range connections {
|
|
if conn.Session != nil {
|
|
conn.Session.Close()
|
|
}
|
|
if conn.Cmd != nil && conn.Cmd.Process != nil {
|
|
conn.Cmd.Process.Kill()
|
|
conn.Cmd.Wait()
|
|
}
|
|
}
|
|
}
|
|
|
|
func connectServer(server conf.MCPServer) (*mcp.ClientSession, *exec.Cmd, error) {
|
|
c := mcp.NewClient(&mcp.Implementation{Name: "siyuan", Version: "3.0"}, nil)
|
|
|
|
switch server.Type {
|
|
case "stdio":
|
|
return connectStdio(c, server)
|
|
case "http":
|
|
return connectHTTP(c, server)
|
|
default:
|
|
return nil, nil, fmt.Errorf("unsupported server type: %s", server.Type)
|
|
}
|
|
}
|
|
|
|
func connectStdio(client *mcp.Client, server conf.MCPServer) (*mcp.ClientSession, *exec.Cmd, error) {
|
|
if server.Command == "" {
|
|
return nil, nil, fmt.Errorf("command is required for stdio server")
|
|
}
|
|
|
|
cmd := exec.Command(server.Command, server.Args...)
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("stdin pipe: %w", err)
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
cmd.Stderr = io.Discard
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, nil, fmt.Errorf("start command: %w", err)
|
|
}
|
|
|
|
connectCtx, connectCancel := context.WithTimeout(context.Background(), serverTimeout(server))
|
|
defer connectCancel()
|
|
transport := &mcp.IOTransport{Reader: stdout, Writer: stdin}
|
|
session, err := client.Connect(connectCtx, transport, nil)
|
|
if err != nil {
|
|
cmd.Process.Kill()
|
|
cmd.Wait()
|
|
return nil, cmd, fmt.Errorf("connect: %w", err)
|
|
}
|
|
|
|
return session, cmd, nil
|
|
}
|
|
|
|
func connectHTTP(client *mcp.Client, server conf.MCPServer) (*mcp.ClientSession, *exec.Cmd, error) {
|
|
if server.URL == "" {
|
|
return nil, nil, fmt.Errorf("url is required for http server")
|
|
}
|
|
|
|
transport := &mcp.StreamableClientTransport{
|
|
Endpoint: server.URL,
|
|
}
|
|
if len(server.Headers) > 0 {
|
|
transport.HTTPClient = &http.Client{
|
|
Transport: &headerRoundTripper{
|
|
base: http.DefaultTransport,
|
|
headers: server.Headers,
|
|
},
|
|
}
|
|
}
|
|
|
|
connectCtx, connectCancel := context.WithTimeout(context.Background(), serverTimeout(server))
|
|
defer connectCancel()
|
|
session, err := client.Connect(connectCtx, transport, nil)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("connect: %w", err)
|
|
}
|
|
|
|
return session, nil, nil
|
|
}
|
|
|
|
func mcpToolHandler(serverName, toolName string, timeout time.Duration) func(args map[string]interface{}) (tools.CallToolResult, error) {
|
|
return func(args map[string]interface{}) (tools.CallToolResult, error) {
|
|
result, err := callMCPTool(serverName, toolName, timeout, args)
|
|
if err != nil && isReconnectableError(err) {
|
|
logging.LogWarnf("mcp: server [%s] tool [%s] disconnected (%s), reconnecting", serverName, toolName, err)
|
|
if reconnectMCP() {
|
|
result, err = callMCPTool(serverName, toolName, timeout, args)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return tools.CallToolResult{
|
|
Content: []tools.ContentItem{{Type: "text", Text: fmt.Sprintf("mcp tool error: %s", err.Error())}},
|
|
IsError: true,
|
|
}, nil
|
|
}
|
|
|
|
var textParts []string
|
|
for _, content := range result.Content {
|
|
if textContent, ok := content.(*mcp.TextContent); ok {
|
|
textParts = append(textParts, textContent.Text)
|
|
}
|
|
}
|
|
text := strings.Join(textParts, "\n")
|
|
if text == "" {
|
|
text = "(empty result)"
|
|
}
|
|
|
|
syr := tools.CallToolResult{
|
|
IsError: result.IsError,
|
|
Content: []tools.ContentItem{{Type: "text", Text: text}},
|
|
}
|
|
return syr, nil
|
|
}
|
|
}
|
|
|
|
func callMCPTool(serverName, toolName string, timeout time.Duration, args map[string]interface{}) (*mcp.CallToolResult, error) {
|
|
session := getMCPSession(serverName)
|
|
if session == nil {
|
|
return nil, fmt.Errorf("mcp server [%s] not connected", serverName)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
return session.CallTool(ctx, &mcp.CallToolParams{
|
|
Name: toolName,
|
|
Arguments: args,
|
|
})
|
|
}
|
|
|
|
func getMCPSession(serverName string) *mcp.ClientSession {
|
|
mcpMu.Lock()
|
|
defer mcpMu.Unlock()
|
|
for _, conn := range mcpConns {
|
|
if conn.ServerName == serverName {
|
|
return conn.Session
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reconnectMCP 关闭现有连接并重新注册工具。
|
|
func reconnectMCP() bool {
|
|
mcpMu.Lock()
|
|
// 后台首次连接仍在进行时,重连无意义(连接尚未就绪,工具调用本不会到达这里)。
|
|
if mcpConnecting || len(mcpServers) == 0 {
|
|
mcpMu.Unlock()
|
|
return false
|
|
}
|
|
closeConnections(mcpConns)
|
|
mcpConns = nil
|
|
mcpConnecting = true
|
|
mcpMu.Unlock()
|
|
|
|
conns := connectServers(mcpServers)
|
|
mcpMu.Lock()
|
|
mcpConnecting = false
|
|
if mcpDisconnected {
|
|
mcpDisconnected = false
|
|
mcpMu.Unlock()
|
|
closeConnections(conns)
|
|
return false
|
|
}
|
|
mcpConns = conns
|
|
for _, conn := range mcpConns {
|
|
if conn.Session != nil {
|
|
logging.LogInfof("mcp: reconnected server [%s]", conn.ServerName)
|
|
mcpMu.Unlock()
|
|
return true
|
|
}
|
|
}
|
|
mcpMu.Unlock()
|
|
return false
|
|
}
|
|
|
|
// ReconnectMCPAsync 用最新的 server 配置异步重连,不阻塞调用方(如 setAI 配置保存)。
|
|
// 适用于配置变更(开关切换、编辑、增删 server)后让连接立即跟上,而非等下次 Agent 请求。
|
|
func ReconnectMCPAsync(servers []conf.MCPServer) {
|
|
mcpMu.Lock()
|
|
mcpServers = servers
|
|
mcpConnecting = true
|
|
mcpDisconnected = false
|
|
closeConnections(mcpConns)
|
|
mcpConns = nil
|
|
mcpMu.Unlock()
|
|
|
|
go func() {
|
|
conns := connectServers(servers)
|
|
mcpMu.Lock()
|
|
mcpConnecting = false
|
|
if mcpDisconnected {
|
|
// 连接期间被 DisconnectMCP 要求放弃,丢弃结果并关闭刚建好的连接避免泄漏。
|
|
mcpDisconnected = false
|
|
mcpMu.Unlock()
|
|
closeConnections(conns)
|
|
return
|
|
}
|
|
mcpConns = conns
|
|
mcpMu.Unlock()
|
|
}()
|
|
}
|
|
|
|
// isReconnectableError 判断 MCP 调用失败是否可能因连接断开,值得尝试重连。
|
|
func isReconnectableError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
if strings.Contains(msg, "401") || strings.Contains(msg, "403") ||
|
|
strings.Contains(msg, "invalid_token") || strings.Contains(msg, "unauthorized") {
|
|
return false
|
|
}
|
|
return strings.Contains(msg, "connection closed") ||
|
|
strings.Contains(msg, "client is closing") ||
|
|
strings.Contains(msg, "standalone sse") ||
|
|
strings.Contains(msg, "session missing") ||
|
|
strings.Contains(msg, "not connected")
|
|
}
|
|
|
|
func sanitize(s string) string {
|
|
var sb strings.Builder
|
|
for _, r := range s {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
|
sb.WriteRune(r)
|
|
} else {
|
|
sb.WriteByte('_')
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// MCPStatusItem 描述单个 MCP server 的连接状态,供前端展示。
|
|
type MCPStatusItem struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"` // connected | connecting | failed | disabled
|
|
Tools int `json:"tools"` // 已注册工具数(仅 connected 时有意义)
|
|
}
|
|
|
|
// MCPStatus 返回所有已配置 MCP server 的当前连接状态。
|
|
func MCPStatus() []MCPStatusItem {
|
|
mcpMu.Lock()
|
|
servers := mcpServers
|
|
conns := mcpConns
|
|
connecting := mcpConnecting
|
|
mcpMu.Unlock()
|
|
|
|
// 建立已连接 server 名 → 连接信息的索引。
|
|
connMap := make(map[string]*Connection, len(conns))
|
|
for i := range conns {
|
|
connMap[conns[i].ServerName] = &conns[i]
|
|
}
|
|
|
|
items := make([]MCPStatusItem, 0, len(servers))
|
|
for _, srv := range servers {
|
|
item := MCPStatusItem{Name: srv.Name}
|
|
switch {
|
|
case !srv.Enabled:
|
|
item.Status = "disabled"
|
|
case connecting && connMap[srv.Name] == nil:
|
|
// 后台连接仍在进行,且该 server 尚未出现在已连接列表中。
|
|
item.Status = "connecting"
|
|
case connMap[srv.Name] != nil:
|
|
item.Status = "connected"
|
|
item.Tools = connMap[srv.Name].Tools
|
|
default:
|
|
// 已启用但既未连上、也不在连接中(连接失败或尚未触发首次连接)。
|
|
item.Status = "failed"
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items
|
|
}
|