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:
root
2026-10-19 22:01:07 +08:00
parent bea410f891
commit e849f39663
22 changed files with 2348 additions and 3 deletions

13
example/cmd/README.md Normal file
View File

@ -0,0 +1,13 @@
# cmd — 命令执行插件
沙箱工作目录里执行 shell 命令。按需加载(`auto_start: false`):
由 agent 经 `plugin_enable("cmd")` 拉起,用完 `plugin_stop` 释放。
源自内核内置插件(2026-10-19 外置,判定标准见主仓 ARCHITECTURE
「内置插件的判定标准」:它只用公开 SDK,无必须内置的理由)。
## 构建
hmapdev build
产物:`dist/cmd_linux_amd64.hmap`

9
example/cmd/go.mod Normal file
View File

@ -0,0 +1,9 @@
module homeagent-cmd
go 1.25.0
require github.com/JianFeeeee/homeagentsdk v1.4.0
replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

19
example/cmd/plg.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "cmd",
"name_zh": "命令执行",
"name_en": "cmd",
"version": "1.0.0",
"description": "沙箱内执行",
"author": "HomeAgent",
"entry": "plugin.so",
"sdk": "1.4.0",
"tags": [
"cmd"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": false,
"replaces": {},
"source_dirs": [],
"auto_start": false
}

302
example/cmd/plugin.go Normal file
View File

@ -0,0 +1,302 @@
package main
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"time"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
// isWindows 缓存运行时检测结果
var isWindows = runtime.GOOS == "windows"
// shellUnquote 拆解命令字符串,处理单引号/双引号包裹的参数
func shellUnquote(s string) []string {
var args []string
var cur strings.Builder
inSingle := false
inDouble := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '\'' && !inDouble:
inSingle = !inSingle
case c == '"' && !inSingle:
inDouble = !inDouble
case (c == ' ' || c == '\t') && !inSingle && !inDouble:
if cur.Len() > 0 {
args = append(args, cur.String())
cur.Reset()
}
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
args = append(args, cur.String())
}
return args
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
}
type cmdRecord struct {
Timestamp time.Time `json:"timestamp"`
Command string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Workdir string `json:"workdir"`
Timeout string `json:"timeout"`
Duration string `json:"duration"`
Status string `json:"status"`
}
const maxHistory = 100
type Plugin struct {
name string
defaultTimeout string
maxOutput int
mu sync.Mutex
history []cmdRecord
}
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)
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "default_timeout", Type: "string", DisplayName: "默认命令超时",
Description: "命令执行的默认超时时间,例如 30s, 1m, 5m(默认 30s)",
Default: "30s",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "max_output_bytes", Type: "int", DisplayName: "最大输出字节数",
Description: "命令输出的最大字节数,超出部分将被截断(默认 32000)",
Default: "32000",
})
p.maxOutput = 32000
if v, _ := s.Settings().Get("max_output_bytes"); v != nil {
if s, ok := v.(string); ok && s != "" {
if n, err := fmt.Sscanf(s, "%d", &p.maxOutput); err == nil && n > 0 {
}
}
}
p.defaultTimeout = "30s"
if v, _ := s.Settings().Get("default_timeout"); v != nil {
if s, ok := v.(string); ok && s != "" {
p.defaultTimeout = s
}
}
s.RegisterTool("cmd_run", sdk.ToolDef{
Name: "cmd_run",
Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top),请使用 terminal_create 相关工具。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"command": map[string]interface{}{
"type": "string",
"description": "要执行的命令",
},
"timeout": map[string]interface{}{
"type": "string",
"description": "超时时间,例如 10s, 1m, 30s(默认 30s)",
},
"workdir": map[string]interface{}{
"type": "string",
"description": "工作目录(可选,默认由 core.agent.workdir 配置决定)",
},
},
"required": []string{"command"},
},
// 执行体只依赖入参;唯一共享 p.history 由 recordCmd 加 p.mu 保护
ParallelSafe: true,
}, func(args map[string]interface{}) (interface{}, error) {
command, _ := args["command"].(string)
if command == "" {
return map[string]interface{}{"error": "command is required"}, nil
}
timeoutStr, _ := args["timeout"].(string)
if timeoutStr == "" {
timeoutStr = p.defaultTimeout
}
timeout, err := time.ParseDuration(timeoutStr)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("invalid timeout %q: %v", timeoutStr, err)}, nil
}
workdir, _ := args["workdir"].(string)
if workdir == "" {
if sett := s.Settings(); sett != nil {
if v, _ := sett.GetCore("core.agent.workdir"); v != nil {
if str, ok := v.(string); ok && str != "" {
workdir = str
}
}
}
}
tStart := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Linux/Unix 经 /bin/sh -c 执行完整 shell 语法:管道、分号、&&、
// 命令替换、heredoc、重定向全部可用。旧实现 shellUnquote 拆词后
// 直接 exec,导致 pwd; ls / 变成执行名为 "pwd;" 的程序(exit -1)、
// heredoc 被截断——agent 多次反馈"命令解析奇怪"即此。
var cmd *exec.Cmd
if isWindows {
execCmd := "chcp 65001>nul & " + command
cmd = exec.CommandContext(ctx, "cmd.exe", "/d", "/c", execCmd)
} else {
shell := "/bin/sh"
if _, err := os.Stat("/bin/bash"); err == nil {
shell = "/bin/bash" // bash 支持更完整的语法(数组、[[ ]] 等)
}
cmd = exec.CommandContext(ctx, shell, "-c", command)
}
if workdir != "" {
cmd.Dir = workdir
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
rec := cmdRecord{Timestamp: tStart, Command: command, Workdir: workdir, Timeout: timeoutStr}
exitCode := -1
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
rec.Status = "timeout"
rec.Stdout = p.truncateOutput(stdout.String())
rec.Stderr = p.truncateOutput(stderr.String())
rec.Duration = time.Since(tStart).Round(time.Millisecond).String()
p.recordCmd(rec)
return map[string]interface{}{
"status": "timeout",
"stdout": rec.Stdout,
"stderr": rec.Stderr,
"error": fmt.Sprintf("命令执行超时(%s)", timeoutStr),
}, nil
}
}
if cmd.ProcessState != nil {
exitCode = cmd.ProcessState.ExitCode()
}
rec.Status = "ok"
rec.Stdout = p.truncateOutput(stdout.String())
rec.Stderr = p.truncateOutput(stderr.String())
rec.ExitCode = exitCode
rec.Duration = time.Since(tStart).Round(time.Millisecond).String()
p.recordCmd(rec)
return map[string]interface{}{
"status": "ok",
"stdout": rec.Stdout,
"stderr": rec.Stderr,
"exit_code": exitCode,
"command": command,
}, nil
})
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
if len(ctx.ToolCalls) == 0 {
return nil
}
tc := ctx.ToolCalls[0]
if tc.Name != "cmd_run" {
return nil
}
command, _ := tc.Arguments["command"].(string)
if command == "" {
return nil
}
if blocked, reason := isDangerousCommand(command); blocked {
ctx.Lock()
ctx.Response = &reason
ctx.Unlock()
return nil
}
return nil
}, sdk.StageScopeOwnTools)
return nil
}
func (p *Plugin) Stop() error {
return nil
}
func (p *Plugin) recordCmd(r cmdRecord) {
p.mu.Lock()
defer p.mu.Unlock()
p.history = append(p.history, r)
if len(p.history) > maxHistory {
p.history = p.history[len(p.history)-maxHistory:]
}
}
func (p *Plugin) truncateOutput(s string) string {
maxLen := p.maxOutput
if maxLen <= 0 {
maxLen = 32000
}
if len(s) > maxLen {
return s[:maxLen] + fmt.Sprintf("\n... [输出被截断,共 %d 字节]", len(s))
}
return strings.TrimRight(s, "\n")
}
func isDangerousCommand(command string) (bool, string) {
pid := os.Getpid()
pidStr := fmt.Sprintf("%d", pid)
lower := strings.ToLower(command)
selfTargets := []string{"homed", pidStr}
killCmds := []string{"kill ", "killall ", "pkill ", "kill -", "kill -9"}
serviceCmds := []string{
"systemctl stop homeagent",
"systemctl restart homeagent",
"systemctl kill homeagent",
"service homeagent stop",
"service homeagent restart",
}
for _, kp := range killCmds {
if !strings.Contains(lower, kp) {
continue
}
for _, t := range selfTargets {
if strings.Contains(lower, strings.ToLower(t)) {
return true, fmt.Sprintf("命令被拦截:禁止向 homed 进程(pid=%s)发送信号", pidStr)
}
}
}
for _, sc := range serviceCmds {
if strings.Contains(lower, sc) {
return true, fmt.Sprintf("命令被拦截:禁止操作 homed 服务进程(pid=%s)", pidStr)
}
}
return false, ""
}

