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 }