feat(m8): MCP client, tool replaceability, and plugin system
Complete the remaining M8 extensibility deliverables:
- MCP client with JSON-RPC 2.0 over stdio transport, protocol
lifecycle (initialize/tools-list/tools-call), and process group
management for clean shutdown
- MCP tool adapter implementing tool.Tool with mcp__{server}__{tool}
naming convention and replace_default for swapping built-in tools
- MCP manager for multi-server orchestration with parallel startup,
tool discovery, and registry integration
- Plugin system with plugin.json manifest (name/version/capabilities),
directory-based discovery (global + project scopes with precedence),
loader that merges skills/hooks/MCP configs into existing registries,
and install/uninstall/list lifecycle manager
- Config additions: MCPServerConfig, PluginsSection with opt-in/opt-out
enabled/disabled resolution
- TUI /plugins command for listing installed plugins
- 54 tests across internal/mcp and internal/plugin packages
This commit is contained in:
156
internal/plugin/loader.go
Normal file
156
internal/plugin/loader.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/config"
|
||||
)
|
||||
|
||||
// Plugin is a discovered, parsed plugin.
|
||||
type Plugin struct {
|
||||
Manifest Manifest
|
||||
Dir string // absolute path to plugin directory
|
||||
Scope string // "user" or "project"
|
||||
}
|
||||
|
||||
// SkillSource is a directory + source tag for skill.Registry.LoadDir.
|
||||
type SkillSource struct {
|
||||
Dir string
|
||||
Source string
|
||||
}
|
||||
|
||||
// LoadResult contains the merged capabilities from all loaded plugins.
|
||||
type LoadResult struct {
|
||||
Skills []SkillSource
|
||||
Hooks []config.HookConfig
|
||||
MCPServers []config.MCPServerConfig
|
||||
}
|
||||
|
||||
// Loader discovers and loads plugins from directories.
|
||||
type Loader struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewLoader creates a plugin loader.
|
||||
func NewLoader(logger *slog.Logger) *Loader {
|
||||
return &Loader{logger: logger}
|
||||
}
|
||||
|
||||
// Discover scans global and project plugin directories, returning all valid plugins.
|
||||
// Project-scoped plugins override same-name global plugins.
|
||||
func (l *Loader) Discover(globalDir, projectDir string) ([]Plugin, error) {
|
||||
byName := make(map[string]Plugin)
|
||||
|
||||
// Global plugins first (user scope).
|
||||
l.scanDir(globalDir, "user", byName)
|
||||
|
||||
// Project plugins override global.
|
||||
l.scanDir(projectDir, "project", byName)
|
||||
|
||||
plugins := make([]Plugin, 0, len(byName))
|
||||
for _, p := range byName {
|
||||
plugins = append(plugins, p)
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
// Load processes enabled plugins and extracts their capabilities.
|
||||
func (l *Loader) Load(plugins []Plugin, enabledSet map[string]bool) (LoadResult, error) {
|
||||
var result LoadResult
|
||||
|
||||
for _, p := range plugins {
|
||||
if !enabledSet[p.Manifest.Name] {
|
||||
l.logger.Debug("plugin disabled, skipping", "name", p.Manifest.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
l.logger.Debug("loading plugin", "name", p.Manifest.Name, "scope", p.Scope)
|
||||
|
||||
// Skills: resolve glob directories.
|
||||
for _, glob := range p.Manifest.Capabilities.Skills {
|
||||
// Use the directory portion of the glob as the skill source dir.
|
||||
skillDir := filepath.Join(p.Dir, filepath.Dir(glob))
|
||||
result.Skills = append(result.Skills, SkillSource{
|
||||
Dir: skillDir,
|
||||
Source: fmt.Sprintf("plugin:%s", p.Manifest.Name),
|
||||
})
|
||||
}
|
||||
|
||||
// Hooks: convert to config.HookConfig with resolved paths.
|
||||
for _, h := range p.Manifest.Capabilities.Hooks {
|
||||
execPath := h.Exec
|
||||
if execPath != "" && !filepath.IsAbs(execPath) {
|
||||
execPath = filepath.Join(p.Dir, execPath)
|
||||
}
|
||||
result.Hooks = append(result.Hooks, config.HookConfig{
|
||||
Name: h.Name,
|
||||
Event: h.Event,
|
||||
Type: h.Type,
|
||||
Exec: execPath,
|
||||
Timeout: h.Timeout,
|
||||
FailOpen: h.FailOpen,
|
||||
ToolPattern: h.ToolPattern,
|
||||
})
|
||||
}
|
||||
|
||||
// MCP servers: convert with resolved command paths.
|
||||
for _, s := range p.Manifest.Capabilities.MCPServers {
|
||||
cmd := s.Command
|
||||
if cmd != "" && !filepath.IsAbs(cmd) {
|
||||
cmd = filepath.Join(p.Dir, cmd)
|
||||
}
|
||||
result.MCPServers = append(result.MCPServers, config.MCPServerConfig{
|
||||
Name: s.Name,
|
||||
Command: cmd,
|
||||
Args: s.Args,
|
||||
Env: s.Env,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (l *Loader) scanDir(dir, scope string, byName map[string]Plugin) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
// Missing directory is fine (not all users have plugins).
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
pluginDir := filepath.Join(dir, entry.Name())
|
||||
manifestPath := filepath.Join(pluginDir, "plugin.json")
|
||||
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
l.logger.Debug("skipping plugin dir (no manifest)", "dir", pluginDir)
|
||||
continue
|
||||
}
|
||||
|
||||
manifest, err := ParseManifest(data)
|
||||
if err != nil {
|
||||
l.logger.Warn("skipping plugin (invalid manifest)", "dir", pluginDir, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
byName[manifest.Name] = Plugin{
|
||||
Manifest: *manifest,
|
||||
Dir: pluginDir,
|
||||
Scope: scope,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// marshalJSON is a thin wrapper for tests.
|
||||
func marshalJSON(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
Reference in New Issue
Block a user