View File

@ -0,0 +1,23 @@
---
name: cmd-tools
description: 在沙箱工作目录里执行 shell 命令(cmd_run)。按需加载:先 plugin_enable 再调用,用完可 plugin_stop 释放上下文。
---
# 命令执行
## 本插件是按需加载(auto_start: false)
它只在你真的要跑命令时才需要。要先用 `plugin_enable("cmd")` 拉起,
否则 `cmd_run` 不在工具清单里(不是坏了,是还没装上)。
## 工具
| 工具 | 用途 |
|---|---|
| `cmd_run` | 执行 shell 命令,返回 stdout/stderr/exit_code |
## 注意
- 超时可用参数 `timeout`(如 "30s" / "5m")指定,超过后命令被终止。
- 长驻进程请用 `agentcli` 的 `terminal_*`(PTY 会话),不要用 cmd_run 挂着。
- 危险命令(rm -rf、写系统目录)先向用户确认。

View File

@ -0,0 +1,100 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func newTestPlugin(t *testing.T) *Plugin {
t.Helper()
inst, err := NewPluginFactory("files", nil)
if err != nil { t.Fatal(err) }
return inst.(*Plugin)
}
// 精确替换:old 在文件里出现多次时 files_edit 拒绝,edit_line 按行号可改。
func TestEditLineReplacesNonUniqueLine(t *testing.T) {
p := newTestPlugin(t)
dir := t.TempDir()
p.filesDir = dir
file := filepath.Join(dir, "a.go")
content := "return nil\nx := 1\nreturn nil\n"
os.WriteFile(file, []byte(content), 0644)
res, err := p.handleEditLine(map[string]interface{}{
"path": file,
"edits": []interface{}{map[string]interface{}{
"line": float64(3), "op": "replace", "text": "return err",
}},
})
if err != nil { t.Fatal(err) }
_ = res
got, _ := os.ReadFile(file)
want := "return nil\nx := 1\nreturn err\n"
if string(got) != want {
t.Fatalf("第3行应被替换。\nwant: %q\ngot: %q", want, string(got))
}
}
func TestEditLineExpectGuard(t *testing.T) {
p := newTestPlugin(t)
dir := t.TempDir()
p.filesDir = dir
file := filepath.Join(dir, "a.txt")
os.WriteFile(file, []byte("alpha\nbeta\ngamma\n"), 0644)
// 本插件的约定:工具级错误经**返回值**(isError:true)回传而非 Go error,
// 与 handleEdit 一致 —— 断言返回体而不是 err。
res, _ := p.handleEditLine(map[string]interface{}{
"path": file,
"edits": []interface{}{map[string]interface{}{
"line": float64(2), "op": "replace", "text": "BETA", "expect": "delta",
}},
})
m, _ := res.(map[string]interface{})
if m == nil || m["isError"] != true {
t.Fatalf("expect 不匹配应返回 isError:true,实际: %v", res)
}
content, _ := m["content"].(string)
if !strings.Contains(content, "expected") {
t.Fatalf("错误内容应指出 expect 不匹配: %s", content)
}
got, _ := os.ReadFile(file)
if string(got) != "alpha\nbeta\ngamma\n" { t.Fatal("失败时文件不得被改") }
}
func TestEditLineInsertAndDelete(t *testing.T) {
p := newTestPlugin(t)
dir := t.TempDir()
p.filesDir = dir
file := filepath.Join(dir, "a.txt")
os.WriteFile(file, []byte("one\ntwo\n"), 0644)
if _, err := p.handleEditLine(map[string]interface{}{
"path": file,
"edits": []interface{}{
map[string]interface{}{"line": float64(2), "op": "insert", "text": "one-five\none-b"}, // 插在 two 前
map[string]interface{}{"line": float64(1), "op": "delete"},
},
}); err != nil { t.Fatal(err) }
got, _ := os.ReadFile(file)
// 从后往前应用:先删 line1(one),再在 line2(原 two)前插两行
want := "one-five\none-b\ntwo\n"
if string(got) != want { t.Fatalf("want %q got %q", want, string(got)) }
}
func TestEditLineAppendAtEnd(t *testing.T) {
p := newTestPlugin(t)
dir := t.TempDir()
p.filesDir = dir
file := filepath.Join(dir, "a.txt")
os.WriteFile(file, []byte("a\nb\n"), 0644)
if _, err := p.handleEditLine(map[string]interface{}{
"path": file,
"edits": []interface{}{map[string]interface{}{"line": float64(3), "op": "insert", "text": "c"}},
}); err != nil { t.Fatal(err) }
got, _ := os.ReadFile(file)
if string(got) != "a\nb\nc\n" { t.Fatalf("want a\\nb\\nc\\n got %q", string(got)) }
}

