Files
hexis/internal/provider/workspace_mcp.go
T
kami cca63269e1 Initial commit: Hexis capability registry + execution service baseline
Go daemon (hexisd/hexisctl) implementing capability registry, guarded
execution (confirmations, blessed-entity checks), systemd/workspace-mcp
providers per ECOSYSTEM-SPEC.md. Snapshotting existing working state
before further development.
2026-07-20 00:50:37 +04:00

229 lines
5.6 KiB
Go

package provider
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/kami/hexis/internal/domain"
"gopkg.in/yaml.v3"
)
func LoadToolAllowlist(path string) (ToolAllowlist, error) {
data, err := os.ReadFile(path)
if err != nil {
return ToolAllowlist{}, fmt.Errorf("read allowlist: %w", err)
}
var allowlist ToolAllowlist
if err := yaml.Unmarshal(data, &allowlist); err != nil {
return ToolAllowlist{}, fmt.Errorf("parse allowlist: %w", err)
}
if allowlist.Tools == nil {
allowlist.Tools = map[string]ToolMapping{}
}
return allowlist, nil
}
type WorkspaceMCPProvider struct {
mu sync.RWMutex
baseURL string
httpClient *http.Client
tools []WorkspaceTool
allowlist ToolAllowlist
}
type WorkspaceTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema any `json:"inputSchema"`
}
type ToolAllowlist struct {
Tools map[string]ToolMapping `yaml:"tools" json:"tools"`
}
type ToolMapping struct {
Capability string `yaml:"capability" json:"capability"`
Risk string `yaml:"risk" json:"risk"`
TargetType string `yaml:"target_type,omitempty" json:"target_type,omitempty"`
ReadOnly bool `yaml:"read_only" json:"read_only"`
SideEffects string `yaml:"side_effects,omitempty" json:"side_effects,omitempty"`
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
AllowParams []string `yaml:"allow_params,omitempty" json:"allow_params,omitempty"`
}
func (r *ToolAllowlist) IsEnabled(name string) bool {
m, ok := r.Tools[name]
if !ok {
return false
}
if m.Enabled != nil && !*m.Enabled {
return false
}
return true
}
func (r *ToolAllowlist) Mapping(name string) (ToolMapping, bool) {
m, ok := r.Tools[name]
return m, ok
}
func NewWorkspaceMCPProvider(baseURL string, allowlist ToolAllowlist) *WorkspaceMCPProvider {
return &WorkspaceMCPProvider{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
allowlist: allowlist,
}
}
func (p *WorkspaceMCPProvider) Name() string {
return "workspace_mcp"
}
func (p *WorkspaceMCPProvider) DiscoverTools() ([]WorkspaceTool, error) {
resp, err := p.httpClient.Get(fmt.Sprintf("%s/api/tools", p.baseURL))
if err != nil {
return nil, fmt.Errorf("discover workspace tools: %w", err)
}
defer resp.Body.Close()
var result struct {
Tools []WorkspaceTool `json:"tools"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode workspace tools: %w", err)
}
p.mu.Lock()
p.tools = result.Tools
p.mu.Unlock()
return result.Tools, nil
}
func (p *WorkspaceMCPProvider) DiscoveredTools() []WorkspaceTool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.tools
}
func (p *WorkspaceMCPProvider) Execute(capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error) {
toolName := p.capabilityToTool(capability.Name)
if toolName == "" {
return nil, fmt.Errorf("no workspace tool mapped for capability %q", capability.Name)
}
mapping, ok := p.allowlist.Mapping(toolName)
if !ok {
return nil, fmt.Errorf("tool %q not in allowlist", toolName)
}
if !p.allowlist.IsEnabled(toolName) {
return nil, fmt.Errorf("tool %q is disabled in allowlist", toolName)
}
args := map[string]any{}
if req.Arguments != nil {
if len(mapping.AllowParams) > 0 {
for k, v := range req.Arguments {
for _, allowed := range mapping.AllowParams {
if k == allowed {
args[k] = v
break
}
}
}
} else {
args = req.Arguments
}
}
body, _ := json.Marshal(args)
resp, err := p.httpClient.Post(
fmt.Sprintf("%s/api/tool/%s", p.baseURL, toolName),
"application/json",
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("call workspace tool %q: %w", toolName, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read workspace tool response: %w", err)
}
if resp.StatusCode >= 400 {
return map[string]any{
"error": string(respBody),
"http_status": resp.StatusCode,
}, fmt.Errorf("workspace tool %q returned %d: %s", toolName, resp.StatusCode, string(respBody))
}
var result any
if err := json.Unmarshal(respBody, &result); err != nil {
return map[string]any{
"raw": string(respBody),
}, nil
}
return map[string]any{
"result": result,
}, nil
}
func (p *WorkspaceMCPProvider) capabilityToTool(capName string) string {
for toolName, mapping := range p.allowlist.Tools {
if mapping.Capability == capName {
return toolName
}
}
return ""
}
func (p *WorkspaceMCPProvider) BuildCapabilities() []domain.Capability {
var caps []domain.Capability
for toolName, mapping := range p.allowlist.Tools {
if !p.allowlist.IsEnabled(toolName) {
continue
}
readOnly := mapping.ReadOnly
if mapping.Risk == "read" {
readOnly = true
}
now := time.Now().UTC()
caps = append(caps, domain.Capability{
ID: fmt.Sprintf("cap_ws_%s", strings.ReplaceAll(mapping.Capability, ".", "_")),
Name: mapping.Capability,
Description: fmt.Sprintf("Workspace tool: %s", toolName),
TargetTypes: ifString(mapping.TargetType != "", []string{mapping.TargetType}, nil),
TargetEntityID: "",
Provider: "workspace_mcp",
Operation: toolName,
Risk: mapping.Risk,
ReadOnly: readOnly,
ExpectedSideEffects: mapping.SideEffects,
CreatedAt: now,
UpdatedAt: now,
Version: 1,
})
}
return caps
}
func ifString(cond bool, a, b []string) []string {
if cond {
return a
}
return b
}