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/cmd/README.md
Normal file
13
example/cmd/README.md
Normal 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
9
example/cmd/go.mod
Normal 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
19
example/cmd/plg.json
Normal 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
302
example/cmd/plugin.go
Normal 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, ""
|
||||
}
|
||||
23
example/cmd/skill/cmd-tools/SKILL.md
Normal file
23
example/cmd/skill/cmd-tools/SKILL.md
Normal 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、写系统目录)先向用户确认。
|
||||
100
example/files/editline_test.go
Normal file
100
example/files/editline_test.go
Normal 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)) }
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "files",
|
||||
"name_zh": "文件系统",
|
||||
"name_en": "File System",
|
||||
"name_en": "files",
|
||||
"version": "1.0.0",
|
||||
"description": "文件系统操作工具集(读取/写入/编辑/列表),提供沙箱化文件访问,支持配置工作目录。",
|
||||
"author": "HomeAgent",
|
||||
@ -16,4 +16,4 @@
|
||||
"replaces": {},
|
||||
"source_dirs": [],
|
||||
"auto_start": false
|
||||
}
|
||||
}
|
||||
@ -124,6 +124,43 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
},
|
||||
}, 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{
|
||||
Name: tp + "ls",
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) {
|
||||
path, _ := args["path"].(string)
|
||||
|
||||
@ -22,7 +22,8 @@ plugin_start name=files
|
||||
|---|---|
|
||||
| `files_read` | 读文件内容 |
|
||||
| `files_write` | 写文件(覆盖) |
|
||||
| `files_edit` | 局部替换编辑 |
|
||||
| `files_edit` | 局部替换编辑(old 文本须全文件唯一) |
|
||||
| `files_edit_line` | **按行号**精确替换/插入/删除(目标文本不唯一时用它,如改第 N 个 `}`) |
|
||||
| `files_ls` | 列目录 |
|
||||
|
||||
## 用完可释放
|
||||
|
||||
12
example/localuse/README.md
Normal file
12
example/localuse/README.md
Normal 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
9
example/localuse/go.mod
Normal 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
19
example/localuse/plg.json
Normal 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
618
example/localuse/plugin.go
Normal 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(`.*`)
|
||||
27
example/localuse/skill/localuse-tools/SKILL.md
Normal file
27
example/localuse/skill/localuse-tools/SKILL.md
Normal 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
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