View File

@ -1,7 +1,7 @@
{ {
"name": "files", "name": "files",
"name_zh": "文件系统", "name_zh": "文件系统",
"name_en": "File System", "name_en": "files",
"version": "1.0.0", "version": "1.0.0",
"description": "文件系统操作工具集(读取/写入/编辑/列表),提供沙箱化文件访问,支持配置工作目录。", "description": "文件系统操作工具集(读取/写入/编辑/列表),提供沙箱化文件访问,支持配置工作目录。",
"author": "HomeAgent", "author": "HomeAgent",

View File

@ -124,6 +124,43 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}, },
}, p.handleEdit) }, p.handleEdit)
s.RegisterTool(tp+"edit_line", sdk.ToolDef{
Name: tp + "edit_line",
Description: fmt.Sprintf("Replace or insert by **line number** within the sandbox (%s). "+
"Use this instead of %sedit when the target text is not unique (e.g. a blank line, "+
"a closing brace, or a repeated statement) — %sedit requires the old text to appear exactly "+
"once and will refuse otherwise. Line numbers are 1-indexed and refer to the file as it is "+
"before this call; multiple edits are applied bottom-up so earlier line numbers stay valid.",
p.filesDir, p.name+"_", p.name+"_"),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
"edits": map[string]interface{}{
"type": "array",
"description": "Line-targeted operations. Do not overlap ranges. Applied from the last line to the first.",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"line": map[string]interface{}{"type": "integer", "description": "Target line (1-indexed)"},
"op": map[string]interface{}{
"type": "string",
"description": "replace (default): replace that line | insert: insert new text *before* that line | delete: remove that line",
},
"text": map[string]interface{}{"type": "string", "description": "New text (may contain \\n for multiple lines). Omit for delete."},
"expect": map[string]interface{}{
"type": "string",
"description": "Optional safety check: the current line must equal this exactly (ignoring trailing \\r). Guards against acting on a stale line number.",
},
},
"required": []string{"line"},
},
},
},
"required": []string{"path", "edits"},
},
}, p.handleEditLine)
s.RegisterTool(tp+"ls", sdk.ToolDef{ s.RegisterTool(tp+"ls", sdk.ToolDef{
Name: tp + "ls", Name: tp + "ls",
Description: fmt.Sprintf("List directory contents within the sandbox (%s). Directories are marked with / suffix.", p.filesDir), Description: fmt.Sprintf("List directory contents within the sandbox (%s). Directories are marked with / suffix.", p.filesDir),
@ -464,6 +501,190 @@ func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
}, nil }, nil
} }
// handleEditLine implements the edit_line tool: line-number targeted edits.
//
// ## 为什么除了 edit 还要这个
//
// files_edit 的判据是「old 文本在文件里**恰好出现一次**」。这个约束在真实
// 改代码时经常不成立:
//
// · 改一个 `}`、一个空行、`return nil` —— 全文件出现几十次,edit 直接拒绝;
// · 改「第三个 case 分支里的 break」—— 文本与前两个字节相同,无法定位。
//
// 于是模型只能退化成 read 全文 + write 全文,而那是有损的:整段重写会
// 顺带改动空白/换行/文件末尾,diff 里再也看不出「只改了这一行」。
//
// 本工具按**行号**定位,不依赖文本唯一性;`expect` 提供可选的安全闸
// (行内容与预期不符就整体放弃,防止拿到过期行号后改错地方)。
func (p *Plugin) handleEditLine(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
rawEdits, ok := args["edits"].([]interface{})
if !ok || len(rawEdits) == 0 {
return errorResult("edits must be a non-empty array"), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("read error: " + err.Error()), nil
}
original := string(data)
// 按行切分并保留行尾信息:是否有 \r\n、末尾是否带换行。
// 这两点必须原样还原,否则「精确替换一行」会顺手改掉整个文件的换行风格。
crlf := strings.Contains(original, "\r\n")
endsWithNewline := strings.HasSuffix(original, "\n")
lines := strings.Split(original, "\n")
if endsWithNewline {
lines = lines[:len(lines)-1]
}
// 每行去掉 \r(若原文件是 CRLF),edit 内部统一按无 \r 的文本处理。
for i := range lines {
lines[i] = strings.TrimSuffix(lines[i], "\r")
}
type op struct {
line int
kind string
text string
expect string
idx int
}
var ops []op
var errors []string
for i, raw := range rawEdits {
edit, ok := raw.(map[string]interface{})
if !ok {
errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i))
continue
}
ln64, err := toInt64(edit["line"])
if err != nil || ln64 < 1 {
errors = append(errors, fmt.Sprintf("edit[%d]: line must be a positive integer", i))
continue
}
kind, _ := edit["op"].(string)
if kind == "" {
kind = "replace"
}
if kind != "replace" && kind != "insert" && kind != "delete" {
errors = append(errors, fmt.Sprintf("edit[%d]: op must be replace|insert|delete, got %q", i, kind))
continue
}
text, _ := edit["text"].(string)
if kind != "delete" && text == "" && edit["text"] == nil {
errors = append(errors, fmt.Sprintf("edit[%d]: text is required for op=%s", i, kind))
continue
}
expect, _ := edit["expect"].(string)
ops = append(ops, op{line: int(ln64), kind: kind, text: text, expect: expect, idx: i})
}
if len(errors) > 0 {
return errorResult("invalid edits: " + strings.Join(errors, "; ")), nil
}
// 行号允许等于 len(lines)+1 用于在文件末尾追加(insert)。
for _, o := range ops {
if o.line > len(lines)+1 {
return errorResult(fmt.Sprintf(
"edit[%d]: line %d is out of range (file has %d lines)", o.idx, o.line, len(lines))), nil
}
}
// ★ 从后往前应用:这样前面编辑的行号不受影响,调用方可以用「一次性
// 基于原始文件的行号」来描述多处修改,无需自己算偏移。
sort.SliceStable(ops, func(a, b int) bool { return ops[a].line > ops[b].line })
for _, o := range ops {
pos := o.line - 1 // 0-indexed 插入/替换位置
switch o.kind {
case "insert":
// 允许 line == len(lines)+1:追加到末尾。
if pos > len(lines) {
pos = len(lines)
}
newLines := strings.Split(o.text, "\n")
lines = append(lines[:pos], append(newLines, lines[pos:]...)...)
case "delete":
if pos >= len(lines) {
return errorResult(fmt.Sprintf(
"edit[%d]: cannot delete line %d (file has %d lines)", o.idx, o.line, len(lines))), nil
}
if o.expect != "" && lines[pos] != o.expect {
return errorResult(fmt.Sprintf(
"edit[%d]: line %d is %q, expected %q — file changed since you read it; re-read and retry",
o.idx, o.line, lines[pos], o.expect)), nil
}
lines = append(lines[:pos], lines[pos+1:]...)
case "replace":
if pos >= len(lines) {
return errorResult(fmt.Sprintf(
"edit[%d]: cannot replace line %d (file has %d lines); use op=insert to append",
o.idx, o.line, len(lines))), nil
}
// expect 是安全闸:行号过期时宁可报错,也不要在错误位置写内容。
if o.expect != "" && lines[pos] != o.expect {
return errorResult(fmt.Sprintf(
"edit[%d]: line %d is %q, expected %q — file changed since you read it; re-read and retry",
o.idx, o.line, lines[pos], o.expect)), nil
}
newLines := strings.Split(o.text, "\n")
lines = append(lines[:pos], append(newLines, lines[pos+1:]...)...)
}
}
sep := "\n"
if crlf {
sep = "\r\n"
}
out := strings.Join(lines, sep)
if endsWithNewline {
out += sep
}
if err := os.WriteFile(absPath, []byte(out), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Successfully applied %d line edit(s) to %s (%d lines now)",
len(ops), path, len(lines)),
}, nil
}
// toInt64 宽容地把 JSON 数字(float64)或字符串转成 int64。
func toInt64(v interface{}) (int64, error) {
switch n := v.(type) {
case float64:
return int64(n), nil
case int:
return int64(n), nil
case int64:
return n, nil
case json.Number:
return n.Int64()
case string:
var i int64
if _, err := fmt.Sscanf(n, "%d", &i); err != nil {
return 0, err
}
return i, nil
}
return 0, fmt.Errorf("cannot convert %T to int64", v)
}
// handleLs implements the ls tool. // handleLs implements the ls tool.
func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string) path, _ := args["path"].(string)

