Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}

Expand Down
229 changes: 223 additions & 6 deletions internal/mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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()
}
22 changes: 21 additions & 1 deletion internal/service/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions internal/service/agent/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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, &params); 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 {
Expand Down
19 changes: 19 additions & 0 deletions internal/service/agent/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down