74b19e091e
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
373 lines
11 KiB
Go
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
|
|
}
|