From 01ba565be27147cb671956b33aee0dc2663eebb0 Mon Sep 17 00:00:00 2001 From: Raghav Sood Date: Mon, 23 Feb 2026 05:17:58 +0800 Subject: [PATCH 1/4] add find_token MCP tool support with structured token data passthrough Extract token search results from the find_token MCP tool and pass them as structured data in the API response so frontend apps can prompt users to add tokens/chains to their vault. Co-Authored-By: Claude Opus 4.6 --- internal/service/agent/agent.go | 23 +++++++++++++++++-- internal/service/agent/types.go | 39 ++++++++++++++++++++++++++------- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/internal/service/agent/agent.go b/internal/service/agent/agent.go index 4493c88..cc65ce1 100644 --- a/internal/service/agent/agent.go +++ b/internal/service/agent/agent.go @@ -203,6 +203,7 @@ func (s *AgentService) ProcessMessage(ctx context.Context, convID uuid.UUID, pub var toolResp *ToolResponse var textContent string + var tokens *TokenSearchResult for range maxLoopIterations { anthropicReq := &anthropic.Request{ @@ -261,6 +262,14 @@ 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" { + var tokenResult TokenSearchResult + if err := json.Unmarshal([]byte(result), &tokenResult); err == nil && len(tokenResult.Tokens) > 0 { + tokens = &tokenResult + } + } } messages = append(messages, anthropic.AssistantMessage{ @@ -278,10 +287,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") 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"` +} From 5078c33ade2c0a76b36c0562aea1aea634e8496b Mon Sep 17 00:00:00 2001 From: Raghav Sood Date: Mon, 23 Feb 2026 06:29:35 +0800 Subject: [PATCH 2/4] fix find_token structured data extraction from MCP results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues prevented the tokens field from appearing in API responses: 1. CallTool discarded text on IsError — when the MCP tool set IsError: true, CallTool returned a Go error and the text content was lost. Now returns a ToolError that carries the text, so executeTool can still pass it to trackToolResult for structured extraction. 2. trackToolResult assumed pure JSON — MCP tools may return multiple text content blocks (joined with \n) or mix descriptive text with JSON. The direct json.Unmarshal failed silently. Now uses extractTokens() which tries direct unmarshal first, then scans for JSON objects in the text using json.Decoder (which handles trailing content). Also adds diagnostic logging when parsing fails so we can see the actual MCP result text in logs. Co-Authored-By: Claude Opus 4.6 --- internal/mcp/client.go | 18 ++++++++++-- internal/service/agent/agent.go | 46 ++++++++++++++++++++++++++++-- internal/service/agent/executor.go | 11 +++++++ 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 95e383d..f94e6a5 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"` @@ -316,10 +327,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{ diff --git a/internal/service/agent/agent.go b/internal/service/agent/agent.go index cc65ce1..5e34a09 100644 --- a/internal/service/agent/agent.go +++ b/internal/service/agent/agent.go @@ -265,9 +265,11 @@ func (s *AgentService) ProcessMessage(ctx context.Context, convID uuid.UUID, pub // Track find_token results for structured passthrough if tc.Name == "find_token" { - var tokenResult TokenSearchResult - if err := json.Unmarshal([]byte(result), &tokenResult); err == nil && len(tokenResult.Tokens) > 0 { - tokens = &tokenResult + 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") } } } @@ -776,6 +778,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..0686f89 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 @@ -36,6 +39,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 } From 9f3b9bc87290e4ad36847c7fae2f33fe19d6502c Mon Sep 17 00:00:00 2001 From: Raghav Sood Date: Tue, 24 Feb 2026 03:51:49 +0800 Subject: [PATCH 3/4] add MCP skill discovery and on-demand loading Integrate MCP resources protocol (resources/list, resources/read) to discover and load skill guides from the MCP server. Skills are markdown documents at skills/{slug}.md that provide detailed workflow instructions. The skill list is injected into the system prompt so the LLM knows what's available, but skill content is only loaded on-demand via the new get_skill tool when relevant to the user's request. This keeps the context window lean as the skill library grows. - MCP client: add ListSkills, ReadSkill, SkillSummary with TTL caching - Agent: extend MCPToolProvider interface with skill methods - Agent: inject skill summary into system prompt after tool descriptions - Tools: add get_skill native tool (only registered when skills exist) - Executor: add get_skill handler delegating to MCP ReadSkill - Main: pre-warm skill cache at startup alongside tools Co-Authored-By: Claude Opus 4.6 --- cmd/server/main.go | 8 ++ internal/mcp/client.go | 205 ++++++++++++++++++++++++++++- internal/service/agent/agent.go | 22 +++- internal/service/agent/executor.go | 26 ++++ internal/service/agent/tools.go | 19 +++ 5 files changed, 273 insertions(+), 7 deletions(-) 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..372cf94 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,137 @@ 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 converts a resource URI like "skills/dca-setup.md" to "dca-setup". +// Returns "" if the URI doesn't match the skill pattern. +func extractSkillSlug(uri string) string { + if !strings.HasPrefix(uri, "skills/") || !strings.HasSuffix(uri, ".md") { + return "" + } + return strings.TrimSuffix(strings.TrimPrefix(uri, "skills/"), ".md") +} + +// 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 + } + + uri := "skills/" + slug + ".md" + 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 +} + +// 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, From 27c50d0dfb94e11282372782fee72628d30230b8 Mon Sep 17 00:00:00 2001 From: Raghav Sood Date: Tue, 24 Feb 2026 04:06:30 +0800 Subject: [PATCH 4/4] fix skill URI parsing for skill:// scheme The MCP server returns skill resources with URIs like "skill://vultisig/evm-contract-call.md" but extractSkillSlug was filtering for the "skills/" prefix, discarding all entries. - extractSkillSlug now extracts the last path segment before .md, handling any URI scheme (skill://, skills/, etc.) - ReadSkill now looks up the full URI from the skill cache instead of constructing it, so it works with any URI format Co-Authored-By: Claude Opus 4.6 --- internal/mcp/client.go | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 372cf94..c053b4d 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -526,13 +526,21 @@ func (c *Client) ListSkills(ctx context.Context) ([]skillEntry, error) { return skills, nil } -// extractSkillSlug converts a resource URI like "skills/dca-setup.md" to "dca-setup". -// Returns "" if the URI doesn't match the skill pattern. +// 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.HasPrefix(uri, "skills/") || !strings.HasSuffix(uri, ".md") { + if !strings.HasSuffix(uri, ".md") { return "" } - return strings.TrimSuffix(strings.TrimPrefix(uri, "skills/"), ".md") + 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. @@ -542,7 +550,12 @@ func (c *Client) ReadSkill(ctx context.Context, slug string) (string, error) { return cached.(string), nil } - uri := "skills/" + slug + ".md" + // 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, @@ -573,6 +586,17 @@ func (c *Client) ReadSkill(ctx context.Context, slug string) (string, error) { 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 {