Files
hexis/internal/mcp/adapter.go
T
kami 74b19e091e Wire hexis.resolve_target to real Nexus, fix changes cursor, pass full execute fields over MCP
resolve_target previously returned a hardcoded "requires_nexus_resolution"
placeholder; it now calls Nexus's /api/v1/resolve via a new minimal
internal/nexusclient, configurable with -nexus (default localhost:8987).

/api/v1/changes ignored the since query param and always returned from
sequence 0 (`since = 0` regardless of what was parsed) — fixed to actually
parse and use it, so change-cursor polling works.

The MCP hexis.execute tool only forwarded capability_id/target_entity_id/
arguments/idempotency_key, silently dropping entity_version, requested_by,
origin, correlation_id, causation_id, resolution_evidence, and
confirmation_id even though the native HTTP API and domain.ExecuteRequest
already supported all of them — MCP callers now get full parity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA
2026-07-20 11:28:13 +04:00

373 lines
11 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/kami/hexis/internal/domain"
"github.com/kami/hexis/internal/execution"
"github.com/kami/hexis/internal/nexusclient"
"github.com/kami/hexis/internal/storage"
)
type Adapter struct {
server *server.MCPServer
store storage.Interface
engine *execution.Engine
nexus nexusclient.Client
}
func New(store storage.Interface, engine *execution.Engine, nexus nexusclient.Client) *Adapter {
a := &Adapter{
store: store,
engine: engine,
nexus: nexus,
}
mcpServer := server.NewMCPServer(
"hexis",
"1.0.0",
server.WithResourceCapabilities(true, true),
server.WithToolCapabilities(true),
)
mcpServer.AddTool(mcp.NewTool("hexis.list_capabilities",
mcp.WithDescription("List capabilities, optionally filtered by entity_id"),
mcp.WithString("entity_id",
mcp.Description("Optional entity ID to filter capabilities"),
),
), a.handleListCapabilities)
mcpServer.AddTool(mcp.NewTool("hexis.inspect_capability",
mcp.WithDescription("Get capability details by ID"),
mcp.WithString("capability_id",
mcp.Description("Capability ID"),
mcp.Required(),
),
), a.handleInspectCapability)
mcpServer.AddTool(mcp.NewTool("hexis.resolve_target",
mcp.WithDescription("Resolve a free-text target to a canonical entity ID via Nexus"),
mcp.WithString("query",
mcp.Description("Free-text query (name, alias, path)"),
mcp.Required(),
),
mcp.WithString("capability",
mcp.Description("Capability name to filter target types"),
),
), a.handleResolveTarget)
mcpServer.AddTool(mcp.NewTool("hexis.execute",
mcp.WithDescription("Execute a capability against a target entity"),
mcp.WithString("capability_id",
mcp.Description("Capability ID"),
mcp.Required(),
),
mcp.WithString("target_entity_id",
mcp.Description("Canonical entity ID of the target"),
mcp.Required(),
),
mcp.WithString("arguments",
mcp.Description("JSON string of execution arguments"),
),
mcp.WithString("idempotency_key",
mcp.Description("Idempotency key for safe retry"),
),
mcp.WithString("entity_version",
mcp.Description("Expected Nexus entity version, for optimistic concurrency"),
),
mcp.WithString("requested_by",
mcp.Description("JSON string describing the requester (e.g. {\"type\":\"user\",\"id\":\"...\"})"),
),
mcp.WithString("origin",
mcp.Description("JSON string describing the request origin (e.g. {\"system\":\"maven\",\"channel\":\"voice\"})"),
),
mcp.WithString("correlation_id",
mcp.Description("Correlation ID for cross-system tracing"),
),
mcp.WithString("causation_id",
mcp.Description("Causation ID of the event/request that triggered this execute"),
),
mcp.WithString("resolution_evidence",
mcp.Description("JSON array of evidence objects backing target_entity_id's resolution"),
),
mcp.WithString("confirmation_id",
mcp.Description("Confirmation ID for capabilities that require confirmation"),
),
), a.handleExecute)
mcpServer.AddTool(mcp.NewTool("hexis.execution_status",
mcp.WithDescription("Get execution status by ID"),
mcp.WithString("execution_id",
mcp.Description("Execution ID"),
mcp.Required(),
),
), a.handleExecutionStatus)
mcpServer.AddResource(mcp.NewResource("hexis://capabilities",
"All capabilities",
mcp.WithMIMEType("application/json"),
), a.handleCapabilitiesResource)
mcpServer.AddResourceTemplate(
mcp.NewResourceTemplate("hexis://capabilities/{id}", "Capability by ID"),
a.handleCapabilityResourceTemplate,
)
mcpServer.AddResourceTemplate(
mcp.NewResourceTemplate("hexis://executions/{id}", "Execution by ID"),
a.handleExecutionResourceTemplate,
)
a.server = mcpServer
return a
}
func (a *Adapter) ServeStdio() error {
return server.ServeStdio(a.server)
}
func (a *Adapter) MCPServer() *server.MCPServer {
return a.server
}
func (a *Adapter) handleListCapabilities(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
entityID := req.GetString("entity_id", "")
caps, err := a.store.ListCapabilities(entityID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("list capabilities: %v", err)), nil
}
var result []map[string]any
for _, c := range caps {
result = append(result, map[string]any{
"id": c.ID,
"name": c.Name,
"description": c.Description,
"target_types": c.TargetTypes,
"target_entity_id": c.TargetEntityID,
"provider": c.Provider,
"operation": c.Operation,
"risk": c.Risk,
"read_only": c.ReadOnly,
})
}
data, _ := json.MarshalIndent(result, "", " ")
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{Type: "text", Text: string(data)},
},
}, nil
}
func (a *Adapter) handleInspectCapability(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
capID := req.GetString("capability_id", "")
if capID == "" {
return mcp.NewToolResultError("capability_id is required"), nil
}
cap, err := a.store.GetCapability(capID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("capability not found: %v", err)), nil
}
data, _ := json.MarshalIndent(cap, "", " ")
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{Type: "text", Text: string(data)},
},
}, nil
}
func (a *Adapter) handleResolveTarget(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
query := req.GetString("query", "")
if query == "" {
return mcp.NewToolResultError("query is required"), nil
}
if a.nexus == nil {
return mcp.NewToolResultError("resolve_target unavailable: no Nexus URL configured"), nil
}
var types []string
if capName := req.GetString("capability", ""); capName != "" {
if caps, err := a.store.ListCapabilities(""); err == nil {
for _, c := range caps {
if c.Name == capName {
types = c.TargetTypes
break
}
}
}
}
result, err := a.nexus.Resolve(ctx, query, types)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("nexus resolve: %v", err)), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{Type: "text", Text: string(data)},
},
}, nil
}
func (a *Adapter) handleExecute(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
capID := req.GetString("capability_id", "")
targetID := req.GetString("target_entity_id", "")
argsStr := req.GetString("arguments", "")
idempKey := req.GetString("idempotency_key", "")
if capID == "" || targetID == "" {
return mcp.NewToolResultError("capability_id and target_entity_id are required"), nil
}
args := map[string]any{}
if argsStr != "" {
json.Unmarshal([]byte(argsStr), &args)
}
var entityVersion int64
if v := req.GetString("entity_version", ""); v != "" {
parsed, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("entity_version must be an integer: %v", err)), nil
}
entityVersion = parsed
}
var requestedBy map[string]string
if v := req.GetString("requested_by", ""); v != "" {
if err := json.Unmarshal([]byte(v), &requestedBy); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("requested_by must be a JSON object: %v", err)), nil
}
}
var origin map[string]string
if v := req.GetString("origin", ""); v != "" {
if err := json.Unmarshal([]byte(v), &origin); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("origin must be a JSON object: %v", err)), nil
}
}
var resolutionEvidence []map[string]any
if v := req.GetString("resolution_evidence", ""); v != "" {
if err := json.Unmarshal([]byte(v), &resolutionEvidence); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("resolution_evidence must be a JSON array: %v", err)), nil
}
}
execReq := &domain.ExecuteRequest{
CapabilityID: capID,
TargetEntityID: targetID,
EntityVersion: entityVersion,
Arguments: args,
RequestedBy: requestedBy,
Origin: origin,
IdempotencyKey: idempKey,
CorrelationID: req.GetString("correlation_id", ""),
CausationID: req.GetString("causation_id", ""),
ResolutionEvidence: resolutionEvidence,
ConfirmationID: req.GetString("confirmation_id", ""),
}
result, err := a.engine.Execute(execReq)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("execution failed: %v", err)), nil
}
data, _ := json.MarshalIndent(result.Execution, "", " ")
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{Type: "text", Text: string(data)},
},
}, nil
}
func (a *Adapter) handleExecutionStatus(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
execID := req.GetString("execution_id", "")
if execID == "" {
return mcp.NewToolResultError("execution_id is required"), nil
}
exec, err := a.store.GetExecution(execID)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("execution not found: %v", err)), nil
}
data, _ := json.MarshalIndent(exec, "", " ")
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{Type: "text", Text: string(data)},
},
}, nil
}
func (a *Adapter) handleCapabilitiesResource(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
caps, err := a.store.ListCapabilities("")
if err != nil {
return nil, err
}
data, _ := json.MarshalIndent(caps, "", " ")
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: "hexis://capabilities",
MIMEType: "application/json",
Text: string(data),
},
}, nil
}
func (a *Adapter) handleCapabilityResourceTemplate(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
uri := req.Params.URI
id := strings.TrimPrefix(uri, "hexis://capabilities/")
if id == "" {
return nil, fmt.Errorf("invalid capability URI: %s", uri)
}
cap, err := a.store.GetCapability(id)
if err != nil {
return nil, fmt.Errorf("capability %s: %w", id, err)
}
data, _ := json.MarshalIndent(cap, "", " ")
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: uri,
MIMEType: "application/json",
Text: string(data),
},
}, nil
}
func (a *Adapter) handleExecutionResourceTemplate(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
uri := req.Params.URI
id := strings.TrimPrefix(uri, "hexis://executions/")
if id == "" {
return nil, fmt.Errorf("invalid execution URI: %s", uri)
}
exec, err := a.store.GetExecution(id)
if err != nil {
return nil, fmt.Errorf("execution %s: %w", id, err)
}
data, _ := json.MarshalIndent(exec, "", " ")
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: uri,
MIMEType: "application/json",
Text: string(data),
},
}, nil
}