Files
hexis/internal/provider/workspace_mcp.go
T
kami 3ac0fbb6d7 gofmt ToolMapping struct alignment
The review flagged three files as unformatted at HEAD. systemd.go was deleted
and handler.go/client.go were reformatted as a side effect of being edited;
this is the remaining realignment. No behaviour change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uea55zaiWuEByEDC4UBSdd
2026-07-30 23:40:45 +04:00

272 lines
7.3 KiB
Go

package provider
import (
"bytes"
"context"
"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) Execute(ctx context.Context, 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)
// The caller's context carries the capability timeout. Building the
// request with it means a cancelled execution tears down the in-flight
// HTTP call instead of leaving it to run to the client's own (much
// longer) timeout.
httpReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
fmt.Sprintf("%s/api/tool/%s", p.baseURL, toolName),
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("build workspace tool request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(httpReq)
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
}
// workspace-mcp catches tool-handler exceptions and reports them as an
// ERROR-coded warning inside a 200 response envelope rather than an HTTP
// error status, so a plain status-code check would report every such
// failure as a successful execution. Surface it as an error here so it
// maps to Hexis's failed execution status instead.
if envelopeErr := workspaceEnvelopeError(result); envelopeErr != "" {
return map[string]any{"result": result}, fmt.Errorf("workspace tool %q reported an error: %s", toolName, envelopeErr)
}
return map[string]any{
"result": result,
}, nil
}
func workspaceEnvelopeError(result any) string {
envelope, ok := result.(map[string]any)
if !ok {
return ""
}
warnings, ok := envelope["warnings"].([]any)
if !ok {
return ""
}
for _, w := range warnings {
warning, ok := w.(map[string]any)
if !ok {
continue
}
if code, _ := warning["code"].(string); code == "ERROR" {
message, _ := warning["message"].(string)
return message
}
}
return ""
}
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,
// Derived server-side from the risk tier, exactly as the HTTP
// registration path does (ECOSYSTEM-SPEC.md §4.3). Leaving these
// zero-valued registered every workspace capability disabled.
Enabled: domain.EnabledForRisk(mapping.Risk),
RequiresConfirmation: domain.RequiresConfirmationForRisk(mapping.Risk),
TimeoutSeconds: domain.DefaultCapabilityTimeoutSeconds,
CreatedAt: now,
UpdatedAt: now,
Version: 1,
})
}
return caps
}
func ifString(cond bool, a, b []string) []string {
if cond {
return a
}
return b
}