Files
homeagent-sdk/example/mcp/client.go
root e849f39663 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 全绿。
2026-10-19 22:01:07 +08:00

198 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}