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
247 changes: 238 additions & 9 deletions internal/mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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.
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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()
}
Loading