View File

@ -22,7 +22,8 @@ plugin_start name=files
|---|---| |---|---|
| `files_read` | 读文件内容 | | `files_read` | 读文件内容 |
| `files_write` | 写文件(覆盖) | | `files_write` | 写文件(覆盖) |
| `files_edit` | 局部替换编辑 | | `files_edit` | 局部替换编辑(old 文本须全文件唯一) |
| `files_edit_line` | **按行号**精确替换/插入/删除(目标文本不唯一时用它,如改第 N 个 `}`) |
| `files_ls` | 列目录 | | `files_ls` | 列目录 |
## 用完可释放 ## 用完可释放

View File

@ -0,0 +1,12 @@
# localuse — 本地外设插件
本机外设能力(截屏/摄像头/麦克风/剪贴板/整机操控)。按需加载
(`auto_start: false`):agent 经 `plugin_enable("localuse")` 拉起。
源自内核内置插件(2026-10-19 外置;只用公开 SDK,见主仓判定标准)。
## 构建
hmapdev build
产物:`dist/localuse_linux_amd64.hmap`

9
example/localuse/go.mod Normal file
View File

@ -0,0 +1,9 @@
module localuse
go 1.25.0
require github.com/JianFeeeee/homeagentsdk v1.4.0
replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

19
example/localuse/plg.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "localuse",
"name_zh": "本地外设",
"name_en": "localuse",
"version": "1.0.0",
"description": "Device",
"author": "HomeAgent",
"entry": "plugin.so",
"sdk": "1.4.0",
"tags": [
"localuse"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": false,
"replaces": {},
"source_dirs": [],
"auto_start": false
}

