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:
207
internal/plugin/manifest_test.go
Normal file
207
internal/plugin/manifest_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseManifest_Valid(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"name": "git-tools",
|
||||
"version": "1.0.0",
|
||||
"description": "Git integration for gnoma",
|
||||
"author": "vikingowl",
|
||||
"capabilities": {
|
||||
"skills": ["skills/*.md"],
|
||||
"hooks": [
|
||||
{
|
||||
"name": "lint-before-commit",
|
||||
"event": "pre_tool_use",
|
||||
"type": "command",
|
||||
"exec": "scripts/lint.sh",
|
||||
"tool_pattern": "bash*"
|
||||
}
|
||||
],
|
||||
"mcp_servers": [
|
||||
{
|
||||
"name": "git",
|
||||
"command": "bin/mcp-git",
|
||||
"args": ["--verbose"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
m, err := ParseManifest(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifest: %v", err)
|
||||
}
|
||||
|
||||
if m.Name != "git-tools" {
|
||||
t.Errorf("Name = %q, want %q", m.Name, "git-tools")
|
||||
}
|
||||
if m.Version != "1.0.0" {
|
||||
t.Errorf("Version = %q, want %q", m.Version, "1.0.0")
|
||||
}
|
||||
if len(m.Capabilities.Skills) != 1 {
|
||||
t.Errorf("Skills count = %d, want 1", len(m.Capabilities.Skills))
|
||||
}
|
||||
if len(m.Capabilities.Hooks) != 1 {
|
||||
t.Errorf("Hooks count = %d, want 1", len(m.Capabilities.Hooks))
|
||||
}
|
||||
if len(m.Capabilities.MCPServers) != 1 {
|
||||
t.Errorf("MCPServers count = %d, want 1", len(m.Capabilities.MCPServers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_Minimal(t *testing.T) {
|
||||
data := []byte(`{"name": "minimal", "version": "0.1.0"}`)
|
||||
m, err := ParseManifest(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseManifest: %v", err)
|
||||
}
|
||||
if m.Name != "minimal" {
|
||||
t.Errorf("Name = %q", m.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_InvalidJSON(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`not json`))
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifest_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
m Manifest
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
m: Manifest{Name: "my-plugin", Version: "1.0.0"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
m: Manifest{Version: "1.0.0"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid name uppercase",
|
||||
m: Manifest{Name: "MyPlugin", Version: "1.0.0"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid name starts with number",
|
||||
m: Manifest{Name: "1plugin", Version: "1.0.0"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty version",
|
||||
m: Manifest{Name: "my-plugin"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid version",
|
||||
m: Manifest{Name: "my-plugin", Version: "not-semver"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "skill glob path traversal",
|
||||
m: Manifest{
|
||||
Name: "bad",
|
||||
Version: "1.0.0",
|
||||
Capabilities: Capabilities{
|
||||
Skills: []string{"../../../etc/passwd"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "skill glob absolute path",
|
||||
m: Manifest{
|
||||
Name: "bad",
|
||||
Version: "1.0.0",
|
||||
Capabilities: Capabilities{
|
||||
Skills: []string{"/etc/passwd"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hook exec path traversal",
|
||||
m: Manifest{
|
||||
Name: "bad",
|
||||
Version: "1.0.0",
|
||||
Capabilities: Capabilities{
|
||||
Hooks: []HookSpec{{
|
||||
Name: "h",
|
||||
Event: "pre_tool_use",
|
||||
Type: "command",
|
||||
Exec: "../../../bin/evil",
|
||||
}},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mcp command path traversal",
|
||||
m: Manifest{
|
||||
Name: "bad",
|
||||
Version: "1.0.0",
|
||||
Capabilities: Capabilities{
|
||||
MCPServers: []MCPServerSpec{{
|
||||
Name: "evil",
|
||||
Command: "../../../bin/evil",
|
||||
}},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid name with hyphens and numbers",
|
||||
m: Manifest{Name: "my-plugin-2", Version: "0.1.0"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid name with underscores",
|
||||
m: Manifest{Name: "my_plugin", Version: "0.1.0"},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.m.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidSemver(t *testing.T) {
|
||||
tests := []struct {
|
||||
v string
|
||||
want bool
|
||||
}{
|
||||
{"1.0.0", true},
|
||||
{"0.1.0", true},
|
||||
{"12.34.56", true},
|
||||
{"1.0", false},
|
||||
{"1", false},
|
||||
{"v1.0.0", false},
|
||||
{"1.0.0-beta", false}, // strict semver only for v1
|
||||
{"", false},
|
||||
{"not-a-version", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.v, func(t *testing.T) {
|
||||
if got := validSemver(tt.v); got != tt.want {
|
||||
t.Errorf("validSemver(%q) = %v, want %v", tt.v, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user