diff --git a/README.md b/README.md index fa0b09bb..0113f82b 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,8 @@ Each builtin server entry requires: - No configuration options required - `todo`: Manage ephemeral todo lists for task tracking during sessions - No configuration options required (todos are stored in memory and reset on restart) -- `fetch`: Fetch web content and convert to text, markdown, or HTML formats +- `http`: Fetch web content and convert to text, markdown, or HTML formats + - Tools: `fetch` (fetch and convert web content), `fetch_summarize` (fetch and summarize web content using AI) - No configuration options required #### Builtin Server Examples @@ -259,7 +260,7 @@ Each builtin server entry requires: }, "web-fetcher": { "type": "builtin", - "name": "fetch" + "name": "http" } } } diff --git a/go.mod b/go.mod index 779af6b5..f9147fc9 100644 --- a/go.mod +++ b/go.mod @@ -8,20 +8,19 @@ require ( github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/PuerkitoBio/goquery v1.10.3 github.com/bytedance/sonic v1.13.3 - github.com/charmbracelet/huh v0.3.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/cloudwego/eino v0.3.41 github.com/cloudwego/eino-ext/components/model/claude v0.0.0-20250609074000-b7f307dffa18 github.com/cloudwego/eino-ext/components/model/ollama v0.0.0-20250609074000-b7f307dffa18 github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250609074000-b7f307dffa18 github.com/mark3labs/mcp-filesystem-server v0.11.1 - github.com/mark3labs/mcp-go v0.32.0 + github.com/mark3labs/mcp-go v0.34.0 github.com/ollama/ollama v0.5.12 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.20.1 golang.org/x/term v0.31.0 google.golang.org/genai v1.10.0 - gopkg.in/yaml.v3 v3.0.1 + gopkg.in/yaml.v3 v3.0.1 // indirect ) require github.com/getkin/kin-openapi v0.118.0 @@ -50,7 +49,6 @@ require ( github.com/aws/smithy-go v1.22.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect - github.com/catppuccin/go v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect diff --git a/go.sum b/go.sum index d411e822..abe75b75 100644 --- a/go.sum +++ b/go.sum @@ -70,8 +70,6 @@ github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1 github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= -github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= @@ -83,8 +81,6 @@ github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE= -github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= @@ -215,8 +211,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mark3labs/mcp-filesystem-server v0.11.1 h1:7uKIZRMaKWfgvtDj/uLAvo0+7Mwb8gxo5DJywhqFW88= github.com/mark3labs/mcp-filesystem-server v0.11.1/go.mod h1:xDqJizVYWZ5a31Mt4xuYbVku2AR/kT56H3O0SbpANoQ= -github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8= -github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/mark3labs/mcp-go v0.34.0 h1:eWy7WBGvhk6EyAAyVzivTCprE52iXJwNtvHV6Cv3bR0= +github.com/mark3labs/mcp-go v0.34.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c69c8045..40ddebce 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -65,6 +65,10 @@ func NewAgent(ctx context.Context, config *AgentConfig) (*Agent, error) { // Create and load MCP tools toolManager := tools.NewMCPToolManager() + + // Set the model for sampling support + toolManager.SetModel(providerResult.Model) + if err := toolManager.LoadTools(ctx, config.MCPConfig); err != nil { return nil, fmt.Errorf("failed to load MCP tools: %v", err) } diff --git a/internal/builtin/http.go b/internal/builtin/http.go index 75dc8ec9..d5f58201 100644 --- a/internal/builtin/http.go +++ b/internal/builtin/http.go @@ -11,6 +11,8 @@ import ( "github.com/JohannesKaufmann/html-to-markdown" "github.com/PuerkitoBio/goquery" + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) @@ -21,8 +23,14 @@ const ( httpMaxFetchTimeout = 120 * time.Second ) +// httpServerModel holds the model for the HTTP server +var httpServerModel model.ToolCallingChatModel + // NewHTTPServer creates a new HTTP MCP server -func NewHTTPServer() (*server.MCPServer, error) { +func NewHTTPServer(llmModel model.ToolCallingChatModel) (*server.MCPServer, error) { + // Store the model globally for use in tool handlers + httpServerModel = llmModel + s := server.NewMCPServer("http-server", "1.0.0", server.WithToolCapabilities(true)) // Register the fetch tool @@ -49,6 +57,21 @@ func NewHTTPServer() (*server.MCPServer, error) { s.AddTool(fetchTool, executeHTTPFetch) + // Only add the summarize tool if we have a model + if llmModel != nil { + summarizeTool := mcp.NewTool("fetch_summarize", + mcp.WithDescription(httpSummarizeDescription), + mcp.WithString("url", + mcp.Required(), + mcp.Description("The URL to fetch and summarize"), + ), + mcp.WithString("instructions", + mcp.Description("Optional summarization instructions (default: 'Provide a concise summary')"), + ), + ) + s.AddTool(summarizeTool, executeHTTPFetchSummarize) + } + return s, nil } @@ -236,6 +259,164 @@ func httpConvertHTMLToMarkdown(htmlContent string) (string, error) { return markdown, nil } +// executeHTTPFetchSummarize handles the fetch_summarize tool execution +func executeHTTPFetchSummarize(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Get URL + urlStr, err := request.RequireString("url") + if err != nil { + return mcp.NewToolResultError("url parameter is required and must be a string"), nil + } + + // Get optional instructions + instructions := request.GetString("instructions", "Provide a concise summary of this content.") + + // Fetch content as text (reuse existing logic) + content, err := httpFetchAndExtractText(ctx, urlStr) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch content: %v", err)), nil + } + + // Check if we have a model available + if httpServerModel == nil { + return mcp.NewToolResultError("LLM model not available for summarization"), nil + } + + // Create messages for the LLM + messages := []*schema.Message{ + schema.UserMessage(fmt.Sprintf("%s\n\nContent to summarize:\n%s", instructions, content)), + } + + // Generate summary using the model directly + response, err := httpServerModel.Generate(ctx, messages) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Summarization failed: %v", err)), nil + } + + // Return summary + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: response.Content, + }, + }, + }, nil +} + +// httpFetchAndExtractText fetches content from URL and extracts as text +func httpFetchAndExtractText(ctx context.Context, urlStr string) (string, error) { + // Parse timeout (use default) + timeout := httpDefaultFetchTimeout + + // Validate URL + parsedURL, err := url.Parse(urlStr) + if err != nil { + return "", fmt.Errorf("invalid URL: %v", err) + } + + // Ensure URL has a scheme + if parsedURL.Scheme == "" { + urlStr = "https://" + urlStr + parsedURL, err = url.Parse(urlStr) + if err != nil { + return "", fmt.Errorf("invalid URL after adding https: %v", err) + } + } + + // Only allow HTTP and HTTPS + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return "", fmt.Errorf("URL must use http:// or https://") + } + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: timeout, + } + + // Create request with context + req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %v", err) + } + + // Set headers to mimic a real browser + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + + // Make the request + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %v", err) + } + defer resp.Body.Close() + + // Check status code + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("request failed with status code: %d", resp.StatusCode) + } + + // Check content length + if resp.ContentLength > httpMaxResponseSize { + return "", fmt.Errorf("response too large (exceeds 5MB limit)") + } + + // Read response body with size limit + limitedReader := io.LimitReader(resp.Body, httpMaxResponseSize+1) + bodyBytes, err := io.ReadAll(limitedReader) + if err != nil { + return "", fmt.Errorf("failed to read response: %v", err) + } + + // Check if we exceeded the size limit + if len(bodyBytes) > httpMaxResponseSize { + return "", fmt.Errorf("response too large (exceeds 5MB limit)") + } + + content := string(bodyBytes) + contentType := resp.Header.Get("Content-Type") + + // Extract text content + if strings.Contains(contentType, "text/html") { + return httpExtractTextFromHTML(content) + } + return content, nil +} + +// httpExtractTextFromHTML extracts plain text from HTML content +func httpExtractTextFromHTML(htmlContent string) (string, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent)) + if err != nil { + return "", err + } + + // Remove script, style, and other non-content elements + doc.Find("script, style, noscript, iframe, object, embed").Remove() + + // Extract text content + text := doc.Text() + + // Clean up whitespace + lines := strings.Split(text, "\n") + var cleanLines []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + cleanLines = append(cleanLines, trimmed) + } + } + + return strings.Join(cleanLines, "\n"), nil +} + +// httpGetTextFromSamplingResult extracts text from sampling result +func httpGetTextFromSamplingResult(result *mcp.CreateMessageResult) string { + if textContent, ok := result.Content.(mcp.TextContent); ok { + return textContent.Text + } + return fmt.Sprintf("%v", result.Content) +} + const httpFetchDescription = `Performs HTTP GET requests and returns content in HTML or Markdown format. - Fetches content from a specified URL using HTTP GET @@ -252,3 +433,17 @@ Usage notes: - "markdown": HTML converted to markdown format - Use bodyOnly=true to extract only the tag content (useful for reducing text) - Timeout can be specified in seconds (default 30s, max 120s)` + +const httpSummarizeDescription = `Fetches web content and returns an AI-generated summary using LLM sampling. + +- Fetches content from a specified URL using HTTP GET +- Uses the client's LLM to generate an intelligent summary +- Supports custom summarization instructions +- Returns a concise AI-generated summary of the content + +Usage notes: + - Requires a client with sampling capability (LLM access) + - The URL must be a fully-formed valid URL + - Content is automatically extracted as text for summarization + - Default instruction: "Provide a concise summary of this content" + - Summary is limited to approximately 500 tokens` diff --git a/internal/builtin/registry.go b/internal/builtin/registry.go index 81e33193..ea599dc0 100644 --- a/internal/builtin/registry.go +++ b/internal/builtin/registry.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/cloudwego/eino/components/model" "github.com/mark3labs/mcp-filesystem-server/filesystemserver" "github.com/mark3labs/mcp-go/server" ) @@ -26,13 +27,13 @@ func (w *BuiltinServerWrapper) GetServer() *server.MCPServer { // Registry holds all available builtin servers type Registry struct { - servers map[string]func(options map[string]any) (*BuiltinServerWrapper, error) + servers map[string]func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) } // NewRegistry creates a new builtin server registry func NewRegistry() *Registry { r := &Registry{ - servers: make(map[string]func(options map[string]any) (*BuiltinServerWrapper, error)), + servers: make(map[string]func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error)), } // Register builtin servers @@ -46,13 +47,13 @@ func NewRegistry() *Registry { } // CreateServer creates a new instance of a builtin server -func (r *Registry) CreateServer(name string, options map[string]any) (*BuiltinServerWrapper, error) { +func (r *Registry) CreateServer(name string, options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) { factory, exists := r.servers[name] if !exists { return nil, fmt.Errorf("unknown builtin server: %s", name) } - return factory(options) + return factory(options, model) } // ListServers returns a list of available builtin server names @@ -66,7 +67,7 @@ func (r *Registry) ListServers() []string { // registerFilesystemServer registers the filesystem server func (r *Registry) registerFilesystemServer() { - r.servers["fs"] = func(options map[string]any) (*BuiltinServerWrapper, error) { + r.servers["fs"] = func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) { // Extract allowed directories from options var allowedDirs []string if dirs, ok := options["allowed_directories"]; ok { @@ -108,7 +109,7 @@ func (r *Registry) registerFilesystemServer() { // registerBashServer registers the bash server func (r *Registry) registerBashServer() { - r.servers["bash"] = func(options map[string]any) (*BuiltinServerWrapper, error) { + r.servers["bash"] = func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) { // Create the bash server server, err := NewBashServer() if err != nil { @@ -121,7 +122,7 @@ func (r *Registry) registerBashServer() { // registerTodoServer registers the todo server func (r *Registry) registerTodoServer() { - r.servers["todo"] = func(options map[string]any) (*BuiltinServerWrapper, error) { + r.servers["todo"] = func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) { // Create the todo server server, err := NewTodoServer() if err != nil { @@ -134,7 +135,7 @@ func (r *Registry) registerTodoServer() { // registerFetchServer registers the fetch server func (r *Registry) registerFetchServer() { - r.servers["fetch"] = func(options map[string]any) (*BuiltinServerWrapper, error) { + r.servers["fetch"] = func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) { // Create the fetch server server, err := NewFetchServer() if err != nil { @@ -147,9 +148,9 @@ func (r *Registry) registerFetchServer() { // registerHTTPServer registers the HTTP server func (r *Registry) registerHTTPServer() { - r.servers["http"] = func(options map[string]any) (*BuiltinServerWrapper, error) { + r.servers["http"] = func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) { // Create the HTTP server - server, err := NewHTTPServer() + server, err := NewHTTPServer(model) if err != nil { return nil, fmt.Errorf("failed to create HTTP server: %v", err) } diff --git a/internal/tools/mcp.go b/internal/tools/mcp.go index aa44349b..40f2e8dd 100644 --- a/internal/tools/mcp.go +++ b/internal/tools/mcp.go @@ -8,6 +8,7 @@ import ( "time" "github.com/bytedance/sonic" + "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/schema" "github.com/getkin/kin-openapi/openapi3" @@ -22,7 +23,8 @@ import ( type MCPToolManager struct { clients map[string]client.MCPClient tools []tool.BaseTool - toolMap map[string]*toolMapping // maps prefixed tool names to their server and original name + toolMap map[string]*toolMapping // maps prefixed tool names to their server and original name + model model.ToolCallingChatModel // LLM model for sampling } // toolMapping stores the mapping between prefixed tool names and their original details @@ -47,6 +49,72 @@ func NewMCPToolManager() *MCPToolManager { } } +// SetModel sets the LLM model for sampling support +func (m *MCPToolManager) SetModel(model model.ToolCallingChatModel) { + m.model = model +} + +// samplingHandler implements the MCP sampling handler interface +type samplingHandler struct { + model model.ToolCallingChatModel +} + +// CreateMessage handles sampling requests from MCP servers +func (h *samplingHandler) CreateMessage(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) { + if h.model == nil { + return nil, fmt.Errorf("no model available for sampling") + } + + // Convert MCP messages to eino messages + var messages []*schema.Message + + // Add system message if provided + if request.SystemPrompt != "" { + messages = append(messages, schema.SystemMessage(request.SystemPrompt)) + } + + // Convert sampling messages + for _, msg := range request.Messages { + // Extract text content + var content string + if textContent, ok := msg.Content.(mcp.TextContent); ok { + content = textContent.Text + } else { + content = fmt.Sprintf("%v", msg.Content) + } + + switch msg.Role { + case mcp.RoleUser: + messages = append(messages, schema.UserMessage(content)) + case mcp.RoleAssistant: + messages = append(messages, schema.AssistantMessage(content, nil)) + default: + messages = append(messages, schema.UserMessage(content)) // Default to user + } + } + + // Generate response using the model (no config options for now) + response, err := h.model.Generate(ctx, messages) + if err != nil { + return nil, fmt.Errorf("model generation failed: %w", err) + } + + // Convert response back to MCP format + result := &mcp.CreateMessageResult{ + Model: "mcphost-model", // Generic model name + StopReason: "endTurn", + } + result.SamplingMessage = mcp.SamplingMessage{ + Role: mcp.RoleAssistant, + Content: mcp.TextContent{ + Type: "text", + Text: response.Content, + }, + } + + return result, nil +} + // LoadTools loads tools from MCP servers based on configuration func (m *MCPToolManager) LoadTools(ctx context.Context, config *config.Config) error { var loadErrors []string @@ -278,9 +346,14 @@ func (m *MCPToolManager) createMCPClient(ctx context.Context, serverName string, } } - stdioClient, err := client.NewStdioMCPClient(command, env, args...) - if err != nil { - return nil, fmt.Errorf("failed to create stdio client: %v", err) + // Create stdio transport + stdioTransport := transport.NewStdio(command, env, args...) + + stdioClient := client.NewClient(stdioTransport) + + // Start the transport + if err := stdioTransport.Start(ctx); err != nil { + return nil, fmt.Errorf("failed to start stdio transport: %v", err) } // Add a brief delay to allow the process to start and potentially fail @@ -388,8 +461,8 @@ func (m *MCPToolManager) initializeClient(ctx context.Context, client client.MCP func (m *MCPToolManager) createBuiltinClient(ctx context.Context, serverName string, serverConfig config.MCPServerConfig) (client.MCPClient, error) { registry := builtin.NewRegistry() - // Create the builtin server - builtinServer, err := registry.CreateServer(serverConfig.Name, serverConfig.Options) + // Create the builtin server, passing the model for servers that need it + builtinServer, err := registry.CreateServer(serverConfig.Name, serverConfig.Options, m.model) if err != nil { return nil, fmt.Errorf("failed to create builtin server: %v", err) }