618
example/localuse/plugin.go Normal file
View File

@ -0,0 +1,618 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
}
type Plugin struct {
name string
dataDir string
}
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)
if dd := s.Settings().DataDir(); dd != "" {
p.dataDir = dd
os.MkdirAll(p.dataDir, 0755)
}
// ── screensee ──
s.RegisterTool("local_screensee", sdk.ToolDef{
Name: "local_screensee",
Description: "截取本机屏幕当前画面(截屏)。" +
"返回图片的 base64 data URL,可直接用于视觉分析。" +
"依赖:Linux 需 scrot/import/gnome-screenshot 任一;macOS 需 screencapture(自带);Windows 用 PowerShell。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
// 只读观察:不改插件内共享状态
ParallelSafe: true,
}, p.handleScreensee)
// ── camerasue ──
s.RegisterTool("local_camerasue", sdk.ToolDef{
Name: "local_camerasue",
Description: "使用本机摄像头拍照或录像。" +
"无参数=拍照(jpeg),传入正整数=录像 N 秒(mp4)。" +
"依赖:ffmpeg + v4l2(Linux)/ dshow(Windows)/ avfoundation(macOS)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"duration": map[string]interface{}{
"type": "integer",
"description": "录像秒数(省略=拍照,传 0=拍照)",
},
},
},
// 只读观察:不改插件内共享状态
ParallelSafe: true,
}, p.handleCamerasue)
// ── speakeruse ──
s.RegisterTool("local_speakeruse", sdk.ToolDef{
Name: "local_speakeruse",
Description: "使用本机扬声器朗读指定文字(TTS)。" +
"依赖:Linux 需 espeak/festival 任一;macOS 需 say(自带);Windows 用 PowerShell SAPI。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"text": map[string]interface{}{
"type": "string",
"description": "要朗读的文字",
},
},
"required": []string{"text"},
},
// 只读观察:不改插件内共享状态
ParallelSafe: true,
}, p.handleSpeakeruse)
// ── screensue ──
s.RegisterTool("local_screensue", sdk.ToolDef{
Name: "local_screensue",
Description: "在本机屏幕上显示一段内容(通知/弹窗/HTML 页面)。" +
"适合在用户面前弹出提醒、会议倒计时等。" +
"Linux 用 xterm/browsh/w3m/notify-send;macOS 用 osascript;Windows 用 PowerShell 弹窗。" +
"参数可选 duration(秒,默认 5)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"content": map[string]interface{}{
"type": "string",
"description": "要显示的文字或 HTML",
},
"duration": map[string]interface{}{
"type": "integer",
"description": "显示时长秒数(默认 5,0=永不超时)",
},
},
"required": []string{"content"},
},
}, p.handleScreensue)
// ── clipboardsee ──
s.RegisterTool("local_clipboardsee", sdk.ToolDef{
Name: "local_clipboardsee",
Description: "读取本机剪切板当前内容(用户最近复制/剪切的文字)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
// 只读观察:不改插件内共享状态
ParallelSafe: true,
}, p.handleClipboardsee)
// ── clipboardsue ──
s.RegisterTool("local_clipboardsue", sdk.ToolDef{
Name: "local_clipboardsue",
Description: "将文字写入本机剪切板(用户随后可 Ctrl+V 粘贴)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"text": map[string]interface{}{
"type": "string",
"description": "要写入剪切板的文字",
},
},
"required": []string{"text"},
},
}, p.handleClipboardsue)
// ── computeruse ──
s.RegisterTool("local_computeruse", sdk.ToolDef{
Name: "local_computeruse",
Description: "操控本机鼠标/键盘。依赖 xdotool(Linux)/ PowerShell(Windows)。" +
"action 可选:click / doubleclick / rightclick / move / scroll / keypress / type。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string", "enum": []interface{}{
"click", "doubleclick", "rightclick", "move", "scroll", "keypress", "type",
},
},
"x": map[string]interface{}{"type": "integer", "description": "X 坐标(click/move 必填)"},
"y": map[string]interface{}{"type": "integer", "description": "Y 坐标(click/move 必填)"},
"button": map[string]interface{}{"type": "string", "description": "鼠标按键 left/right/middle"},
"dy": map[string]interface{}{"type": "integer", "description": "scroll 滚动量"},
"key": map[string]interface{}{"type": "string", "description": "keypress 按键名"},
"text": map[string]interface{}{"type": "string", "description": "type 要输入的文字"},
},
"required": []interface{}{"action"},
},
// 只读观察:不改插件内共享状态
ParallelSafe: true,
}, p.handleComputeruse)
return nil
}
func (p *Plugin) Stop() error { return nil }
// ===========================================================================
// 工具实现
// ===========================================================================
// ── screensee ──────────────────────────────────────────────────────────────
// psSingleQuote 生成 PowerShell **单引号字符串**的字面量内容。
//
// 单引号串里只有单引号本身需要写成两个连续单引号;反斜杠是普通字符,
// **不能加倍**(PowerShell 的转义字符是反引号而不是反斜杠)。早期代码把
// Windows 临时路径的反斜杠写成双写,会让截图保存到错误路径。
func psSingleQuote(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
// escapeAppleScriptString 转义 AppleScript 双引号字符串字面量。
//
// 顺序关键:**先转义反斜杠再转义双引号**。若反过来,刚插入的 `\"` 中的
// 反斜杠会被再转义一遍变成 `\\"`(反斜杠 + 未转义引号),内容可越出字符串。
func escapeAppleScriptString(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
return strings.ReplaceAll(s, `"`, `\"`)
}
func (p *Plugin) handleScreensee(args map[string]interface{}) (interface{}, error) {
platform := runtime.GOOS
tmpDir := os.TempDir()
outFile := filepath.Join(tmpDir, fmt.Sprintf("local_screenshot_%d.png", time.Now().UnixNano()))
defer os.Remove(outFile)
var cmd *exec.Cmd
switch platform {
case "linux":
if _, err := exec.LookPath("import"); err == nil {
cmd = exec.Command("import", "-window", "root", outFile)
} else if _, err := exec.LookPath("scrot"); err == nil {
cmd = exec.Command("scrot", outFile)
} else if _, err := exec.LookPath("gnome-screenshot"); err == nil {
cmd = exec.Command("gnome-screenshot", "-f", outFile)
} else {
return map[string]interface{}{"isError": true, "content": "无截图工具(安装 scrot/import/gnome-screenshot 任一)"}, nil
}
case "darwin":
cmd = exec.Command("screencapture", "-x", outFile)
case "windows":
psScript := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
$screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bitmap = New-Object Drawing.Bitmap $screen.Width, $screen.Height
$graphics = [Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($screen.X, $screen.Y, 0, 0, $screen.Size)
$bitmap.Save('%s', [Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bitmap.Dispose()
`, psSingleQuote(outFile))
cmd = exec.Command("powershell", "-NoProfile", "-Command", psScript)
default:
return map[string]interface{}{"isError": true, "content": "不支持的平台: " + platform}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
if out, err := cmd.CombinedOutput(); err != nil {
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("截屏失败: %v\n%s", err, string(out))}, nil
}
data, err := os.ReadFile(outFile)
if err != nil {
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("读取截图失败: %v", err)}, nil
}
// 大图保存到 dataDir(避免 agent 上下文爆掉),返回路径
if len(data) > 256*1024 && p.dataDir != "" {
srcFile := filepath.Join(p.dataDir, fmt.Sprintf("screenshot_%d.png", time.Now().UnixNano()))
if err := os.WriteFile(srcFile, data, 0644); err == nil {
return map[string]interface{}{
"content": fmt.Sprintf("截图已保存: %s (%d bytes)", srcFile, len(data)),
"local_path": srcFile,
"size": len(data),
}, nil
}
}
b64 := base64.StdEncoding.EncodeToString(data)
return map[string]interface{}{
"content": fmt.Sprintf("截图完成 (%d bytes)", len(data)),
"image_data_url": "data:image/png;base64," + b64,
}, nil
}
// ── camerasue ──────────────────────────────────────────────────────────────
func (p *Plugin) handleCamerasue(args map[string]interface{}) (interface{}, error) {
duration := 0
if d, ok := args["duration"].(float64); ok && d > 0 {
duration = int(d)
}
platform := runtime.GOOS
tmpDir := os.TempDir()
isVideo := duration > 0
outFile := filepath.Join(tmpDir, fmt.Sprintf("local_cam_%d.jpg", time.Now().UnixNano()))
if isVideo {
outFile = filepath.Join(tmpDir, fmt.Sprintf("local_cam_%d.mp4", time.Now().UnixNano()))
}
var cmd *exec.Cmd
switch platform {
case "linux":
if isVideo {
cmd = exec.Command("ffmpeg", "-f", "v4l2", "-i", "/dev/video0",
"-t", fmt.Sprintf("%d", duration),
"-pix_fmt", "yuv420p", "-c:v", "libx264",
"-f", "mp4", "-y", outFile)
} else {
cmd = exec.Command("ffmpeg", "-f", "v4l2", "-i", "/dev/video0",
"-frames:v", "1", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1")
}
case "windows":
if isVideo {
cmd = exec.Command("ffmpeg", "-f", "dshow", "-i", "video=USB Camera",
"-t", fmt.Sprintf("%d", duration),
"-pix_fmt", "yuv420p", "-c:v", "libx264",
"-f", "mp4", "-y", outFile)
} else {
cmd = exec.Command("ffmpeg", "-f", "dshow", "-i", "video=USB Camera",
"-frames:v", "1", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1")
}
default:
return map[string]interface{}{"isError": true, "content": "camerasue 不支持平台: " + platform}, nil
}
timeout := 15 * time.Second
if isVideo {
timeout = time.Duration(duration+15) * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if isVideo {
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
if err := cmd.Run(); err != nil {
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("摄像头录像失败: %v", err)}, nil
}
data, err := os.ReadFile(outFile)
os.Remove(outFile)
if err != nil {
return map[string]interface{}{"isError": true, "content": "读取录像失败"}, nil
}
if p.dataDir != "" {
srcFile := filepath.Join(p.dataDir, fmt.Sprintf("camera_%d.mp4", time.Now().UnixNano()))
if err := os.WriteFile(srcFile, data, 0644); err == nil {
return map[string]interface{}{
"content": fmt.Sprintf("录像已保存: %s (%d bytes, %ds)", srcFile, len(data), duration),
"local_path": srcFile,
"size": len(data),
}, nil
}
}
b64 := base64.StdEncoding.EncodeToString(data)
return map[string]interface{}{
"content": fmt.Sprintf("录像完成 (%d bytes, %ds)", len(data), duration),
"image_data_url": "data:video/mp4;base64," + b64,
}, nil
}
// 拍照:读 stdout JPEG
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = nil
cmd2 := exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
cmd2.Stdout = &out
if err := cmd2.Run(); err != nil {
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("摄像头拍照失败: %v", err)}, nil
}
b64 := base64.StdEncoding.EncodeToString(out.Bytes())
return map[string]interface{}{
"content": fmt.Sprintf("拍照完成 (%d bytes)", len(out.Bytes())),
"image_data_url": "data:image/jpeg;base64," + b64,
}, nil
}
// ── speakeruse ─────────────────────────────────────────────────────────────
func (p *Plugin) handleSpeakeruse(args map[string]interface{}) (interface{}, error) {
text, _ := args["text"].(string)
if strings.TrimSpace(text) == "" {
return map[string]interface{}{"isError": true, "content": "text 不能为空"}, nil
}
platform := runtime.GOOS
var cmd *exec.Cmd
switch platform {
case "linux":
if _, err := exec.LookPath("espeak"); err == nil {
cmd = exec.Command("espeak", text)
} else if _, err := exec.LookPath("festival"); err == nil {
cmd = exec.Command("festival", "--tts", "--pipe")
stdin, _ := cmd.StdinPipe()
go func() { defer stdin.Close(); stdin.Write([]byte(text)) }()
} else {
return map[string]interface{}{"isError": true, "content": "无 TTS 引擎(安装 espeak 或 festival)"}, nil
}
case "darwin":
cmd = exec.Command("say", text)
case "windows":
psScript := fmt.Sprintf(`
Add-Type -AssemblyName System.Speech
$synthesizer = New-Object System.Speech.Synthesis.SpeechSynthesizer
$synthesizer.Speak('%s')
`, psSingleQuote(text))
cmd = exec.Command("powershell", "-NoProfile", "-Command", psScript)
default:
return map[string]interface{}{"isError": true, "content": "speakeruse 不支持平台: " + platform}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
if err := cmd.Run(); err != nil {
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("朗读失败: %v", err)}, nil
}
return map[string]interface{}{"content": "朗读完成"}, nil
}
// ── screensue ──────────────────────────────────────────────────────────────
func (p *Plugin) handleScreensue(args map[string]interface{}) (interface{}, error) {
content, _ := args["content"].(string)
duration := 5
if d, ok := args["duration"].(float64); ok && d > 0 {
duration = int(d)
}
if strings.TrimSpace(content) == "" {
return map[string]interface{}{"isError": true, "content": "content 不能为空"}, nil
}
platform := runtime.GOOS
switch platform {
case "linux":
return p.screensueLinux(content, duration)
case "darwin":
// macOS 用 osascript 弹窗
psScript := fmt.Sprintf(`display dialog "%s" with title "HomeAgent" buttons {"OK"} default button "OK giving up after %d"`, escapeAppleScriptString(content), duration)
cmd := exec.Command("osascript", "-e", psScript)
_ = cmd.Run()
return map[string]interface{}{"content": "屏幕显示已触发"}, nil
case "windows":
escContent := psSingleQuote(content)
psScript := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
$form = New-Object System.Windows.Forms.Form
$form.Text = "HomeAgent"
$form.Size = New-Object Drawing.Size(500,300)
$form.StartPosition = "CenterScreen"
$form.TopMost = $true
$label = New-Object System.Windows.Forms.Label
$label.Text = '%s'
$label.AutoSize = $true
$label.Location = New-Object Drawing.Point(20,20)
$form.Controls.Add($label)
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = %d
$timer.Add_Tick({ $form.Close() })
$timer.Start()
[Windows.Forms.Application]::Run($form)
`, escContent, duration*1000)
cmd := exec.Command("powershell", "-NoProfile", "-Command", psScript)
_ = cmd.Run()
return map[string]interface{}{"content": "屏幕显示已触发"}, nil
default:
return map[string]interface{}{"isError": true, "content": "screensue 不支持平台: " + platform}, nil
}
}
func (p *Plugin) screensueLinux(content string, duration int) (interface{}, error) {
// 优先 xterm
if _, err := exec.LookPath("xterm"); err == nil {
// 写临时文件显示
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("local_screensue_%d.txt", time.Now().UnixNano()))
os.WriteFile(tmpFile, []byte(content), 0644)
defer os.Remove(tmpFile)
cmd := exec.Command("xterm", "-T", "HomeAgent", "-e", "cat", tmpFile)
_ = cmd.Start()
go func() { time.Sleep(time.Duration(duration) * time.Second); cmd.Process.Kill() }()
return map[string]interface{}{"content": "屏幕显示已触发(xterm)"}, nil
}
// 回退 notify-send
if _, err := exec.LookPath("notify-send"); err == nil {
_ = exec.Command("notify-send", "HomeAgent", content).Run()
return map[string]interface{}{"content": "通知已发送"}, nil
}
return map[string]interface{}{"isError": true, "content": "无可用显示方式(安装 xterm 或 libnotify-bin)"}, nil
}
// ── clipboardsee ───────────────────────────────────────────────────────────
func (p *Plugin) handleClipboardsee(args map[string]interface{}) (interface{}, error) {
platform := runtime.GOOS
switch platform {
case "linux":
if _, err := exec.LookPath("xclip"); err == nil {
out, _ := exec.Command("xclip", "-o", "-selection", "clipboard").Output()
return map[string]interface{}{"content": string(out)}, nil
}
if _, err := exec.LookPath("xsel"); err == nil {
out, _ := exec.Command("xsel", "-ob").Output()
return map[string]interface{}{"content": string(out)}, nil
}
return map[string]interface{}{"isError": true, "content": "无剪贴板工具(安装 xclip 或 xsel)"}, nil
case "darwin":
out, _ := exec.Command("pbpaste").Output()
return map[string]interface{}{"content": string(out)}, nil
case "windows":
ps := `Add-Type -AssemblyName System.Windows.Forms; [Windows.Forms.Clipboard]::GetText()`
out, _ := exec.Command("powershell", "-NoProfile", "-Command", ps).Output()
return map[string]interface{}{"content": strings.TrimSpace(string(out))}, nil
default:
return map[string]interface{}{"isError": true, "content": "clipboardsee 不支持: " + platform}, nil
}
}
// ── clipboardsue ───────────────────────────────────────────────────────────
func (p *Plugin) handleClipboardsue(args map[string]interface{}) (interface{}, error) {
text, _ := args["text"].(string)
if strings.TrimSpace(text) == "" {
return map[string]interface{}{"isError": true, "content": "text 不能为空"}, nil
}
platform := runtime.GOOS
switch platform {
case "linux":
if _, err := exec.LookPath("xclip"); err == nil {
cmd := exec.Command("xclip", "-i", "-selection", "clipboard")
cmd.Stdin = strings.NewReader(text)
_ = cmd.Run()
return map[string]interface{}{"content": "剪切板已写入"}, nil
}
if _, err := exec.LookPath("xsel"); err == nil {
cmd := exec.Command("xsel", "-ib")
cmd.Stdin = strings.NewReader(text)
_ = cmd.Run()
return map[string]interface{}{"content": "剪切板已写入"}, nil
}
return map[string]interface{}{"isError": true, "content": "无剪贴板工具"}, nil
case "darwin":
cmd := exec.Command("pbcopy")
cmd.Stdin = strings.NewReader(text)
_ = cmd.Run()
return map[string]interface{}{"content": "剪切板已写入"}, nil
case "windows":
ps := fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms; [Windows.Forms.Clipboard]::SetText('%s')`,
psSingleQuote(text))
_ = exec.Command("powershell", "-NoProfile", "-Command", ps).Run()
return map[string]interface{}{"content": "剪切板已写入"}, nil
default:
return map[string]interface{}{"isError": true, "content": "clipboardsue 不支持: " + platform}, nil
}
}
// ── computeruse ────────────────────────────────────────────────────────────
func (p *Plugin) handleComputeruse(args map[string]interface{}) (interface{}, error) {
action, _ := args["action"].(string)
if action == "" {
return map[string]interface{}{"isError": true, "content": "action 必填"}, nil
}
platform := runtime.GOOS
if platform == "linux" {
if _, err := exec.LookPath("xdotool"); err != nil {
return map[string]interface{}{"isError": true, "content": "computeruse 需要 xdotool(apt install xdotool)"}, nil
}
return p.computeruseLinux(action, args)
}
if platform == "windows" {
return p.computeruseWindows(action, args)
}
return map[string]interface{}{"isError": true, "content": "computeruse 不支持: " + platform}, nil
}
func (p *Plugin) computeruseLinux(action string, args map[string]interface{}) (interface{}, error) {
switch action {
case "click":
btn := "1"
if b, ok := args["button"].(string); ok {
switch b {
case "right":
btn = "3"
case "middle":
btn = "2"
}
}
_ = exec.Command("xdotool", "click", btn).Run()
case "doubleclick":
_ = exec.Command("xdotool", "click", "--repeat", "2", "1").Run()
case "rightclick":
_ = exec.Command("xdotool", "click", "3").Run()
case "move":
x, _ := args["x"].(float64)
y, _ := args["y"].(float64)
_ = exec.Command("xdotool", "mousemove", fmt.Sprintf("%d", int(x)), fmt.Sprintf("%d", int(y))).Run()
case "scroll":
dy, _ := args["dy"].(float64)
btn := "4"
if dy < 0 {
btn = "5"
}
_ = exec.Command("xdotool", "click", btn).Run()
case "type":
text, _ := args["text"].(string)
_ = exec.Command("xdotool", "type", text).Run()
case "keypress":
key, _ := args["key"].(string)
_ = exec.Command("xdotool", "key", key).Run()
default:
return map[string]interface{}{"isError": true, "content": "未知 action: " + action}, nil
}
return map[string]interface{}{"content": "操作已执行: " + action}, nil
}
func (p *Plugin) computeruseWindows(action string, args map[string]interface{}) (interface{}, error) {
switch action {
case "click", "move":
x, _ := args["x"].(float64)
y, _ := args["y"].(float64)
ps := fmt.Sprintf(`[System.Windows.Forms.Cursor]::Position = [System.Drawing.Point]::new(%d,%d)`, int(x), int(y))
_ = exec.Command("powershell", "-NoProfile", "-Command", ps).Run()
case "type":
text, _ := args["text"].(string)
ps := fmt.Sprintf(`$w = New-Object -ComObject wscript.shell; $w.SendKeys('%s')`, psSingleQuote(text))
_ = exec.Command("powershell", "-NoProfile", "-Command", ps).Run()
default:
return map[string]interface{}{"isError": true, "content": "Windows computeruse 暂不支持 " + action}, nil
}
return map[string]interface{}{"content": "操作已执行: " + action}, nil
}
// 确保正则在编译时生效(避免 unused)
var _ = regexp.MustCompile(`.*`)

View File

@ -0,0 +1,27 @@
---
name: localuse-tools
description: 本机外设能力:截屏/看屏、摄像头/麦克风、剪贴板读写、整机操控。按需加载:先 plugin_enable("localuse") 再调用。
---
# 本地外设
## 本插件是按需加载(auto_start: false)
外设能力只在被要求时才启动。用 `plugin_enable("localuse")` 拉起,
工具才会出现在清单里。
## 工具(see 前缀 = 只读观察;sue 前缀 = 主动操作)
| 工具 | 用途 |
|---|---|
| `local_screensee` | 截屏当前画面(返回图片) |
| `local_screensue` | 主动操作屏幕(点击/键入) |
| `local_camerasue` | 摄像头拍照 |
| `local_speakeruse` | 扬声器播放 |
| `local_clipboardsee` / `local_clipboardsue` | 剪贴板读 / 写 |
| `local_computeruse` | 整机操控 |
## 注意
- 涉及摄像头/麦克风/整机操控时,先向用户确认再执行。
- 依赖系统工具(Linux: scrot/import、gnome-screenshot 任一)。

13
example/mcp/README.md Normal file
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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()
}