mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 06:17:25 +00:00
feat(examples): 内置插件外置化 —— cmd/localuse/mcp 迁入 example + files 新增按行号精确编辑
## 背景 主仓按「只有必须使用内部 SDK 能力的插件才内置」的判定标准(见主仓 ARCHITECTURE「内置插件的判定标准」)把纯公开 SDK 插件移出编译期内置清单。 本仓为它们提供外部插件工程(example/ 下,hmapdev 可直接构建)。 ★ 终审翻案:kbtree 与 multimodal **不**在本次外置之列 —— 逐方法对照 公开 SDK 后发现它们依赖内部专有能力(kbtree 要 KnowledgeAPI 的 Subtree/CategoryCounts/SearchIn/Categories 与树视图类型;multimodal 要 LLMAPI.Chat 与多模态消息类型),公开面没有,已加回主仓内置清单。 ## 新增工程(全部对齐当前模板 + 随用随起契约) | 工程 | 工具 | auto_start | 说明 | |---|---|---|---| | cmd | cmd_run | false(按需) | 沙箱执行 shell 命令 | | localuse | screensee/screensue/camerasue/speakeruse/clipboardsee/clipboardsue/computeruse | false(按需) | 本机外设 | | mcp | mcp_add/list/remove/restart_server | false(按需) | MCP 服务器接入(stdio/SSE) | 每工程:plg.json(含 sdk 字段声明)+ skill/<name>-tools/SKILL.md + README。按 auto_start 契约(sdk/autostart.go 的 OnDemandNeedsSkill), **auto_start=false 必须配 skill**,否则工具不出现在初始清单 ⇒ 模型不知道 它存在 ⇒ 永远不会被拉起,等于没装还占地方。 ## 移植要点 - 注册:内置版 init()+RegisterFactory → 外部版导出 NewPluginFactory; RegisterPluginMeta 的显示名进 plg.json/plugin.json。 - mcp:本地 rpcRequest/rpcResponse/rpcError 与 hmapdev 生成的 z_proc_gen.go 同名冲突(构建期生成物与本目录源码同包编译), 本地类型改名 mcpRPC*;internal/safeclose 内联为 safeCloseChan (外部插件不能引 internal 包,close-of-closed-channel 纪律随之带过来)。 - module 名:cmd 的 go.mod 用 homeagent-cmd(避免与标准库 cmd 混淆); name_en 对齐插件名,hmapdev 以 toSnake(name_en) 命名产物 (此前误写 Command/Local,产物成了 command_/local_)。 ## files:新增 files_edit_line(按行号精确编辑) files_edit 要求 old 文本全文件唯一,改「第三个 case 的 break」这类 非唯一文本时只能退化为整文件重写(有损)。files_edit_line 按行号 定位,op=replace/insert/delete,支持: - 多条 edit 从后往前应用(前面行号不漂移) - expect 安全闸:行内容与预期不符则整体拒绝(防行号过期改错位置) - CRLF/末尾换行保真 skill 同步补 edit_line 行。测试 5 条(含 expect 拒绝路径)。 ## 验证 hmapdev build 四工程全过:cmd/localuse/mcp/files 产物齐; go test files 全绿。
This commit is contained in:
13
example/mcp/README.md
Normal file
13
example/mcp/README.md
Normal file
@ -0,0 +1,13 @@
|
||||
# mcp — MCP 服务器接入插件
|
||||
|
||||
接入外部 MCP (Model Context Protocol) 服务器(stdio / SSE),透传其工具。
|
||||
按需加载(`auto_start: false`)。
|
||||
|
||||
源自内核内置插件(2026-10-19 外置;internal/safeclose 已内联为本目录的
|
||||
safeCloseChan —— 外部插件不能引 internal 包)。
|
||||
|
||||
## 构建
|
||||
|
||||
hmapdev build
|
||||
|
||||
产物:`dist/mcp_linux_amd64.hmap`
|
||||
197
example/mcp/client.go
Normal file
197
example/mcp/client.go
Normal file
@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// JSON-RPC 2.0 消息结构:使用 z_proc_gen.go(hmapdev 生成)里的
|
||||
// mcpRPCRequest/mcpRPCResponse/mcpRPCError,不再本地重复声明 —— 内置版与生成物
|
||||
// 类型字段不同(ID int vs uint64),本地版已删,调用点已适配。
|
||||
|
||||
// JSON-RPC 2.0 消息结构(mcp 前缀避免与 hmapdev 生成的 z_proc_gen.go
|
||||
// 里的 mcpRPCRequest/mcpRPCResponse 冲突 —— 构建期生成物与本目录源码同包编译)。
|
||||
type mcpRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params interface{} `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type mcpRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Result *json.RawMessage `json:"result,omitempty"`
|
||||
Error *mcpRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type mcpRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
// MCP Tool 定义
|
||||
type MCPTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]interface{} `json:"inputSchema"`
|
||||
}
|
||||
|
||||
// MCP 工具调用结果
|
||||
type MCPContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// MCPCallResult 是 tools/call 的响应体。
|
||||
//
|
||||
// ★ isError 字段必须声明(2026-10-05 补)。
|
||||
//
|
||||
// 实测证据:agent 抱怨「email_email_attachment_list 返回了裸 null,
|
||||
// 无法区分『该邮件无附件』和『工具故障』」,并自己花了两轮做对照探针。
|
||||
// 而第三方 MCP 服务器(mcp-server-email)的真实回包是:
|
||||
//
|
||||
// {"content":[{"type":"text","text":"id is required"}],"isError":true}
|
||||
//
|
||||
// 旧结构体**没有** isError ⇒ json.Unmarshal 静默丢弃它
|
||||
// ⇒ CallTool 把错误当成普通文本返回 ⇒ **失败与成功对模型完全同形**。
|
||||
//
|
||||
// 这与本项目反复记的「报假成功比报错危险」同型:模型据此无法判断
|
||||
// 该重试、该换参数、还是该放弃,只能反复试探浪费轮次。
|
||||
type MCPCallResult struct {
|
||||
Content []MCPContent `json:"content"`
|
||||
// IsError 为 true 表示**这批 content 是错误消息**,不是正常结果。
|
||||
// MCP 规范里它是 result 内的字段(不是 JSON-RPC 层的 error),
|
||||
// 所以外层 resp.Error 会是 nil —— 不显式读就会当成成功。
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
|
||||
// Transport 抽象: 支持 stdio / SSE
|
||||
type Transport interface {
|
||||
Send(req *mcpRPCRequest) (*mcpRPCResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Server 代表一个 MCP 服务器连接
|
||||
type Server struct {
|
||||
name string
|
||||
transport Transport
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
}
|
||||
|
||||
func NewServer(name string, t Transport) *Server {
|
||||
return &Server{name: name, transport: t}
|
||||
}
|
||||
|
||||
func (s *Server) Name() string { return s.name }
|
||||
|
||||
func (s *Server) nextRequestID() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextID++
|
||||
return s.nextID
|
||||
}
|
||||
|
||||
// ListTools 列举 MCP 服务器提供的所有工具
|
||||
func (s *Server) ListTools() ([]MCPTool, error) {
|
||||
req := &mcpRPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: s.nextRequestID(),
|
||||
Method: "tools/list",
|
||||
}
|
||||
resp, err := s.transport.Send(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp %s tools/list: %w", s.name, err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("mcp %s tools/list error: %s", s.name, resp.Error.Message)
|
||||
}
|
||||
if resp.Result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var result struct {
|
||||
Tools []MCPTool `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(*resp.Result, &result); err != nil {
|
||||
return nil, fmt.Errorf("mcp %s tools/list unmarshal: %w", s.name, err)
|
||||
}
|
||||
return result.Tools, nil
|
||||
}
|
||||
|
||||
// CallTool 调用 MCP 工具
|
||||
func (s *Server) CallTool(name string, args map[string]interface{}) (string, error) {
|
||||
req := &mcpRPCRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: s.nextRequestID(),
|
||||
Method: "tools/call",
|
||||
Params: map[string]interface{}{
|
||||
"name": name,
|
||||
"arguments": args,
|
||||
},
|
||||
}
|
||||
resp, err := s.transport.Send(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mcp %s tools/call %s: %w", s.name, name, err)
|
||||
}
|
||||
return classifyCallResult(s.name, name, resp)
|
||||
}
|
||||
|
||||
// classifyCallResult 把 tools/call 的响应判成(文本, error)。
|
||||
//
|
||||
// 抽成独立函数是为了让判据能**直接拿真实回包驱动它**,而不必起一个
|
||||
// 真的 MCP 子进程(第三方二进制 + 真实凭证,环境依赖太重)。
|
||||
// 生产路径与判据走的是同一份逻辑。
|
||||
func classifyCallResult(serverName, toolName string, resp *mcpRPCResponse) (string, error) {
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("mcp %s tools/call %s: 无响应(连接已断开或服务端崩溃)", serverName, toolName)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return "", fmt.Errorf("mcp %s tools/call %s error: %s", serverName, toolName, resp.Error.Message)
|
||||
}
|
||||
if resp.Result == nil {
|
||||
return "", fmt.Errorf("mcp %s tools/call %s: 服务端返回空 result(无 content 字段)", serverName, toolName)
|
||||
}
|
||||
var result MCPCallResult
|
||||
if err := json.Unmarshal(*resp.Result, &result); err != nil {
|
||||
return "", fmt.Errorf("mcp %s tools/call %s unmarshal: %w", serverName, toolName, err)
|
||||
}
|
||||
// 拼接所有文本片段
|
||||
var sb string
|
||||
for _, c := range result.Content {
|
||||
if c.Type == "text" {
|
||||
sb += c.Text
|
||||
}
|
||||
}
|
||||
// ★ isError=true ⇒ 这是**失败**,必须以 error 形态上抛。
|
||||
//
|
||||
// 不能只把文本原样返回:那样模型收到的是一段「看起来像结果的说明」,
|
||||
// 无从知道工具失败了(实测症状就是模型以为「返回了 null」并反复探针)。
|
||||
// 也不能丢弃错误文本 —— 那正是服务端给的**唯一**诊断线索。
|
||||
if result.IsError {
|
||||
msg := strings.TrimSpace(sb)
|
||||
if msg == "" {
|
||||
msg = "(服务端未给出错误文本)"
|
||||
}
|
||||
return "", fmt.Errorf("mcp %s tools/call %s 失败: %s", serverName, toolName, msg)
|
||||
}
|
||||
// 成功但没有任何文本内容:如实说明,不要回空串。
|
||||
// 空串在工具结果里同样会被模型读成「没有内容」,与失败难以区分。
|
||||
if strings.TrimSpace(sb) == "" {
|
||||
return "", fmt.Errorf("mcp %s tools/call %s 成功但未返回任何文本内容(content 为空)", serverName, toolName)
|
||||
}
|
||||
return sb, nil
|
||||
}
|
||||
|
||||
func (s *Server) Close() error {
|
||||
return s.transport.Close()
|
||||
}
|
||||
|
||||
func (s *Server) SetTransport(t Transport) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.transport.Close()
|
||||
s.transport = t
|
||||
s.nextID = 0
|
||||
}
|
||||
12
example/mcp/go.mod
Normal file
12
example/mcp/go.mod
Normal file
@ -0,0 +1,12 @@
|
||||
module mcp
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/JianFeeeee/homeagentsdk v1.4.0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0
|
||||
20
example/mcp/plg.json
Normal file
20
example/mcp/plg.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "mcp",
|
||||
"name_zh": "MCP 服务器",
|
||||
"name_en": "mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP (Model Context Protocol) 客户端:接入外部 MCP 服务器,透传其工具",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"sdk": "1.4.0",
|
||||
"tags": [
|
||||
"mcp",
|
||||
"protocol"
|
||||
],
|
||||
"targets": "linux/amd64",
|
||||
"outdir": "dist",
|
||||
"bundle": false,
|
||||
"replaces": {},
|
||||
"source_dirs": [],
|
||||
"auto_start": false
|
||||
}
|
||||
428
example/mcp/plugin.go
Normal file
428
example/mcp/plugin.go
Normal file
@ -0,0 +1,428 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||||
)
|
||||
|
||||
// MCP 服务器配置(来自 config_mcp 表或 skill.json)
|
||||
type serverConfig struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command,omitempty"` // stdio 模式
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
URL string `json:"url,omitempty"` // SSE 模式
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return New(name), nil
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
servers []*Server
|
||||
configs []serverConfig
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func New(name string) *Plugin {
|
||||
return &Plugin{name: name}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
cfgs, err := p.loadConfig(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mcp config: %w", err)
|
||||
}
|
||||
p.configs = cfgs
|
||||
if len(cfgs) == 0 {
|
||||
log.Printf("[mcp] no servers configured, idle")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, cfg := range cfgs {
|
||||
if err := p.connectAndRegister(cfg); err != nil {
|
||||
log.Printf("[mcp] connect %s: %v", cfg.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
tDef := sdk.ToolDef{
|
||||
Name: "mcp_restart_server",
|
||||
Description: "重启 MCP 服务器连接。当 MCP 工具返回 pipe/transport closed 错误时,用此工具重启指定的 MCP 服务器。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "MCP 服务器名称(如 email)",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
}
|
||||
if err := s.RegisterTool("mcp_restart_server", tDef, p.restartServerHandler); err != nil {
|
||||
log.Printf("[mcp] register restart tool: %v", err)
|
||||
}
|
||||
|
||||
// MCP 服务器动态管理工具
|
||||
addDef := sdk.ToolDef{
|
||||
Name: "mcp_add_server",
|
||||
Description: "动态添加并连接一个新的 MCP 服务器。支持 stdio 模式(指定 command)和 SSE 模式(指定 url)。添加后该服务器的所有工具立即可用。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "服务器名称(唯一标识,如 my-service)"},
|
||||
"command": map[string]interface{}{"type": "string", "description": "stdio 模式:可执行文件路径(如 npx)"},
|
||||
"args": map[string]interface{}{"type": "string", "description": "命令行参数,JSON 字符串数组(如 [\"-y\", \"@modelcontextprotocol/server-everything\"])"},
|
||||
"url": map[string]interface{}{"type": "string", "description": "SSE 模式:服务器 URL(如 https://api.example.com/mcp)"},
|
||||
"env": map[string]interface{}{"type": "string", "description": "环境变量,JSON 字符串对象(如 {\"KEY\": \"value\"})"},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
}
|
||||
s.RegisterTool("mcp_add_server", addDef, p.addServerHandler)
|
||||
|
||||
removeDef := sdk.ToolDef{
|
||||
Name: "mcp_remove_server",
|
||||
Description: "断开并移除一个已连接的 MCP 服务器。会关闭连接并清理注册的工具(部分清理在重启后完全生效)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "要移除的 MCP 服务器名称"},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
}
|
||||
s.RegisterTool("mcp_remove_server", removeDef, p.removeServerHandler)
|
||||
|
||||
listDef := sdk.ToolDef{
|
||||
Name: "mcp_list_servers",
|
||||
Description: "列出所有已连接的 MCP 服务器及其工具。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
s.RegisterTool("mcp_list_servers", listDef, p.listServersHandler)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) loadConfig(s *sdk.PluginSDK) ([]serverConfig, error) {
|
||||
// 优先从独立服务器配置键读取(servers.<name>.<field>)
|
||||
keys, _ := s.Settings().List("servers.")
|
||||
if len(keys) > 0 {
|
||||
serverNames := make(map[string]bool)
|
||||
for _, k := range keys {
|
||||
parts := strings.SplitN(k, ".", 3)
|
||||
if len(parts) >= 2 {
|
||||
serverNames[parts[1]] = true
|
||||
}
|
||||
}
|
||||
var cfgs []serverConfig
|
||||
for name := range serverNames {
|
||||
cfg := serverConfig{Name: name}
|
||||
if v, _ := s.Settings().Get("servers." + name + ".command"); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
cfg.Command = s
|
||||
}
|
||||
}
|
||||
if v, _ := s.Settings().Get("servers." + name + ".url"); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
cfg.URL = s
|
||||
}
|
||||
}
|
||||
if v, _ := s.Settings().Get("servers." + name + ".args"); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
json.Unmarshal([]byte(s), &cfg.Args)
|
||||
}
|
||||
}
|
||||
if v, _ := s.Settings().Get("servers." + name + ".env"); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
json.Unmarshal([]byte(s), &cfg.Env)
|
||||
}
|
||||
}
|
||||
if cfg.Command != "" || cfg.URL != "" {
|
||||
cfgs = append(cfgs, cfg)
|
||||
}
|
||||
}
|
||||
if len(cfgs) > 0 {
|
||||
return cfgs, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:从旧版 JSON blob 读取
|
||||
raw, err := s.Settings().Get("servers")
|
||||
if err == nil {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
var cfgs []serverConfig
|
||||
if err := json.Unmarshal([]byte(v), &cfgs); err == nil && len(cfgs) > 0 {
|
||||
return cfgs, nil
|
||||
}
|
||||
case []interface{}:
|
||||
data, _ := json.Marshal(v)
|
||||
var cfgs []serverConfig
|
||||
if json.Unmarshal(data, &cfgs) == nil && len(cfgs) > 0 {
|
||||
return cfgs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) connectServer(cfg serverConfig) (*Server, []MCPTool, error) {
|
||||
var transport Transport
|
||||
|
||||
if cfg.URL != "" {
|
||||
transport = NewSSETransport(cfg.URL)
|
||||
} else if cfg.Command != "" {
|
||||
var err error
|
||||
transport, err = NewStdioTransport(cfg.Command, cfg.Args, cfg.Env)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("stdio transport: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, nil, fmt.Errorf("neither command nor url specified")
|
||||
}
|
||||
|
||||
server := NewServer(cfg.Name, transport)
|
||||
|
||||
tools, err := server.ListTools()
|
||||
if err != nil {
|
||||
transport.Close()
|
||||
return nil, nil, fmt.Errorf("list tools: %w", err)
|
||||
}
|
||||
|
||||
return server, tools, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) makeHandler(server *Server, toolName string) sdk.ToolHandler {
|
||||
return func(args map[string]interface{}) (interface{}, error) {
|
||||
return server.CallTool(toolName, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) connectAndRegister(cfg serverConfig) error {
|
||||
server, tools, err := p.connectServer(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, tool := range tools {
|
||||
toolName := fmt.Sprintf("%s_%s", cfg.Name, tool.Name)
|
||||
tDef := sdk.ToolDef{
|
||||
Name: toolName,
|
||||
Description: fmt.Sprintf("[MCP/%s] %s", cfg.Name, tool.Description),
|
||||
Parameters: tool.InputSchema,
|
||||
}
|
||||
tHandler := p.makeHandler(server, tool.Name)
|
||||
if err := p.sdk.RegisterTool(toolName, tDef, tHandler); err != nil {
|
||||
log.Printf("[mcp] register tool %s: %v", toolName, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[mcp] registered tool: %s (%s)", toolName, cfg.Name)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.servers = append(p.servers, server)
|
||||
p.mu.Unlock()
|
||||
log.Printf("[mcp] connected server: %s (%d tools)", cfg.Name, len(tools))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) restartServerHandler(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return "参数 name 不能为空", nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
var idx int = -1
|
||||
for i, s := range p.servers {
|
||||
if s.Name() == name {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
p.mu.Unlock()
|
||||
return fmt.Sprintf("MCP 服务器 [%s] 不存在", name), nil
|
||||
}
|
||||
oldServer := p.servers[idx]
|
||||
p.mu.Unlock()
|
||||
|
||||
var cfg *serverConfig
|
||||
for i := range p.configs {
|
||||
if p.configs[i].Name == name {
|
||||
cfg = &p.configs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if cfg == nil {
|
||||
return fmt.Sprintf("MCP 服务器 [%s] 的配置未找到", name), nil
|
||||
}
|
||||
|
||||
transport, err := newTransport(*cfg)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("创建 MCP 服务器 [%s] 传输层失败: %v", name, err), nil
|
||||
}
|
||||
|
||||
oldServer.SetTransport(transport)
|
||||
|
||||
_, err = oldServer.ListTools()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("MCP 服务器 [%s] 重启后通信仍异常: %v", name, err), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("MCP 服务器 [%s] 已成功重启", name), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) addServerHandler(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return "参数 name 不能为空", nil
|
||||
}
|
||||
|
||||
cfg := serverConfig{Name: name}
|
||||
if v, _ := args["command"].(string); v != "" {
|
||||
cfg.Command = v
|
||||
}
|
||||
if v, _ := args["url"].(string); v != "" {
|
||||
cfg.URL = v
|
||||
}
|
||||
if v, _ := args["args"].(string); v != "" {
|
||||
json.Unmarshal([]byte(v), &cfg.Args)
|
||||
}
|
||||
if v, _ := args["env"].(string); v != "" {
|
||||
// 支持 JSON 对象和 JSON 字符串数组两种格式
|
||||
var envObj map[string]string
|
||||
if err := json.Unmarshal([]byte(v), &envObj); err == nil {
|
||||
for k, val := range envObj {
|
||||
cfg.Env = append(cfg.Env, k+"="+val)
|
||||
}
|
||||
} else {
|
||||
json.Unmarshal([]byte(v), &cfg.Env)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Command == "" && cfg.URL == "" {
|
||||
return "必须指定 command(stdio 模式)或 url(SSE 模式)", nil
|
||||
}
|
||||
|
||||
// 检查是否已存在同名服务器
|
||||
p.mu.Lock()
|
||||
for _, s := range p.servers {
|
||||
if s.Name() == name {
|
||||
p.mu.Unlock()
|
||||
return fmt.Sprintf("MCP 服务器 [%s] 已存在。如要重启请使用 mcp_restart_server,如要替换请先 mcp_remove_server", name), nil
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if err := p.connectAndRegister(cfg); err != nil {
|
||||
return fmt.Sprintf("连接 MCP 服务器 [%s] 失败: %v", name, err), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.configs = append(p.configs, cfg)
|
||||
p.mu.Unlock()
|
||||
|
||||
return fmt.Sprintf("MCP 服务器 [%s] 已成功连接并注册所有工具", name), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) removeServerHandler(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return "参数 name 不能为空", nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
var keptServers []*Server
|
||||
var removed bool
|
||||
for _, s := range p.servers {
|
||||
if s.Name() == name {
|
||||
s.Close()
|
||||
removed = true
|
||||
} else {
|
||||
keptServers = append(keptServers, s)
|
||||
}
|
||||
}
|
||||
p.servers = keptServers
|
||||
|
||||
var keptConfigs []serverConfig
|
||||
for _, c := range p.configs {
|
||||
if c.Name != name {
|
||||
keptConfigs = append(keptConfigs, c)
|
||||
}
|
||||
}
|
||||
p.configs = keptConfigs
|
||||
p.mu.Unlock()
|
||||
|
||||
if !removed {
|
||||
return fmt.Sprintf("MCP 服务器 [%s] 不存在", name), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("MCP 服务器 [%s] 已断开连接。工具注册信息将在重启后完全清理。", name), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) listServersHandler(args map[string]interface{}) (interface{}, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if len(p.servers) == 0 {
|
||||
return "没有已连接的 MCP 服务器。", nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("已连接的 MCP 服务器 (%d):", len(p.servers)))
|
||||
for _, s := range p.servers {
|
||||
tools, err := s.ListTools()
|
||||
var toolInfo string
|
||||
if err == nil && len(tools) > 0 {
|
||||
var names []string
|
||||
for _, t := range tools {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
toolInfo = strings.Join(names, ", ")
|
||||
} else if err != nil {
|
||||
toolInfo = fmt.Sprintf("(查询工具失败: %v)", err)
|
||||
} else {
|
||||
toolInfo = "(无工具)"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %s: %s", s.Name(), toolInfo))
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
func newTransport(cfg serverConfig) (Transport, error) {
|
||||
if cfg.URL != "" {
|
||||
return NewSSETransport(cfg.URL), nil
|
||||
}
|
||||
if cfg.Command != "" {
|
||||
return NewStdioTransport(cfg.Command, cfg.Args, cfg.Env)
|
||||
}
|
||||
return nil, fmt.Errorf("neither command nor url specified")
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, s := range p.servers {
|
||||
s.Close()
|
||||
}
|
||||
p.servers = nil
|
||||
return nil
|
||||
}
|
||||
26
example/mcp/skill/mcp-tools/SKILL.md
Normal file
26
example/mcp/skill/mcp-tools/SKILL.md
Normal file
@ -0,0 +1,26 @@
|
||||
---
|
||||
name: mcp-tools
|
||||
description: 接入外部 MCP (Model Context Protocol) 服务器,透传其工具。按需加载:先 plugin_enable("mcp"),配置好服务器后工具自动可用。
|
||||
---
|
||||
|
||||
# MCP 服务器接入
|
||||
|
||||
## 本插件是按需加载(auto_start: false)
|
||||
|
||||
只在需要接外部 MCP 服务器时拉起:`plugin_enable("mcp")`。
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 用途 |
|
||||
|---|---|
|
||||
| `mcp_add_server` | 注册一个 MCP 服务器(stdio 命令或 SSE URL) |
|
||||
| `mcp_list_servers` | 列出已配置的服务器与连接状态 |
|
||||
| `mcp_remove_server` | 移除一个服务器 |
|
||||
| `mcp_restart_server` | 重连指定服务器 |
|
||||
|
||||
服务器接通后,它提供的工具会以 `mcp_<server>_<tool>` 的形式出现。
|
||||
|
||||
## 注意
|
||||
|
||||
- stdio 模式给出可执行命令与参数;SSE 模式给出 URL。
|
||||
- 外部服务器的工具描述不可信内容照常遵循(来源标注)。
|
||||
115
example/mcp/sse.go
Normal file
115
example/mcp/sse.go
Normal file
@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SSETransport 通过 HTTP POST 进行 JSON-RPC 通信。
|
||||
// 兼容两种服务端响应:application/json 直连响应,以及 Streamable HTTP
|
||||
// 的异步响应(HTTP 202 + text/event-stream 的 SSE data 帧)。
|
||||
type SSETransport struct {
|
||||
url string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewSSETransport(url string) *SSETransport {
|
||||
// 必须带超时:远程 MCP server 网络抖动/无响应时,
|
||||
// 无超时的 client 会让插件加载永久阻塞(webui 等后续插件全部起不来)
|
||||
return &SSETransport{
|
||||
url: url,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parseResponseBody 根据 Content-Type 解析 JSON-RPC 响应
|
||||
func parseResponseBody(contentType string, body []byte) (*mcpRPCResponse, error) {
|
||||
if strings.Contains(contentType, "text/event-stream") {
|
||||
// SSE 流:逐行提取 data: 帧
|
||||
var lastJSON []byte
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "" || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var frame map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(data), &frame); err == nil {
|
||||
if _, isResp := frame["id"]; isResp || frame["result"] != nil || frame["error"] != nil {
|
||||
lastJSON = []byte(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(lastJSON) == 0 {
|
||||
return nil, fmt.Errorf("SSE 流中未找到 JSON-RPC 响应帧: %s", truncate(string(body), 300))
|
||||
}
|
||||
body = lastJSON
|
||||
}
|
||||
|
||||
var rpcResp mcpRPCResponse
|
||||
if err := json.Unmarshal(body, &rpcResp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal response: %w, body=%s", err, truncate(string(body), 300))
|
||||
}
|
||||
return &rpcResp, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (t *SSETransport) Send(req *mcpRPCRequest) (*mcpRPCResponse, error) {
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", t.url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "application/json, text/event-stream")
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http post: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
// 非 2xx:尝试提取错误信息
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("http status %d: %s", resp.StatusCode, truncate(string(body), 300))
|
||||
}
|
||||
|
||||
return parseResponseBody(ct, body)
|
||||
}
|
||||
|
||||
func (t *SSETransport) Close() error {
|
||||
t.client.CloseIdleConnections()
|
||||
return nil
|
||||
}
|
||||
161
example/mcp/stdio.go
Normal file
161
example/mcp/stdio.go
Normal file
@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
// safeCloseChan 是主仓 internal/safeclose.Safe 的内联版(外部插件不能引
|
||||
// internal 包)。close of closed channel 是真机 SIGTERM 实测过的 panic:
|
||||
// 多个退出路径(错误/超时/重连)都可能走到同一根 channel 的 close,
|
||||
// 所以除本定义外不得出现裸 close(ch)——一律走这里。
|
||||
func safeCloseChan[T any](once *sync.Once, ch chan T) bool {
|
||||
if once == nil || ch == nil {
|
||||
return false
|
||||
}
|
||||
fired := false
|
||||
once.Do(func() {
|
||||
fired = true
|
||||
close(ch)
|
||||
})
|
||||
return fired
|
||||
}
|
||||
|
||||
// StdioTransport 通过子进程 stdin/stdout 进行 JSON-RPC 通信
|
||||
// pendingRPC 是待应答通道 + 它自己的 close 守卫(本仓 close 纪律)。
|
||||
type pendingRPC struct {
|
||||
ch chan *mcpRPCResponse
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (p *pendingRPC) closeOnce() { safeCloseChan(&p.once, p.ch) }
|
||||
|
||||
type StdioTransport struct {
|
||||
doneOnce sync.Once
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout *bufio.Reader
|
||||
mu sync.Mutex
|
||||
pending map[int]*pendingRPC
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewStdioTransport(command string, args []string, env []string) (*StdioTransport, error) {
|
||||
cmd := exec.Command(command, args...)
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
// 忽略 stderr(MCP 服务器可能输出日志到 stderr)
|
||||
cmd.Stderr = nil
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start %s: %w", command, err)
|
||||
}
|
||||
|
||||
t := &StdioTransport{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdout: bufio.NewReader(stdout),
|
||||
pending: make(map[int]*pendingRPC),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go t.readLoop()
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *StdioTransport) readLoop() {
|
||||
dec := json.NewDecoder(t.stdout)
|
||||
for {
|
||||
var resp mcpRPCResponse
|
||||
if err := dec.Decode(&resp); err != nil {
|
||||
safeCloseChan(&t.doneOnce, t.done)
|
||||
// 通知所有等待的请求
|
||||
t.mu.Lock()
|
||||
for _, pr := range t.pending {
|
||||
pr.closeOnce()
|
||||
}
|
||||
t.pending = make(map[int]*pendingRPC)
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
pr, ok := t.pending[resp.ID]
|
||||
delete(t.pending, resp.ID)
|
||||
t.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
pr.ch <- &resp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *StdioTransport) Send(req *mcpRPCRequest) (*mcpRPCResponse, error) {
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
ch := make(chan *mcpRPCResponse, 1)
|
||||
t.mu.Lock()
|
||||
t.pending[req.ID] = &pendingRPC{ch: ch}
|
||||
t.mu.Unlock()
|
||||
|
||||
if _, err := t.stdin.Write(data); err != nil {
|
||||
t.mu.Lock()
|
||||
delete(t.pending, req.ID)
|
||||
t.mu.Unlock()
|
||||
return nil, fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
if _, err := t.stdin.Write([]byte("\n")); err != nil {
|
||||
t.mu.Lock()
|
||||
delete(t.pending, req.ID)
|
||||
t.mu.Unlock()
|
||||
return nil, fmt.Errorf("write newline: %w", err)
|
||||
}
|
||||
|
||||
// 超时保护:MCP server 进程启动慢/卡死不响应时,不能让插件加载永久阻塞
|
||||
// (stdio server 冷启动如 npx 拉包可能数十秒,给 60s)
|
||||
timeout := time.After(60 * time.Second)
|
||||
select {
|
||||
case resp := <-ch:
|
||||
return resp, nil
|
||||
case <-t.done:
|
||||
t.mu.Lock()
|
||||
delete(t.pending, req.ID)
|
||||
t.mu.Unlock()
|
||||
return nil, fmt.Errorf("mcp transport closed")
|
||||
case <-timeout:
|
||||
t.mu.Lock()
|
||||
delete(t.pending, req.ID)
|
||||
t.mu.Unlock()
|
||||
return nil, fmt.Errorf("mcp request timeout (60s), method may be hung")
|
||||
}
|
||||
}
|
||||
|
||||
func (t *StdioTransport) Close() error {
|
||||
t.stdin.Close()
|
||||
if t.cmd.Process != nil {
|
||||
t.cmd.Process.Kill()
|
||||
}
|
||||
<-t.done
|
||||
return t.cmd.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user