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 f94e6a5..c053b4d 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -102,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. @@ -120,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, } } @@ -414,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 5e34a09..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,6 +206,11 @@ 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 @@ -363,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, @@ -382,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 diff --git a/internal/service/agent/executor.go b/internal/service/agent/executor.go index 0686f89..8bf2785 100644 --- a/internal/service/agent/executor.go +++ b/internal/service/agent/executor.go @@ -30,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: @@ -102,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,