Files
gnoma/internal/mcp/tool.go
vikingowl d2d79d65da 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
2026-04-12 03:09:05 +02:00

114 lines
3.0 KiB
Go

package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"somegit.dev/Owlibou/gnoma/internal/tool"
)
// Adapter wraps an MCPTool as a gnoma tool.Tool.
type Adapter struct {
serverName string
mcpTool MCPTool
client *Client
overrideName string // non-empty when replacing a built-in
}
// Compile-time interface checks.
var (
_ tool.Tool = (*Adapter)(nil)
_ tool.DeferrableTool = (*Adapter)(nil)
)
// NewAdapter creates a tool adapter for the given MCP tool.
func NewAdapter(serverName string, mcpTool MCPTool, client *Client) *Adapter {
return &Adapter{
serverName: serverName,
mcpTool: mcpTool,
client: client,
}
}
// SetOverrideName sets a replacement name (used for replace_default).
func (a *Adapter) SetOverrideName(name string) {
a.overrideName = name
}
// Name returns the tool name. Uses mcp__{server}__{tool} convention,
// or the override name when replacing a built-in.
func (a *Adapter) Name() string {
if a.overrideName != "" {
return a.overrideName
}
return fmt.Sprintf("mcp__%s__%s", a.serverName, a.mcpTool.Name)
}
// Description returns the MCP tool's description.
func (a *Adapter) Description() string {
return a.mcpTool.Description
}
// Parameters returns the MCP tool's input schema (zero-copy passthrough).
func (a *Adapter) Parameters() json.RawMessage {
return a.mcpTool.InputSchema
}
// Execute calls the MCP server's tools/call method.
func (a *Adapter) Execute(ctx context.Context, args json.RawMessage) (tool.Result, error) {
result, err := a.client.CallTool(ctx, a.mcpTool.Name, args)
if err != nil {
// RPC errors are surfaced as tool output so the LLM can see them.
var rpcErr *RPCError
if errors.As(err, &rpcErr) {
return tool.Result{
Output: fmt.Sprintf("MCP error: %s", rpcErr.Message),
}, nil
}
// Transport-level errors are Go errors (broken pipe, timeout).
return tool.Result{}, err
}
output, err := extractTextContent(result)
if err != nil {
return tool.Result{
Output: fmt.Sprintf("MCP response parse error: %v\nRaw: %s", err, result),
}, nil
}
return tool.Result{Output: output}, nil
}
// IsReadOnly returns false conservatively — MCP tools may have side effects.
func (a *Adapter) IsReadOnly() bool { return false }
// IsDestructive returns false — use permission rules for granular control.
func (a *Adapter) IsDestructive() bool { return false }
// ShouldDefer returns true — MCP tools start deferred to reduce token overhead.
func (a *Adapter) ShouldDefer() bool { return true }
// extractTextContent concatenates text blocks from an MCP tools/call result.
func extractTextContent(raw json.RawMessage) (string, error) {
var result struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return "", err
}
var parts []string
for _, c := range result.Content {
if c.Type == "text" {
parts = append(parts, c.Text)
}
}
return strings.Join(parts, "\n"), nil
}