diff --git a/cmd/server/main.go b/cmd/server/main.go index e6d8305..0c6656b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -92,6 +92,14 @@ func main() { logger.WithField("tool_count", len(tools)).Info("mcp tools loaded") mcpProvider = mcpClient } + + // Pre-warm skill cache (non-fatal) + skills, err := mcpClient.ListSkills(mcpCtx) + if err != nil { + logger.WithError(err).Warn("failed to list mcp skills, continuing without skills") + } else { + logger.WithField("skill_count", len(skills)).Info("mcp skills loaded") + } } } diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 95e383d..c053b4d 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -44,6 +44,17 @@ func (e *jsonRPCError) Error() string { // MCP-specific types +// ToolError is returned when an MCP tool sets IsError: true. +// It carries the tool's text content so callers can still parse structured data from it. +type ToolError struct { + ToolName string + Text string +} + +func (e *ToolError) Error() string { + return fmt.Sprintf("mcp tool %s error: %s", e.ToolName, e.Text) +} + // MCPTool represents a tool definition from the MCP server. type MCPTool struct { Name string `json:"name"` @@ -91,14 +102,72 @@ func (tc *toolCache) set(tools []MCPTool) { tc.fetchedAt = time.Now() } +// MCP resource types (resources/list, resources/read) + +type resourceEntry struct { + URI string `json:"uri"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + MimeType string `json:"mimeType,omitempty"` +} + +type readResourceParams struct { + URI string `json:"uri"` +} + +type readResourceResult struct { + Contents []resourceContent `json:"contents"` +} + +type resourceContent struct { + URI string `json:"uri"` + MimeType string `json:"mimeType,omitempty"` + Text string `json:"text,omitempty"` +} + +// skillEntry is an MCP skill discovered via resources/list. +type skillEntry struct { + Slug string + Name string + Description string + URI string +} + +// skillCache holds cached skill metadata with a TTL. +type skillCache struct { + mu sync.RWMutex + skills []skillEntry + fetchedAt time.Time + ttl time.Duration +} + +func (sc *skillCache) get() ([]skillEntry, bool) { + sc.mu.RLock() + defer sc.mu.RUnlock() + if sc.skills == nil { + return nil, false + } + fresh := time.Since(sc.fetchedAt) < sc.ttl + return sc.skills, fresh +} + +func (sc *skillCache) set(skills []skillEntry) { + sc.mu.Lock() + defer sc.mu.Unlock() + sc.skills = skills + sc.fetchedAt = time.Now() +} + // Client is an MCP JSON-RPC 2.0 client using Streamable HTTP transport. type Client struct { - serverURL string - httpClient *http.Client - sessionID string - requestID atomic.Int64 - cache toolCache - logger *logrus.Logger + serverURL string + httpClient *http.Client + sessionID string + requestID atomic.Int64 + cache toolCache + skills skillCache + skillContent sync.Map // slug → string (cached skill markdown) + logger *logrus.Logger } // NewClient creates a new MCP client. @@ -109,6 +178,7 @@ func NewClient(serverURL string, cacheTTL time.Duration, logger *logrus.Logger) Timeout: 30 * time.Second, }, cache: toolCache{ttl: cacheTTL}, + skills: skillCache{ttl: cacheTTL}, logger: logger, } } @@ -316,10 +386,11 @@ func (c *Client) CallTool(ctx context.Context, name string, arguments json.RawMe if callResult.IsError { c.logger.WithFields(logrus.Fields{ - "mcp_tool": name, - "mcp_error": text, + "mcp_tool": name, + "mcp_error": text, }).Error("mcp tool returned error") - return "", fmt.Errorf("mcp tool error: %s", text) + // Return the text with a ToolError so callers can still access the content. + return text, &ToolError{ToolName: name, Text: text} } c.logger.WithFields(logrus.Fields{ @@ -402,3 +473,161 @@ func (c *Client) ToolDescriptions() string { c.logger.WithField("mcp_desc_len", len(desc)).Debug("mcp ToolDescriptions generated") return desc } + +// --------------------------------------------------------------------------- +// MCP Resources — skill discovery and loading +// --------------------------------------------------------------------------- + +// ListSkills fetches available skills from the MCP server via resources/list. +// Skills are resources with URIs matching "skills/*.md". +func (c *Client) ListSkills(ctx context.Context) ([]skillEntry, error) { + c.logger.Debug("mcp listing skills via resources/list") + + result, err := c.call(ctx, "resources/list", nil) + if err != nil { + if stale, _ := c.skills.get(); stale != nil { + c.logger.WithError(err).Warn("mcp resources/list failed, using stale skill cache") + return stale, nil + } + return nil, fmt.Errorf("list resources: %w", err) + } + + var listResult struct { + Resources []resourceEntry `json:"resources"` + } + if err := json.Unmarshal(result, &listResult); err != nil { + return nil, fmt.Errorf("unmarshal resources: %w", err) + } + + var skills []skillEntry + for _, r := range listResult.Resources { + slug := extractSkillSlug(r.URI) + if slug == "" { + continue + } + skills = append(skills, skillEntry{ + Slug: slug, + Name: r.Name, + Description: r.Description, + URI: r.URI, + }) + } + + slugs := make([]string, len(skills)) + for i, s := range skills { + slugs[i] = s.Slug + } + c.logger.WithFields(logrus.Fields{ + "skill_count": len(skills), + "skill_slugs": slugs, + }).Info("mcp skills discovered") + + c.skills.set(skills) + return skills, nil +} + +// extractSkillSlug extracts a slug from a skill resource URI. +// Handles various URI formats: +// - "skill://vultisig/evm-contract-call.md" → "evm-contract-call" +// - "skills/evm-contract-call.md" → "evm-contract-call" +// +// Returns "" if the URI doesn't end in .md. +func extractSkillSlug(uri string) string { + if !strings.HasSuffix(uri, ".md") { + return "" + } + base := strings.TrimSuffix(uri, ".md") + if idx := strings.LastIndex(base, "/"); idx >= 0 { + return base[idx+1:] + } + return base +} + +// ReadSkill fetches the content of a specific skill by slug. +func (c *Client) ReadSkill(ctx context.Context, slug string) (string, error) { + // Check in-memory content cache first + if cached, ok := c.skillContent.Load(slug); ok { + return cached.(string), nil + } + + // Look up the full URI from the skill cache + uri := c.skillURI(slug) + if uri == "" { + return "", fmt.Errorf("skill %q not found in skill list", slug) + } + + c.logger.WithFields(logrus.Fields{ + "skill": slug, + "uri": uri, + }).Debug("mcp reading skill via resources/read") + + result, err := c.call(ctx, "resources/read", readResourceParams{URI: uri}) + if err != nil { + return "", fmt.Errorf("read skill %s: %w", slug, err) + } + + var readResult readResourceResult + if err := json.Unmarshal(result, &readResult); err != nil { + return "", fmt.Errorf("unmarshal skill content: %w", err) + } + + if len(readResult.Contents) == 0 { + return "", fmt.Errorf("skill %s: empty content", slug) + } + + text := readResult.Contents[0].Text + c.skillContent.Store(slug, text) + + c.logger.WithFields(logrus.Fields{ + "skill": slug, + "content_len": len(text), + }).Info("mcp skill loaded") + + return text, nil +} + +// skillURI looks up the full resource URI for a skill slug from the cache. +func (c *Client) skillURI(slug string) string { + skills, _ := c.skills.get() + for _, s := range skills { + if s.Slug == slug { + return s.URI + } + } + return "" +} + +// SkillSummary returns a formatted list of available skills for injection into the system prompt. +// Returns "" if no skills are available. Triggers a background refresh if cache is stale. +func (c *Client) SkillSummary(ctx context.Context) string { + skills, fresh := c.skills.get() + + if !fresh && skills != nil { + go func() { + refreshCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _ = c.ListSkills(refreshCtx) + }() + } + + if len(skills) == 0 { + return "" + } + + var b strings.Builder + b.WriteString("\n\n## Available Skills\n\n") + b.WriteString("You have access to specialized skill guides that provide detailed instructions for specific workflows. ") + b.WriteString("Use the `get_skill` tool to load a skill's full instructions when it is relevant to the user's request.\n\n") + b.WriteString("**IMPORTANT**: Only load skills that are directly relevant to what the user is asking. Do not load all skills.\n\n") + for _, s := range skills { + b.WriteString("- **") + b.WriteString(s.Slug) + b.WriteString("**") + if s.Description != "" { + b.WriteString(": ") + b.WriteString(s.Description) + } + b.WriteString("\n") + } + return b.String() +} diff --git a/internal/service/agent/agent.go b/internal/service/agent/agent.go index 4493c88..907e602 100644 --- a/internal/service/agent/agent.go +++ b/internal/service/agent/agent.go @@ -29,12 +29,14 @@ type PluginSkillsProvider interface { GetSkills(ctx context.Context) []PluginSkill } -// MCPToolProvider provides tools discovered from an MCP server. +// MCPToolProvider provides tools and skills discovered from an MCP server. type MCPToolProvider interface { GetAnthropicTools(ctx context.Context) []anthropic.Tool ToolNames() []string CallTool(ctx context.Context, name string, arguments json.RawMessage) (string, error) ToolDescriptions() string + SkillSummary(ctx context.Context) string + ReadSkill(ctx context.Context, slug string) (string, error) } type SwapTxBuilder interface { @@ -170,6 +172,11 @@ func (s *AgentService) ProcessMessage(ctx context.Context, convID uuid.UUID, pub if mcpDesc != "" { basePrompt += mcpDesc } + skillSummary := s.mcpProvider.SkillSummary(ctx) + if skillSummary != "" { + s.logger.WithField("skill_summary_len", len(skillSummary)).Debug("appending skill summary to system prompt") + basePrompt += skillSummary + } } systemPrompt := BuildSystemPromptWithSummary( basePrompt+s.loadMemorySection(ctx, req.PublicKey)+MemoryManagementInstructions, @@ -199,10 +206,16 @@ func (s *AgentService) ProcessMessage(ctx context.Context, convID uuid.UUID, pub s.logger.Warn("mcp provider active but no tools returned") } tools = append(tools, mcpTools...) + + // Add get_skill tool if skills are available + if s.mcpProvider.SkillSummary(ctx) != "" { + tools = append(tools, GetSkillTool) + } } var toolResp *ToolResponse var textContent string + var tokens *TokenSearchResult for range maxLoopIterations { anthropicReq := &anthropic.Request{ @@ -261,6 +274,16 @@ func (s *AgentService) ProcessMessage(ctx context.Context, convID uuid.UUID, pub ToolUseID: tc.ID, Content: result, }) + + // Track find_token results for structured passthrough + if tc.Name == "find_token" { + if parsed := extractTokens(result); parsed != nil { + tokens = parsed + s.logger.WithField("token_count", len(parsed.Tokens)).Info("tokens extracted from find_token result") + } else { + s.logger.WithField("result_preview", truncateResult(result, 200)).Warn("find_token result could not be parsed as token data") + } + } } messages = append(messages, anthropic.AssistantMessage{ @@ -278,10 +301,20 @@ func (s *AgentService) ProcessMessage(ctx context.Context, convID uuid.UUID, pub } if toolResp != nil { - return s.buildLoopResponse(ctx, convID, req, toolResp, window) + resp, err := s.buildLoopResponse(ctx, convID, req, toolResp, window) + if err != nil { + return nil, err + } + resp.Tokens = tokens + return resp, nil } if textContent != "" { - return s.buildTextResponse(ctx, convID, textContent) + resp, err := s.buildTextResponse(ctx, convID, textContent) + if err != nil { + return nil, err + } + resp.Tokens = tokens + return resp, nil } return nil, errors.New("no response content from Claude") @@ -342,6 +375,11 @@ func (s *AgentService) ProcessMessageStream(ctx context.Context, convID uuid.UUI if mcpDesc != "" { basePrompt += mcpDesc } + skillSummary := s.mcpProvider.SkillSummary(ctx) + if skillSummary != "" { + s.logger.WithField("skill_summary_len", len(skillSummary)).Debug("appending skill summary to system prompt (stream)") + basePrompt += skillSummary + } } systemPrompt := BuildSystemPromptWithSummary( basePrompt+s.loadMemorySection(ctx, req.PublicKey)+MemoryManagementInstructions, @@ -361,6 +399,9 @@ func (s *AgentService) ProcessMessageStream(ctx context.Context, convID uuid.UUI if len(mcpTools) > 0 { tools = append(tools, mcpTools...) } + if s.mcpProvider.SkillSummary(ctx) != "" { + tools = append(tools, GetSkillTool) + } } var toolResp *ToolResponse @@ -757,6 +798,44 @@ func (s *AgentService) emitTextResponse(ctx context.Context, convID uuid.UUID, t eventCh <- SSEEvent{Event: "message", Data: MessagePayload{Message: *assistantMsg}} } +// extractTokens tries to parse a TokenSearchResult from an MCP tool result. +// MCP text content may not be pure JSON (e.g., multiple text blocks joined with \n, +// or descriptive text surrounding JSON), so we try multiple strategies. +func extractTokens(result string) *TokenSearchResult { + // Strategy 1: direct unmarshal (pure JSON) + var direct TokenSearchResult + if err := json.Unmarshal([]byte(result), &direct); err == nil && len(direct.Tokens) > 0 { + return &direct + } + + // Strategy 2: the result may contain non-JSON text around the JSON object. + // Scan for the first '{' and try to decode from there. json.Decoder + // stops after the first complete JSON value, ignoring trailing text. + for i := strings.IndexByte(result, '{'); i >= 0 && i < len(result); { + var candidate TokenSearchResult + dec := json.NewDecoder(strings.NewReader(result[i:])) + if err := dec.Decode(&candidate); err == nil && len(candidate.Tokens) > 0 { + return &candidate + } + // Try the next '{' occurrence + next := strings.IndexByte(result[i+1:], '{') + if next < 0 { + break + } + i = i + 1 + next + } + + return nil +} + +// truncateResult returns the first n bytes of a string for log previews. +func truncateResult(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + func (s *AgentService) autoContinueAfterInstall(ctx context.Context, convID uuid.UUID, req *SendMessageRequest, window *conversationWindow, resp *SendMessageResponse) { pendingKey := fmt.Sprintf("pending_build:%s", convID) suggID, err := s.redis.Get(ctx, pendingKey) diff --git a/internal/service/agent/executor.go b/internal/service/agent/executor.go index bb077bb..8bf2785 100644 --- a/internal/service/agent/executor.go +++ b/internal/service/agent/executor.go @@ -3,10 +3,13 @@ package agent import ( "context" "encoding/json" + "errors" "time" "github.com/google/uuid" "github.com/sirupsen/logrus" + + "github.com/vultisig/agent-backend/internal/mcp" ) const suggestionTTL = 1 * time.Hour @@ -27,6 +30,8 @@ func (s *AgentService) executeTool(ctx context.Context, convID uuid.UUID, name s return s.execCreateSuggestion(ctx, input) case "update_memory": return s.execUpdateMemory(ctx, input, req) + case "get_skill": + return s.execGetSkill(ctx, input) case "set_vault": return s.execSetVault(ctx, convID, input, req) default: @@ -36,6 +41,14 @@ func (s *AgentService) executeTool(ctx context.Context, convID uuid.UUID, name s if mcpName == name { result, err := s.mcpProvider.CallTool(ctx, name, input) if err != nil { + // ToolError carries the text content — return it so + // trackToolResult can still extract structured data + // and Claude can narrate the error to the user. + var toolErr *mcp.ToolError + if errors.As(err, &toolErr) && toolErr.Text != "" { + s.logger.WithField("tool", name).Warn("mcp tool returned isError with content") + return toolErr.Text, nil + } s.logger.WithError(err).WithField("tool", name).Warn("mcp tool call failed") return jsonError("mcp tool error: " + err.Error()), nil } @@ -91,6 +104,30 @@ func (s *AgentService) execSetVault(ctx context.Context, convID uuid.UUID, input return string(result), nil } +// execGetSkill loads a skill's full instructions from the MCP server. +func (s *AgentService) execGetSkill(ctx context.Context, input json.RawMessage) (string, error) { + var params struct { + SkillName string `json:"skill_name"` + } + if err := json.Unmarshal(input, ¶ms); err != nil { + return jsonError("invalid input: " + err.Error()), nil + } + if params.SkillName == "" { + return jsonError("skill_name is required"), nil + } + if s.mcpProvider == nil { + return jsonError("skills not available"), nil + } + + content, err := s.mcpProvider.ReadSkill(ctx, params.SkillName) + if err != nil { + s.logger.WithError(err).WithField("skill", params.SkillName).Warn("failed to load skill") + return jsonError("failed to load skill: " + err.Error()), nil + } + + return content, nil +} + // truncateKey returns the first 12 chars of a key for logging. func truncateKey(key string) string { if len(key) <= 12 { diff --git a/internal/service/agent/tools.go b/internal/service/agent/tools.go index 9921d8a..41a05c0 100644 --- a/internal/service/agent/tools.go +++ b/internal/service/agent/tools.go @@ -130,6 +130,25 @@ var SetVaultTool = anthropic.Tool{ }, } +// GetSkillTool loads a specific skill's full instructions on demand. +// Added to the tool list dynamically only when skills are available from MCP. +var GetSkillTool = anthropic.Tool{ + Name: "get_skill", + Description: "Load the full instructions for a specific skill. " + + "Use this when you identify a skill from the Available Skills list that is relevant to the user's request. " + + "Only load skills that are directly needed — do not speculatively load skills.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "skill_name": map[string]any{ + "type": "string", + "description": "The slug name of the skill to load (as listed in Available Skills).", + }, + }, + "required": []string{"skill_name"}, + }, +} + func agentTools() []anthropic.Tool { return []anthropic.Tool{ RespondToUserTool, diff --git a/internal/service/agent/types.go b/internal/service/agent/types.go index fb9fab6..4cf8c1f 100644 --- a/internal/service/agent/types.go +++ b/internal/service/agent/types.go @@ -71,14 +71,15 @@ type ActionResult struct { // SendMessageResponse is the response for sending a message. type SendMessageResponse struct { - Message types.Message `json:"message"` - Title *string `json:"title,omitempty"` - Suggestions []Suggestion `json:"suggestions,omitempty"` - Actions []Action `json:"actions,omitempty"` - PolicyReady *PolicyReady `json:"policy_ready,omitempty"` - InstallRequired *InstallRequired `json:"install_required,omitempty"` - TxReady *TxReady `json:"tx_ready,omitempty"` - Transactions []Transaction `json:"transactions,omitempty"` + Message types.Message `json:"message"` + Title *string `json:"title,omitempty"` + Suggestions []Suggestion `json:"suggestions,omitempty"` + Actions []Action `json:"actions,omitempty"` + PolicyReady *PolicyReady `json:"policy_ready,omitempty"` + InstallRequired *InstallRequired `json:"install_required,omitempty"` + TxReady *TxReady `json:"tx_ready,omitempty"` + Transactions []Transaction `json:"transactions,omitempty"` + Tokens *TokenSearchResult `json:"tokens,omitempty"` } // Transaction represents an unsigned transaction returned by an MCP tool @@ -208,3 +209,25 @@ type ToolAction struct { Params map[string]any `json:"params,omitempty"` AutoExecute bool `json:"auto_execute"` } + +// TokenSearchResult contains tokens returned by the find_token MCP tool. +type TokenSearchResult struct { + Tokens []Token `json:"tokens"` +} + +// Token represents a cryptocurrency token with its on-chain deployments. +type Token struct { + ID string `json:"id"` + Name string `json:"name"` + Symbol string `json:"symbol"` + MarketCapRank int `json:"market_cap_rank"` + Logo string `json:"logo"` + Deployments []TokenDeployment `json:"deployments"` +} + +// TokenDeployment represents a token's deployment on a specific chain. +type TokenDeployment struct { + Chain string `json:"chain"` + ContractAddress string `json:"contract_address"` + Decimals int `json:"decimals"` +}