feat(plugin): 插件热安装(MCP server) — POST /admin/plugins/install 连接+发现+注册工具进registry+落盘plugins.json
- capability/plugin/manager.go: Manager(Install/Uninstall/List/Restore/Close); Install=连接+发现+mcp:name注册工具,失败不落盘; holder持有*mcp.Registry; boot Restore重装
- config/runtimeconfig/plugins.go: PluginConfig(Normalize/Validate), LoadPlugins/SavePlugins(0600+tmp+rename)/Upsert/Delete/Masked(apiKey+敏感env掩码)
- mcp: MCPConfig加APIKey/Env, http/sse Bearer应用, stdio子进程env合并; registry.ToolNames()
- gateway: adminPluginInstall/Uninstall/ListInstalledPlugins; Deps.Plugins注入
- cmd/cyrene/plugins.go: wirePlugins(boot Restore+close)
- 契约: body{kind:mcp,name,config{transport, endpoint?/command?,args?,apiKey?,env?}} → {installed,name,kind,transport,toolCount}; 失败502不落盘/非法400/未注入501; 卸载连带unregister+删配置
- 测试(manager/plugins/admin/mcp_auth/组合根), build/vet/test绿+3平台交叉编译
- Docs/api-client v0.28 + api-reference install=501改真实; .gitignore加plugins.json
- 前端api.ts仍发旧body(无config)会400, 面板安装表单待后续
This commit is contained in:
1 parent
53002f1e11
commit
70c9fba529
20 files changed
+1885
-57
No files matched your search
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// wirePlugins builds the runtime plugin manager over the capability registry and
|
||||
// the gitignored plugins.json, then restores every persisted MCP server so an
|
||||
// installed plugin survives a restart. A server that is unreachable at boot is
|
||||
// logged and left in plugins.json (the owner can retry or uninstall it); boot
|
||||
// never fails because an external server is down.
|
||||
func (a *app) wirePlugins(ctx context.Context) {
|
||||
path := runtimeconfig.DefaultPluginsPath(resolveDataDir(a.cfg))
|
||||
a.plugins = pluginmgr.NewManager(a.caps, path, logger.WithSource(a.log, "capability/plugin"))
|
||||
a.plugins.Restore(ctx)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// pluginFakeMCP is a minimal Streamable HTTP MCP server for the composition-root
|
||||
// test: it answers initialize and tools/list over POST.
|
||||
type pluginFakeMCP struct{ tools []string }
|
||||
|
||||
func (f pluginFakeMCP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if fl, ok := w.(http.Flusher); ok {
|
||||
fl.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
if len(req.ID) == 0 || string(req.ID) == "null" {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
var result any
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
result = map[string]any{"protocolVersion": "2025-11-25", "capabilities": map[string]any{}, "serverInfo": map[string]any{"name": "fake", "version": "1.0"}}
|
||||
case "tools/list":
|
||||
list := make([]map[string]any, 0, len(f.tools))
|
||||
for _, name := range f.tools {
|
||||
list = append(list, map[string]any{"name": name, "description": name, "inputSchema": map[string]any{"type": "object"}})
|
||||
}
|
||||
result = map[string]any{"tools": list}
|
||||
default:
|
||||
result = map[string]any{}
|
||||
}
|
||||
resp, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID), "result": result})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(resp)
|
||||
}
|
||||
|
||||
// TestWirePlugins_RestoresPersistedServer proves the composition root builds the
|
||||
// plugin manager over the resolved data dir (plugins.json) and reinstalls a
|
||||
// persisted MCP server at boot, so a panel install survives a restart.
|
||||
func TestWirePlugins_RestoresPersistedServer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mcpSrv := httptest.NewServer(pluginFakeMCP{tools: []string{"add"}})
|
||||
defer mcpSrv.Close()
|
||||
plugins := []runtimeconfig.PluginConfig{{
|
||||
ID: "p1",
|
||||
Name: "calc",
|
||||
Kind: runtimeconfig.PluginKindMCP,
|
||||
Transport: runtimeconfig.TransportHTTP,
|
||||
Endpoint: mcpSrv.URL,
|
||||
CreatedAt: "2026-09-08T00:00:00Z",
|
||||
}}
|
||||
raw, err := json.Marshal(plugins)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "plugins.json"), raw, 0o600); err != nil {
|
||||
t.Fatalf("write plugins.json: %v", err)
|
||||
}
|
||||
|
||||
caps := registry.New()
|
||||
a := &app{caps: caps, cfg: &config.Config{DataDir: dir}, log: testLog()}
|
||||
a.wirePlugins(context.Background())
|
||||
if a.plugins == nil {
|
||||
t.Fatal("plugins manager not wired")
|
||||
}
|
||||
defer a.plugins.Close(context.Background())
|
||||
|
||||
if _, err := caps.Get("add"); err != nil {
|
||||
t.Fatalf("restored tool add not registered: %v", err)
|
||||
}
|
||||
if _, err := caps.Get("mcp:calc"); err != nil {
|
||||
t.Fatalf("mcp:calc capability not registered: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
@@ -93,6 +94,7 @@ type app struct {
|
||||
iot *iot.Manager
|
||||
voice *voice.Chain
|
||||
mcpRegs []*mcp.Registry
|
||||
plugins *pluginmgr.Manager
|
||||
sessions *session.SessionManager
|
||||
automation *automations.Service
|
||||
backthink *backthink.Backthink
|
||||
@@ -214,6 +216,7 @@ func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, er
|
||||
if err := a.wireAuxiliary(); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.wirePlugins(ctx)
|
||||
if err := a.wireChannels(); err != nil {
|
||||
return a, err
|
||||
}
|
||||
@@ -690,6 +693,7 @@ func (a *app) wireFrontend() error {
|
||||
RAG: ragStore,
|
||||
LLM: a.llm,
|
||||
Channels: a.channels,
|
||||
Plugins: a.plugins,
|
||||
OwnerRegistrar: &ownerRegistrar{path: defaultOwnerPath, owner: a.owner, log: a.log},
|
||||
AuditStore: auditStore,
|
||||
Streaming: true,
|
||||
@@ -784,6 +788,9 @@ func (a *app) close(ctx context.Context) {
|
||||
for _, reg := range a.mcpRegs {
|
||||
_ = reg.Unregister(ctx)
|
||||
}
|
||||
if a.plugins != nil {
|
||||
a.plugins.Close(ctx)
|
||||
}
|
||||
if a.tasks != nil {
|
||||
_ = a.tasks.Close()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user