replace builtin MCP tools with native core tools and custom content blocks

Remove the entire internal/builtin package (bash, fetch, todo, http, fs
servers) and all inprocess/builtin transport support from config and
connection pool.

Add internal/core package with 7 direct fantasy.AgentTool implementations
matching pi's coding agent: bash, read, write, edit, grep, find, ls.
These execute in-process with zero MCP/JSON serialization overhead.

Add internal/message package with crush-inspired custom content blocks:
ContentPart interface with TextContent, ReasoningContent, ToolCall,
ToolResult, and Finish types. Messages carry heterogeneous Parts slices
with type-tagged JSON serialization for persistence and a ToFantasyMessages
bridge for LLM provider integration.

Core tools are always registered on the agent. External MCP servers remain
supported for additional tools, but MCP loading failures are now non-fatal
since core tools guarantee a working baseline.
This commit is contained in:
Ed Zynda
2026-02-26 17:41:02 +03:00
parent 30f368de24
commit 3f2a399e47
34 changed files with 1804 additions and 3186 deletions
+19 -64
View File
@@ -2,7 +2,6 @@ package cmd
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
@@ -459,10 +458,9 @@ func runNormalMode(ctx context.Context) error {
return fmt.Errorf("failed to load session: %v", err)
}
// Convert session messages to schema messages
// Convert session messages to fantasy messages
for _, msg := range loadedSession.Messages {
fantasyMsg := msg.ConvertToFantasyMessage()
messages = append(messages, fantasyMsg)
messages = append(messages, msg.ToFantasyMessages()...)
}
// If we're also saving, use the loaded session with the session manager
@@ -471,13 +469,15 @@ func runNormalMode(ctx context.Context) error {
}
if !quietFlag && cli != nil {
// Create a map of tool call IDs to tool calls for quick lookup
toolCallMap := make(map[string]session.ToolCall)
// Build a map of tool call IDs to tool calls for quick lookup
type toolCallInfo struct {
Name string
Input string
}
toolCallMap := make(map[string]toolCallInfo)
for _, sessionMsg := range loadedSession.Messages {
if sessionMsg.Role == "assistant" && len(sessionMsg.ToolCalls) > 0 {
for _, tc := range sessionMsg.ToolCalls {
toolCallMap[tc.ID] = tc
}
for _, tc := range sessionMsg.ToolCalls() {
toolCallMap[tc.ID] = toolCallInfo{Name: tc.Name, Input: tc.Input}
}
}
@@ -485,67 +485,22 @@ func runNormalMode(ctx context.Context) error {
for _, sessionMsg := range loadedSession.Messages {
switch sessionMsg.Role {
case "user":
cli.DisplayUserMessage(sessionMsg.Content)
cli.DisplayUserMessage(sessionMsg.Content())
case "assistant":
// Display tool calls if present
if len(sessionMsg.ToolCalls) > 0 {
for _, tc := range sessionMsg.ToolCalls {
// Convert arguments to string
var argsStr string
if argBytes, err := json.Marshal(tc.Arguments); err == nil {
argsStr = string(argBytes)
}
// Display tool call
cli.DisplayToolCallMessage(tc.Name, argsStr)
}
for _, tc := range sessionMsg.ToolCalls() {
cli.DisplayToolCallMessage(tc.Name, tc.Input)
}
// Display assistant response (only if there's content)
if sessionMsg.Content != "" {
_ = cli.DisplayAssistantMessage(sessionMsg.Content)
if text := sessionMsg.Content(); text != "" {
_ = cli.DisplayAssistantMessage(text)
}
case "tool":
// Display tool result
if sessionMsg.ToolCallID != "" {
if toolCall, exists := toolCallMap[sessionMsg.ToolCallID]; exists {
// Convert arguments to string
var argsStr string
if argBytes, err := json.Marshal(toolCall.Arguments); err == nil {
argsStr = string(argBytes)
}
// Parse tool result content - it might be JSON-encoded MCP content
resultContent := sessionMsg.Content
// Try to parse as MCP content structure
var mcpContent struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
// First try to unmarshal as-is
if err := json.Unmarshal([]byte(sessionMsg.Content), &mcpContent); err == nil {
// Extract text from MCP content structure
if len(mcpContent.Content) > 0 && mcpContent.Content[0].Type == "text" {
resultContent = mcpContent.Content[0].Text
}
} else {
// If that fails, try unquoting first (in case it's double-encoded)
var unquoted string
if err := json.Unmarshal([]byte(sessionMsg.Content), &unquoted); err == nil {
if err := json.Unmarshal([]byte(unquoted), &mcpContent); err == nil {
if len(mcpContent.Content) > 0 && mcpContent.Content[0].Type == "text" {
resultContent = mcpContent.Content[0].Text
}
}
}
}
// Display tool result (assuming no error for saved results)
cli.DisplayToolMessage(toolCall.Name, argsStr, resultContent, false)
// Display tool results
for _, result := range sessionMsg.ToolResults() {
if tc, exists := toolCallMap[result.ToolCallID]; exists {
cli.DisplayToolMessage(tc.Name, tc.Input, result.Content, result.IsError)
}
}
}
-27
View File
@@ -22,12 +22,6 @@ mcpServers:
GITHUB_TOKEN: "${env://GITHUB_TOKEN}"
DEBUG: "${env://DEBUG:-false}"
filesystem:
type: builtin
name: fs
options:
allowed_directories: ["${env://WORK_DIR:-/tmp}"]
model: "${env://MODEL:-anthropic/claude-sonnet-4-5-20250929}"
debug: ${env://DEBUG:-false}
---
@@ -76,20 +70,6 @@ Working directory is ${env://WORK_DIR:-/tmp}.
t.Errorf("Expected debug=true, got %s", githubServer.Environment["debug"])
}
// Verify environment variable substitution in builtin server options
fsServer, exists := scriptConfig.MCPServers["filesystem"]
if !exists {
t.Fatal("Filesystem server not found in script config")
}
allowedDirs, ok := fsServer.Options["allowed_directories"].([]any)
if !ok {
t.Fatal("allowed_directories should be an array")
}
if len(allowedDirs) != 1 || allowedDirs[0] != "/home/user/projects" {
t.Errorf("Expected allowed_directories=[/home/user/projects], got %v", allowedDirs)
}
// Verify global config values
if scriptConfig.Model != "anthropic/claude-sonnet-4-5-20250929" {
t.Errorf("Expected model=anthropic/claude-sonnet-4-5-20250929, got %s", scriptConfig.Model)
@@ -243,13 +223,6 @@ func TestScriptBackwardCompatibility(t *testing.T) {
scriptContent := `#!/usr/bin/env -S kit script
---
mcpServers:
filesystem:
type: builtin
name: fs
options:
allowed_directories: ["/tmp"]
model: "anthropic/claude-sonnet-4-5-20250929"
---
List files in ${directory:-/tmp} for user ${username}.
+5 -22
View File
@@ -275,9 +275,9 @@ func TestParseScriptContentWithCompactMode(t *testing.T) {
content := `---
compact: true
mcpServers:
todo:
type: "builtin"
name: "todo"
echo:
type: "local"
command: ["echo", "hello"]
---
Test prompt with compact mode`
@@ -312,11 +312,6 @@ mcpServers:
remote-server:
type: "remote"
url: "https://example.com/mcp"
builtin-todo:
type: "builtin"
name: "todo"
options:
storage: "memory"
---
Test prompt with new format MCP servers`
@@ -326,8 +321,8 @@ Test prompt with new format MCP servers`
t.Fatalf("parseScriptContent() failed: %v", err)
}
if len(config.MCPServers) != 3 {
t.Errorf("Expected 3 MCP servers, got %d", len(config.MCPServers))
if len(config.MCPServers) != 2 {
t.Errorf("Expected 2 MCP servers, got %d", len(config.MCPServers))
}
// Test local server
@@ -363,18 +358,6 @@ Test prompt with new format MCP servers`
if remote.URL != "https://example.com/mcp" {
t.Errorf("Expected remote server URL 'https://example.com/mcp', got '%s'", remote.URL)
}
// Test builtin server
builtin, exists := config.MCPServers["builtin-todo"]
if !exists {
t.Error("Expected builtin-todo server to exist")
}
if builtin.Type != "builtin" {
t.Errorf("Expected builtin server type 'builtin', got '%s'", builtin.Type)
}
if builtin.Name != "todo" {
t.Errorf("Expected builtin server name 'todo', got '%s'", builtin.Name)
}
}
func TestParseScriptContentMCPServersLegacyFormat(t *testing.T) {
-3
View File
@@ -222,9 +222,6 @@ func DisplayDebugConfig(cli *ui.CLI, mcpAgent *agent.Agent, mcpConfig *config.Co
if server.URL != "" {
serverInfo["url"] = server.URL
}
if server.Name != "" {
serverInfo["name"] = server.Name
}
mcpServers[name] = serverInfo
}
debugConfig["mcpServers"] = mcpServers
+2 -8
View File
@@ -7,14 +7,10 @@ require (
charm.land/bubbletea/v2 v2.0.0
charm.land/fantasy v0.10.0
charm.land/lipgloss/v2 v2.0.0
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/PuerkitoBio/goquery v1.11.0
github.com/charmbracelet/fang v0.4.4
github.com/mark3labs/mcp-filesystem-server v0.11.1
github.com/mark3labs/mcp-go v0.44.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/tidwall/gjson v1.18.0
golang.org/x/term v0.40.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -27,7 +23,6 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
@@ -61,16 +56,13 @@ require (
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
@@ -98,6 +90,7 @@ require (
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
@@ -122,6 +115,7 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
-107
View File
@@ -22,22 +22,14 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDo
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
@@ -121,12 +113,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
@@ -142,8 +130,6 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -153,15 +139,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
@@ -190,11 +173,8 @@ github.com/kaptinlin/jsonschema v0.7.3 h1:kyIydij76ORiSxmfy0xFYy0cOx8MwG6pyyaSoQ
github.com/kaptinlin/jsonschema v0.7.3/go.mod h1:Ys6zr+W6/1330FzZEouFrAYImK+AmYt5HQVTHQQXQo8=
github.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI=
github.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -203,8 +183,6 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
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.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -234,10 +212,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -249,11 +225,6 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -265,9 +236,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -289,8 +257,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
@@ -313,96 +279,25 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
@@ -416,10 +311,8 @@ google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+84 -40
View File
@@ -10,6 +10,8 @@ import (
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/core"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/tools"
)
@@ -42,8 +44,10 @@ type StreamingResponseHandler func(content string)
// ToolCallContentHandler is a function type for handling content that accompanies tool calls.
type ToolCallContentHandler func(content string)
// Agent represents an AI agent with MCP tool integration using the fantasy library.
// It manages the interaction between an LLM and various tools through the MCP protocol.
// Agent represents an AI agent with core tool integration using the fantasy library.
// Core tools (bash, read, write, edit, grep, find, ls) are registered as direct
// fantasy.AgentTool implementations — no MCP layer, no serialization overhead.
// Additional tools from external MCP servers can be loaded alongside core tools.
type Agent struct {
toolManager *tools.MCPToolManager
fantasyAgent fantasy.Agent
@@ -54,6 +58,7 @@ type Agent struct {
loadingMessage string
providerType string
streamingEnabled bool
coreTools []fantasy.AgentTool
}
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
@@ -62,11 +67,15 @@ type GenerateWithLoopResult struct {
FinalResponse *fantasy.Response
// ConversationMessages contains all messages in the conversation including tool calls and results
ConversationMessages []fantasy.Message
// Messages contains the conversation as custom content blocks (crush-style)
Messages []message.Message
// TotalUsage contains aggregate token usage across all steps
TotalUsage fantasy.Usage
}
// NewAgent creates a new Agent with MCP tool integration and streaming support.
// NewAgent creates a new Agent with core tools and optional MCP tool integration.
// Core tools (bash, read, write, edit, grep, find, ls) are always registered.
// External MCP tools are loaded from the config if any MCP servers are configured.
func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
// Create the LLM provider via fantasy
providerResult, err := models.CreateProvider(ctx, agentConfig.ModelConfig)
@@ -74,16 +83,30 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
return nil, fmt.Errorf("failed to create model provider: %v", err)
}
// Create and load MCP tools
toolManager := tools.NewMCPToolManager()
toolManager.SetModel(providerResult.Model)
// Register core tools (direct fantasy implementations, no MCP overhead)
coreTools := core.AllTools()
if agentConfig.DebugLogger != nil {
toolManager.SetDebugLogger(agentConfig.DebugLogger)
}
// Build the combined tool list: core tools + any external MCP tools
allTools := make([]fantasy.AgentTool, len(coreTools))
copy(allTools, coreTools)
if err := toolManager.LoadTools(ctx, agentConfig.MCPConfig); err != nil {
return nil, fmt.Errorf("failed to load MCP tools: %v", err)
// Load external MCP tools if configured
var toolManager *tools.MCPToolManager
if agentConfig.MCPConfig != nil && len(agentConfig.MCPConfig.MCPServers) > 0 {
toolManager = tools.NewMCPToolManager()
toolManager.SetModel(providerResult.Model)
if agentConfig.DebugLogger != nil {
toolManager.SetDebugLogger(agentConfig.DebugLogger)
}
if err := toolManager.LoadTools(ctx, agentConfig.MCPConfig); err != nil {
// MCP tool loading failures are non-fatal; core tools still work
fmt.Printf("Warning: Failed to load MCP tools: %v\n", err)
} else {
mcpTools := toolManager.GetTools()
allTools = append(allTools, mcpTools...)
}
}
// Build fantasy agent options
@@ -93,10 +116,8 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(agentConfig.SystemPrompt))
}
// Register all MCP tools with the fantasy agent
mcpTools := toolManager.GetTools()
if len(mcpTools) > 0 {
agentOpts = append(agentOpts, fantasy.WithTools(mcpTools...))
if len(allTools) > 0 {
agentOpts = append(agentOpts, fantasy.WithTools(allTools...))
}
// Set max steps as stop condition
@@ -127,6 +148,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
loadingMessage: providerResult.Message,
providerType: providerType,
streamingEnabled: agentConfig.StreamingEnabled,
coreTools: coreTools,
}, nil
}
@@ -306,25 +328,33 @@ func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.Messag
}
// convertAgentResult converts a fantasy AgentResult to our GenerateWithLoopResult.
// It builds both the legacy fantasy.Message slice and the new custom content blocks.
func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.Message) *GenerateWithLoopResult {
// Collect all conversation messages: original + all step messages
var allMessages []fantasy.Message
allMessages = append(allMessages, originalMessages...)
var allFantasyMessages []fantasy.Message
allFantasyMessages = append(allFantasyMessages, originalMessages...)
for _, step := range result.Steps {
allMessages = append(allMessages, step.Messages...)
allFantasyMessages = append(allFantasyMessages, step.Messages...)
}
// Convert to custom content blocks
var allMessages []message.Message
for _, fm := range allFantasyMessages {
allMessages = append(allMessages, message.FromFantasyMessage(fm))
}
return &GenerateWithLoopResult{
FinalResponse: &result.Response,
ConversationMessages: allMessages,
ConversationMessages: allFantasyMessages,
Messages: allMessages,
TotalUsage: result.TotalUsage,
}
}
// extractToolResultText extracts the text and error status from a fantasy ToolResultContent.
// It unwraps the Fantasy type layer and parses the MCP content structure to
// return human-readable text rather than raw JSON.
// For core tools, the result is already clean text (no MCP JSON wrapping).
// For MCP tools, it unwraps the MCP content structure.
func extractToolResultText(tr fantasy.ToolResultContent) (string, bool) {
if tr.Result == nil {
return "", false
@@ -335,20 +365,15 @@ func extractToolResultText(tr fantasy.ToolResultContent) (string, bool) {
return errResult.Error.Error(), true
}
// Get text directly from the Fantasy result type — avoids JSON round-trip.
// Get text directly from the Fantasy result type.
if textResult, ok := tr.Result.(fantasy.ToolResultOutputContentText); ok {
// The text typically contains a JSON-encoded MCP CallToolResult
// (e.g. {"content":[{"type":"text","text":"..."}]}). Extract the
// human-readable text from that structure.
// Try to unwrap MCP JSON structure (for external MCP tools).
// Core tools return plain text, so this is a no-op for them.
return extractMCPContentText(textResult.Text), false
}
// Fallback: marshal to JSON for display.
resultBytes, err := json.Marshal(tr.Result)
if err != nil {
return fmt.Sprintf("%v", tr.Result), false
}
return string(resultBytes), false
// Fallback: stringify for display.
return fmt.Sprintf("%v", tr.Result), false
}
// extractMCPContentText attempts to parse an MCP tool result JSON string
@@ -356,16 +381,24 @@ func extractToolResultText(tr fantasy.ToolResultContent) (string, bool) {
// format is: {"content":[{"type":"text","text":"..."}], "_meta":{...}}
// If parsing fails the original string is returned unchanged.
func extractMCPContentText(result string) string {
var mcpResult struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
// Quick check: if it doesn't look like MCP JSON, return as-is
if !strings.HasPrefix(strings.TrimSpace(result), "{") {
return result
}
if err := json.Unmarshal([]byte(result), &mcpResult); err == nil {
// Try to parse as MCP result structure
type mcpContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
type mcpResult struct {
Content []mcpContent `json:"content"`
}
var parsed mcpResult
if err := json.Unmarshal([]byte(result), &parsed); err == nil && len(parsed.Content) > 0 {
var texts []string
for _, c := range mcpResult.Content {
for _, c := range parsed.Content {
if c.Type == "text" && c.Text != "" {
texts = append(texts, c.Text)
}
@@ -380,7 +413,12 @@ func extractMCPContentText(result string) string {
// GetTools returns the list of available tools loaded in the agent.
func (a *Agent) GetTools() []fantasy.AgentTool {
return a.toolManager.GetTools()
allTools := make([]fantasy.AgentTool, len(a.coreTools))
copy(allTools, a.coreTools)
if a.toolManager != nil {
allTools = append(allTools, a.toolManager.GetTools()...)
}
return allTools
}
// GetLoadingMessage returns the loading message from provider creation.
@@ -390,6 +428,9 @@ func (a *Agent) GetLoadingMessage() string {
// GetLoadedServerNames returns the names of successfully loaded MCP servers.
func (a *Agent) GetLoadedServerNames() []string {
if a.toolManager == nil {
return nil
}
return a.toolManager.GetLoadedServerNames()
}
@@ -400,7 +441,10 @@ func (a *Agent) GetModel() fantasy.LanguageModel {
// Close closes the agent and cleans up resources.
func (a *Agent) Close() error {
toolErr := a.toolManager.Close()
var toolErr error
if a.toolManager != nil {
toolErr = a.toolManager.Close()
}
if a.providerCloser != nil {
if err := a.providerCloser.Close(); err != nil && toolErr == nil {
toolErr = err
-190
View File
@@ -1,190 +0,0 @@
package builtin
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
maxOutputLength = 30000
defaultTimeout = 2 * time.Minute
maxTimeout = 10 * time.Minute
)
var bannedCommands = []string{
"alias",
"curl",
"curlie",
"wget",
"axel",
"aria2c",
"nc",
"telnet",
"lynx",
"w3m",
"links",
"httpie",
"xh",
"http-prompt",
"chrome",
"firefox",
"safari",
}
// NewBashServer creates a new MCP server that provides bash command execution capabilities.
// The server includes a single tool "run_shell_cmd" that executes shell commands with
// security restrictions, timeout controls, and output truncation. Returns an error if
// server initialization fails.
func NewBashServer() (*server.MCPServer, error) {
s := server.NewMCPServer("bash-server", "1.0.0", server.WithToolCapabilities(true))
// Register the run_shell_cmd tool using the builder pattern
bashTool := mcp.NewTool("run_shell_cmd",
mcp.WithDescription(bashDescription),
mcp.WithString("command",
mcp.Required(),
mcp.Description("The command to execute"),
),
mcp.WithNumber("timeout",
mcp.Description("Optional timeout in milliseconds"),
mcp.Min(0),
mcp.Max(600000),
),
mcp.WithString("description",
mcp.Required(),
mcp.Description("Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'"),
),
)
s.AddTool(bashTool, executeBash)
return s, nil
}
// executeBash executes a bash command with security restrictions
func executeBash(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Extract parameters using the helper methods
command, err := request.RequireString("command")
if err != nil {
return mcp.NewToolResultError("command parameter is required and must be a string"), nil
}
description, err := request.RequireString("description")
if err != nil {
return mcp.NewToolResultError("description parameter is required and must be a string"), nil
}
// Parse timeout (optional)
timeout := defaultTimeout
if timeoutMs := request.GetFloat("timeout", 0); timeoutMs > 0 {
timeoutDuration := time.Duration(timeoutMs) * time.Millisecond
timeout = min(timeoutDuration, maxTimeout)
}
// Check for banned commands
for _, banned := range bannedCommands {
if strings.HasPrefix(command, banned) {
return mcp.NewToolResultError(fmt.Sprintf("Command '%s' is not allowed", command)), nil
}
}
// Create context with timeout
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Execute the command
cmd := exec.CommandContext(cmdCtx, "bash", "-c", command)
// Capture both stdout and stderr
output, err := cmd.CombinedOutput()
// Truncate output if too long
outputStr := string(output)
if len(outputStr) > maxOutputLength {
outputStr = outputStr[:maxOutputLength] + "\n... (output truncated)"
}
// Prepare the result
var stdout, stderr string
if err != nil {
// If there's an error, treat the output as stderr
stderr = outputStr
if _, ok := err.(*exec.ExitError); ok {
// Command ran but exited with non-zero status
stdout = ""
} else {
// Command failed to start or other error
stderr = fmt.Sprintf("Failed to execute command: %v\n%s", err, outputStr)
}
} else {
// Command succeeded
stdout = outputStr
stderr = ""
}
// Format output similar to the TypeScript version
result := fmt.Sprintf("<stdout>\n%s\n</stdout>\n<stderr>\n%s\n</stderr>", stdout, stderr)
// Get exit code
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
exitCode = 1
}
}
// Create result with metadata
toolResult := mcp.NewToolResultText(result)
toolResult.Meta = &mcp.Meta{
AdditionalFields: map[string]any{
"stderr": stderr,
"stdout": stdout,
"exit": exitCode,
"description": description,
"title": command,
},
}
return toolResult, nil
}
const bashDescription = `Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
Before executing the command, please follow these steps:
1. Directory Verification:
- If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
- For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
2. Command Execution:
- Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
- Examples of proper quoting:
- cd "/Users/name/My Documents" (correct)
- cd /Users/name/My Documents (incorrect - will fail)
- python "/path/with spaces/script.py" (correct)
- python /path/with spaces/script.py (incorrect - will fail)
- After ensuring proper quoting, execute the command.
- Capture the output of the command.
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- If the output exceeds 30000 characters, output will be truncated before being returned to you.
- VERY IMPORTANT: You MUST avoid using search commands like find and grep. Instead use Grep, Glob, or Task to search. You MUST avoid read tools like cat, head, tail, and ls, and use Read and LS to read files.
- If you _still_ need to run grep, STOP. ALWAYS USE ripgrep at rg (or /usr/bin/rg) first, which all kit users have pre-installed.
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of cd. You may use cd if the User explicitly requests it.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>`
-148
View File
@@ -1,148 +0,0 @@
package builtin
import (
"context"
"slices"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestNewBashServer(t *testing.T) {
server, err := NewBashServer()
if err != nil {
t.Fatalf("Failed to create bash server: %v", err)
}
if server == nil {
t.Fatal("Expected server to be non-nil")
}
}
func TestBashServerRegistry(t *testing.T) {
registry := NewRegistry()
// Test that bash server is registered
servers := registry.ListServers()
found := slices.Contains(servers, "bash")
if !found {
t.Error("bash server not found in registry")
}
// Test creating bash server through registry
wrapper, err := registry.CreateServer("bash", map[string]any{}, nil)
if err != nil {
t.Fatalf("Failed to create bash server through registry: %v", err)
}
if wrapper == nil {
t.Fatal("Expected wrapper to be non-nil")
}
if wrapper.GetServer() == nil {
t.Fatal("Expected wrapped server to be non-nil")
}
}
func TestExecuteBash(t *testing.T) {
// Create a simple test request
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "run_shell_cmd",
Arguments: map[string]any{
"command": "echo 'Hello, World!'",
"description": "Test echo command",
},
},
}
ctx := context.Background()
result, err := executeBash(ctx, request)
if err != nil {
t.Fatalf("Failed to execute bash command: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Check that the result contains our expected output
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty text content")
}
} else {
t.Error("Expected text content")
}
}
func TestBashCommandValidation(t *testing.T) {
// Test banned command
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "run_shell_cmd",
Arguments: map[string]any{
"command": "curl http://example.com",
"description": "Test banned command",
},
},
}
ctx := context.Background()
result, err := executeBash(ctx, request)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Should return an error result, not fail
if result == nil {
t.Fatal("Expected result to be non-nil")
}
// Check that it's an error result
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty error message")
}
}
}
func TestToolNameChange(t *testing.T) {
// Test that the tool can be called with the new name "run_shell_cmd"
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "run_shell_cmd", // This should be the new name
Arguments: map[string]any{
"command": "echo 'test'",
"description": "Test renamed tool",
},
},
}
ctx := context.Background()
result, err := executeBash(ctx, request)
if err != nil {
t.Fatalf("Failed to execute renamed tool: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
// Verify the tool executed successfully
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
}
-267
View File
@@ -1,267 +0,0 @@
package builtin
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/JohannesKaufmann/html-to-markdown"
"github.com/PuerkitoBio/goquery"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
maxResponseSize = 5 * 1024 * 1024 // 5MB
defaultFetchTimeout = 30 * time.Second
maxFetchTimeout = 120 * time.Second
)
// NewFetchServer creates a new MCP server that provides web content fetching capabilities.
// The server includes a single tool "fetch" that retrieves content from URLs and converts
// it to text, markdown, or HTML format. Returns an error if server initialization fails.
func NewFetchServer() (*server.MCPServer, error) {
s := server.NewMCPServer("fetch-server", "1.0.0", server.WithToolCapabilities(true))
// Register the fetch tool
fetchTool := mcp.NewTool("fetch",
mcp.WithDescription(fetchDescription),
mcp.WithString("url",
mcp.Required(),
mcp.Description("The URL to fetch content from"),
),
mcp.WithString("format",
mcp.Required(),
mcp.Enum("text", "markdown", "html"),
mcp.Description("The format to return the content in (text, markdown, or html)"),
),
mcp.WithNumber("timeout",
mcp.Description("Optional timeout in seconds (max 120)"),
mcp.Min(0),
mcp.Max(120),
),
)
s.AddTool(fetchTool, executeFetch)
return s, nil
}
// executeFetch handles the fetch tool execution
func executeFetch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Extract parameters
urlStr, err := request.RequireString("url")
if err != nil {
return mcp.NewToolResultError("url parameter is required and must be a string"), nil
}
format, err := request.RequireString("format")
if err != nil {
return mcp.NewToolResultError("format parameter is required and must be a string"), nil
}
// Validate format
if format != "text" && format != "markdown" && format != "html" {
return mcp.NewToolResultError("format must be 'text', 'markdown', or 'html'"), nil
}
// Parse timeout (optional)
timeout := defaultFetchTimeout
if timeoutSec := request.GetFloat("timeout", 0); timeoutSec > 0 {
timeoutDuration := time.Duration(timeoutSec) * time.Second
timeout = min(timeoutDuration, maxFetchTimeout)
}
// Validate URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid URL: %v", err)), nil
}
// Ensure URL has a scheme
if parsedURL.Scheme == "" {
urlStr = "https://" + urlStr
parsedURL, err = url.Parse(urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid URL after adding https: %v", err)), nil
}
}
// Only allow HTTP and HTTPS
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return mcp.NewToolResultError("URL must use http:// or https://"), nil
}
// Upgrade HTTP to HTTPS only for external URLs (not localhost/127.0.0.1)
if parsedURL.Scheme == "http" && !isLocalhost(parsedURL.Host) {
parsedURL.Scheme = "https"
urlStr = parsedURL.String()
}
// 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 mcp.NewToolResultError(fmt.Sprintf("failed to create request: %v", err)), nil
}
// 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 mcp.NewToolResultError(fmt.Sprintf("request failed: %v", err)), nil
}
defer func() { _ = resp.Body.Close() }()
// Check status code
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return mcp.NewToolResultError(fmt.Sprintf("request failed with status code: %d", resp.StatusCode)), nil
}
// Check content length
if resp.ContentLength > maxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
// Read response body with size limit
limitedReader := io.LimitReader(resp.Body, maxResponseSize+1)
bodyBytes, err := io.ReadAll(limitedReader)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to read response: %v", err)), nil
}
// Check if we exceeded the size limit
if len(bodyBytes) > maxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
content := string(bodyBytes)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "unknown"
}
// Process content based on format
var output string
switch format {
case "text":
if strings.Contains(contentType, "text/html") {
output, err = extractTextFromHTML(content)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to extract text from HTML: %v", err)), nil
}
} else {
output = content
}
case "markdown":
if strings.Contains(contentType, "text/html") {
output, err = convertHTMLToMarkdown(content)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to convert HTML to markdown: %v", err)), nil
}
} else {
output = "```\n" + content + "\n```"
}
case "html":
output = content
default:
output = content
}
// Create result with metadata
title := fmt.Sprintf("%s (%s)", urlStr, contentType)
result := mcp.NewToolResultText(output)
result.Meta = &mcp.Meta{
AdditionalFields: map[string]any{
"title": title,
},
}
return result, nil
}
// extractTextFromHTML extracts plain text from HTML content
func extractTextFromHTML(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
}
// convertHTMLToMarkdown converts HTML content to markdown
func convertHTMLToMarkdown(htmlContent string) (string, error) {
converter := md.NewConverter("", true, nil)
// Remove unwanted elements
converter.Remove("script")
converter.Remove("style")
converter.Remove("meta")
converter.Remove("link")
markdown, err := converter.ConvertString(htmlContent)
if err != nil {
return "", err
}
return markdown, nil
}
// isLocalhost checks if the host is localhost or 127.0.0.1
func isLocalhost(host string) bool {
return strings.HasPrefix(host, "localhost") ||
strings.HasPrefix(host, "127.0.0.1") ||
strings.HasPrefix(host, "::1")
}
const fetchDescription = `Fetches content from a specified URL and returns it in the requested format.
- Fetches content from a specified URL
- Takes a URL and format as input
- Fetches the URL content, converts HTML to markdown or text as requested
- Returns the content in the specified format
- Use this tool when you need to retrieve and analyze web content
Usage notes:
- IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".
- The URL must be a fully-formed valid URL
- HTTP URLs will be automatically upgraded to HTTPS
- This tool is read-only and does not modify any files
- Results may be summarized if the content is very large (max 5MB)
- Supports three output formats:
- "text": Plain text extraction from HTML, or raw content for non-HTML
- "markdown": HTML converted to markdown, or code-wrapped for non-HTML
- "html": Raw HTML content
- Timeout can be specified in seconds (default 30s, max 120s)`
-360
View File
@@ -1,360 +0,0 @@
package builtin
import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestNewFetchServer(t *testing.T) {
server, err := NewFetchServer()
if err != nil {
t.Fatalf("Failed to create fetch server: %v", err)
}
if server == nil {
t.Fatal("Expected server to be non-nil")
}
}
func TestFetchServerRegistry(t *testing.T) {
registry := NewRegistry()
// Test that fetch server is registered
servers := registry.ListServers()
found := slices.Contains(servers, "fetch")
if !found {
t.Error("fetch server not found in registry")
}
// Test creating fetch server through registry
wrapper, err := registry.CreateServer("fetch", map[string]any{}, nil)
if err != nil {
t.Fatalf("Failed to create fetch server through registry: %v", err)
}
if wrapper == nil {
t.Fatal("Expected wrapper to be non-nil")
}
if wrapper.GetServer() == nil {
t.Fatal("Expected wrapped server to be non-nil")
}
}
func TestFetchHTML(t *testing.T) {
// Create a test HTTP server
testHTML := `<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
<script>console.log('test');</script>
<style>body { color: red; }</style>
</head>
<body>
<h1>Hello World</h1>
<p>This is a test paragraph.</p>
<script>alert('should be removed');</script>
</body>
</html>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(testHTML))
}))
defer server.Close()
// Test HTML format
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": server.URL,
"format": "html",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Failed to execute fetch: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Check that we got HTML content
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty HTML content")
}
// Should contain the original HTML
if !strings.Contains(textContent.Text, "<h1>Hello World</h1>") {
t.Error("Expected HTML content to contain original markup")
}
} else {
t.Error("Expected text content")
}
}
func TestFetchText(t *testing.T) {
// Create a test HTTP server
testHTML := `<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
<script>console.log('test');</script>
<style>body { color: red; }</style>
</head>
<body>
<h1>Hello World</h1>
<p>This is a test paragraph.</p>
<script>alert('should be removed');</script>
</body>
</html>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(testHTML))
}))
defer server.Close()
// Test text format
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": server.URL,
"format": "text",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Failed to execute fetch: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Check that we got text content
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty text content")
}
// Should contain the text but not HTML tags
if !strings.Contains(textContent.Text, "Hello World") {
t.Error("Expected text content to contain 'Hello World'")
}
if strings.Contains(textContent.Text, "<h1>") {
t.Error("Expected text content to not contain HTML tags")
}
if strings.Contains(textContent.Text, "console.log") {
t.Error("Expected text content to not contain script content")
}
} else {
t.Error("Expected text content")
}
}
func TestFetchMarkdown(t *testing.T) {
// Create a test HTTP server
testHTML := `<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a test paragraph.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</body>
</html>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(testHTML))
}))
defer server.Close()
// Test markdown format
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": server.URL,
"format": "markdown",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Failed to execute fetch: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Check that we got markdown content
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty markdown content")
}
// Should contain markdown formatting
if !strings.Contains(textContent.Text, "# Hello World") {
t.Error("Expected markdown content to contain '# Hello World'")
}
} else {
t.Error("Expected text content")
}
}
func TestFetchInvalidURL(t *testing.T) {
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": "not-a-valid-url",
"format": "text",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Should return an error result
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty error message")
}
}
}
func TestFetchInvalidFormat(t *testing.T) {
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": "https://example.com",
"format": "invalid",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Should return an error result
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty error message")
}
}
}
func TestFetchPlainText(t *testing.T) {
// Create a test HTTP server that returns plain text
testText := "This is plain text content.\nWith multiple lines.\nAnd some more text."
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(testText))
}))
defer server.Close()
// Test text format with plain text content
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": server.URL,
"format": "text",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Failed to execute fetch: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Check that we got the original text content
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text != testText {
t.Errorf("Expected '%s', got '%s'", testText, textContent.Text)
}
} else {
t.Error("Expected text content")
}
}
-632
View File
@@ -1,632 +0,0 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
md "github.com/JohannesKaufmann/html-to-markdown"
"github.com/PuerkitoBio/goquery"
"github.com/tidwall/gjson"
"charm.land/fantasy"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
httpMaxResponseSize = 5 * 1024 * 1024 // 5MB
httpDefaultFetchTimeout = 30 * time.Second
httpMaxFetchTimeout = 120 * time.Second
)
// httpServerModel holds the model for the HTTP server
var httpServerModel fantasy.LanguageModel
// NewHTTPServer creates a new MCP server providing advanced HTTP fetching capabilities.
// The server includes tools for fetching web content, summarizing pages, extracting
// specific information, and filtering JSON responses. If an LLM model is provided,
// AI-powered summarization and extraction tools are enabled. Returns an error if
// server initialization fails.
func NewHTTPServer(llmModel fantasy.LanguageModel) (*server.MCPServer, error) {
httpServerModel = llmModel
s := server.NewMCPServer("http-server", "1.0.0", server.WithToolCapabilities(true))
// Register the fetch tool
fetchTool := mcp.NewTool("fetch",
mcp.WithDescription(httpFetchDescription),
mcp.WithString("url",
mcp.Required(),
mcp.Description("The URL to fetch content from"),
),
mcp.WithString("format",
mcp.Required(),
mcp.Enum("html", "markdown"),
mcp.Description("The format to return the content in (html or markdown)"),
),
mcp.WithBoolean("bodyOnly",
mcp.Description("Extract only the <body> tag content (default: false)"),
),
mcp.WithNumber("timeout",
mcp.Description("Optional timeout in seconds (max 120)"),
mcp.Min(0),
mcp.Max(120),
),
)
s.AddTool(fetchTool, executeHTTPFetch)
// Only add AI-powered tools 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)
extractTool := mcp.NewTool("fetch_extract",
mcp.WithDescription(httpExtractDescription),
mcp.WithString("url",
mcp.Required(),
mcp.Description("The URL to fetch and extract data from"),
),
mcp.WithString("instructions",
mcp.Required(),
mcp.Description("Specific extraction instructions (e.g., 'Extract all product names and prices', 'Get the main article content', 'Find all email addresses')"),
),
)
s.AddTool(extractTool, executeHTTPFetchExtract)
filterJSONTool := mcp.NewTool("fetch_filtered_json",
mcp.WithDescription(httpFilterJSONDescription),
mcp.WithString("url",
mcp.Required(),
mcp.Description("The URL to fetch JSON content from"),
),
mcp.WithString("path",
mcp.Required(),
mcp.Description("The gjson path expression to filter the JSON (e.g., 'users.#.name', 'data.items.0', 'results.#(age>25).name')"),
),
mcp.WithNumber("timeout",
mcp.Description("Optional timeout in seconds (max 120)"),
mcp.Min(0),
mcp.Max(120),
),
)
s.AddTool(filterJSONTool, executeHTTPFetchFilteredJSON)
}
return s, nil
}
// executeHTTPFetch handles the fetch tool execution
func executeHTTPFetch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
urlStr, err := request.RequireString("url")
if err != nil {
return mcp.NewToolResultError("url parameter is required and must be a string"), nil
}
format, err := request.RequireString("format")
if err != nil {
return mcp.NewToolResultError("format parameter is required and must be a string"), nil
}
if format != "html" && format != "markdown" {
return mcp.NewToolResultError("format must be 'html' or 'markdown'"), nil
}
bodyOnly := request.GetBool("bodyOnly", false)
timeout := httpDefaultFetchTimeout
if timeoutSec := request.GetFloat("timeout", 0); timeoutSec > 0 {
timeoutDuration := time.Duration(timeoutSec) * time.Second
timeout = min(timeoutDuration, httpMaxFetchTimeout)
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid URL: %v", err)), nil
}
if parsedURL.Scheme == "" {
urlStr = "https://" + urlStr
parsedURL, err = url.Parse(urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid URL after adding https: %v", err)), nil
}
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return mcp.NewToolResultError("URL must use http:// or https://"), nil
}
client := &http.Client{
Timeout: timeout,
}
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to create request: %v", err)), nil
}
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")
resp, err := client.Do(req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("request failed: %v", err)), nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return mcp.NewToolResultError(fmt.Sprintf("request failed with status code: %d", resp.StatusCode)), nil
}
if resp.ContentLength > httpMaxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
limitedReader := io.LimitReader(resp.Body, httpMaxResponseSize+1)
bodyBytes, err := io.ReadAll(limitedReader)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to read response: %v", err)), nil
}
if len(bodyBytes) > httpMaxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
content := string(bodyBytes)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "unknown"
}
if bodyOnly && strings.Contains(contentType, "text/html") {
content, err = extractBodyContent(content)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to extract body content: %v", err)), nil
}
}
var output string
switch format {
case "html":
output = content
case "markdown":
if strings.Contains(contentType, "text/html") {
output, err = httpConvertHTMLToMarkdown(content)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to convert HTML to markdown: %v", err)), nil
}
} else {
output = "```\n" + content + "\n```"
}
}
title := fmt.Sprintf("%s (%s)", urlStr, contentType)
result := mcp.NewToolResultText(output)
result.Meta = &mcp.Meta{
AdditionalFields: map[string]any{
"title": title,
"url": urlStr,
"contentType": contentType,
"bodyOnly": bodyOnly,
},
}
return result, nil
}
// extractBodyContent extracts only the <body> tag content from HTML
func extractBodyContent(htmlContent string) (string, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
if err != nil {
return "", err
}
bodySelection := doc.Find("body")
if bodySelection.Length() == 0 {
return htmlContent, nil
}
bodyHTML, err := bodySelection.Html()
if err != nil {
return "", err
}
return bodyHTML, nil
}
// httpConvertHTMLToMarkdown converts HTML content to markdown
func httpConvertHTMLToMarkdown(htmlContent string) (string, error) {
converter := md.NewConverter("", true, nil)
converter.Remove("script")
converter.Remove("style")
converter.Remove("meta")
converter.Remove("link")
converter.Remove("noscript")
markdown, err := converter.ConvertString(htmlContent)
if err != nil {
return "", err
}
return markdown, nil
}
// executeHTTPFetchSummarize handles the fetch_summarize tool execution
func executeHTTPFetchSummarize(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
urlStr, err := request.RequireString("url")
if err != nil {
return mcp.NewToolResultError("url parameter is required and must be a string"), nil
}
instructions := request.GetString("instructions", "Provide a concise summary of this content.")
content, err := httpFetchAndExtractText(ctx, urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch content: %v", err)), nil
}
if httpServerModel == nil {
return mcp.NewToolResultError("LLM model not available for summarization"), nil
}
// Use fantasy model for summarization
call := fantasy.Call{
Prompt: fantasy.Prompt{
fantasy.NewUserMessage(fmt.Sprintf("%s\n\nContent to summarize:\n%s", instructions, content)),
},
}
response, err := httpServerModel.Generate(ctx, call)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Summarization failed: %v", err)), nil
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: response.Content.Text(),
},
},
}, nil
}
// executeHTTPFetchExtract handles the fetch_extract tool execution
func executeHTTPFetchExtract(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
urlStr, err := request.RequireString("url")
if err != nil {
return mcp.NewToolResultError("url parameter is required and must be a string"), nil
}
instructions, err := request.RequireString("instructions")
if err != nil {
return mcp.NewToolResultError("instructions parameter is required and must be a string"), nil
}
content, err := httpFetchAndExtractText(ctx, urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch content: %v", err)), nil
}
if httpServerModel == nil {
return mcp.NewToolResultError("LLM model not available for extraction"), nil
}
extractionPrompt := fmt.Sprintf(`Extract the requested information from the following web content.
Extraction Instructions: %s
Web Content:
%s
Please extract only the requested information. If the requested information is not found, respond with "Information not found" and explain what was searched for.`, instructions, content)
call := fantasy.Call{
Prompt: fantasy.Prompt{
fantasy.NewUserMessage(extractionPrompt),
},
}
response, err := httpServerModel.Generate(ctx, call)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Extraction failed: %v", err)), nil
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: response.Content.Text(),
},
},
}, nil
}
// httpFetchAndExtractText fetches content from URL and extracts as text
func httpFetchAndExtractText(ctx context.Context, urlStr string) (string, error) {
timeout := httpDefaultFetchTimeout
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL: %v", err)
}
if parsedURL.Scheme == "" {
urlStr = "https://" + urlStr
parsedURL, err = url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL after adding https: %v", err)
}
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return "", fmt.Errorf("URL must use http:// or https://")
}
client := &http.Client{
Timeout: timeout,
}
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %v", err)
}
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")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("request failed with status code: %d", resp.StatusCode)
}
if resp.ContentLength > httpMaxResponseSize {
return "", fmt.Errorf("response too large (exceeds 5MB 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)
}
if len(bodyBytes) > httpMaxResponseSize {
return "", fmt.Errorf("response too large (exceeds 5MB limit)")
}
content := string(bodyBytes)
contentType := resp.Header.Get("Content-Type")
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
}
doc.Find("script, style, noscript, iframe, object, embed").Remove()
text := doc.Text()
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
}
// executeHTTPFetchFilteredJSON handles the fetch_filtered_json tool execution
func executeHTTPFetchFilteredJSON(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
urlStr, err := request.RequireString("url")
if err != nil {
return mcp.NewToolResultError("url parameter is required and must be a string"), nil
}
path, err := request.RequireString("path")
if err != nil {
return mcp.NewToolResultError("path parameter is required and must be a string"), nil
}
timeout := httpDefaultFetchTimeout
if timeoutSec := request.GetFloat("timeout", 0); timeoutSec > 0 {
timeoutDuration := time.Duration(timeoutSec) * time.Second
timeout = min(timeoutDuration, httpMaxFetchTimeout)
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid URL: %v", err)), nil
}
if parsedURL.Scheme == "" {
urlStr = "https://" + urlStr
parsedURL, err = url.Parse(urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid URL after adding https: %v", err)), nil
}
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return mcp.NewToolResultError("URL must use http:// or https://"), nil
}
client := &http.Client{
Timeout: timeout,
}
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to create request: %v", err)), nil
}
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", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
resp, err := client.Do(req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("request failed: %v", err)), nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return mcp.NewToolResultError(fmt.Sprintf("request failed with status code: %d", resp.StatusCode)), nil
}
if resp.ContentLength > httpMaxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
limitedReader := io.LimitReader(resp.Body, httpMaxResponseSize+1)
bodyBytes, err := io.ReadAll(limitedReader)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to read response: %v", err)), nil
}
if len(bodyBytes) > httpMaxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
content := string(bodyBytes)
if !json.Valid(bodyBytes) {
return mcp.NewToolResultError("response is not valid JSON"), nil
}
result := gjson.Get(content, path)
if !result.Exists() {
return mcp.NewToolResultError(fmt.Sprintf("gjson path '%s' did not match any data", path)), nil
}
var filteredJSON string
if result.IsArray() || result.IsObject() {
filteredJSON = result.Raw
} else {
if result.Type == gjson.String {
filteredJSON = fmt.Sprintf(`"%s"`, result.Str)
} else {
filteredJSON = result.Raw
}
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/json"
}
title := fmt.Sprintf("Filtered JSON from %s (path: %s)", urlStr, path)
mcpResult := mcp.NewToolResultText(filteredJSON)
mcpResult.Meta = &mcp.Meta{
AdditionalFields: map[string]any{
"title": title,
"url": urlStr,
"contentType": contentType,
"gjsonPath": path,
"resultType": result.Type.String(),
},
}
return mcpResult, nil
}
const httpFetchDescription = `Performs HTTP GET requests and returns content in HTML or Markdown format.
- Fetches content from a specified URL using HTTP GET
- Returns content in either original HTML or converted Markdown format
- Can optionally extract only the <body> tag content to reduce text size
- Supports custom timeout configuration
Usage notes:
- The URL must be a fully-formed valid URL
- Only HTTP GET requests are supported
- Maximum response size is 5MB
- Supports two output formats:
- "html": Raw HTML content
- "markdown": HTML converted to markdown format
- Use bodyOnly=true to extract only the <body> 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`
const httpExtractDescription = `Fetches web content and extracts specific data or sections using AI-powered extraction.
- Fetches content from a specified URL using HTTP GET
- Uses the client's LLM to extract specific information based on instructions
- Supports flexible extraction instructions for various data types
- Returns only the requested information from the web 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 processing
- Instructions should be specific (e.g., "Extract all product names and prices", "Get the main article content", "Find all email addresses")
- Returns "Information not found" if the requested data is not available
- Ideal for structured data extraction, content parsing, and targeted information retrieval`
const httpFilterJSONDescription = `Fetches JSON content from a URL and applies gjson path filtering to extract specific data.
- Fetches JSON content from a specified URL using HTTP GET
- Uses gjson path syntax to filter and extract specific parts of the JSON
- Returns filtered JSON results based on the provided path expression
- Supports all gjson features: wildcards, arrays, queries, modifiers, and more
Usage notes:
- The URL must return valid JSON content
- Uses gjson path syntax for filtering (see https://github.com/tidwall/gjson/blob/master/SYNTAX.md)
- Common path examples:
- "users.#.name" - Get all user names from an array
- "data.items.0" - Get the first item from data.items array
- "results.#(age>25).name" - Get names where age > 25
- "friends.#(last==\"Murphy\")#.first" - Get first names of all Murphys
- "@reverse" - Reverse an array
- "users.#.{name,email}" - Create new objects with only name and email
- Returns error if path doesn't match any data
- Maximum response size is 5MB
- Timeout can be specified in seconds (default 30s, max 120s)`
-302
View File
@@ -1,302 +0,0 @@
package builtin
import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestNewHTTPServer(t *testing.T) {
server, err := NewHTTPServer(nil)
if err != nil {
t.Fatalf("Failed to create HTTP server: %v", err)
}
if server == nil {
t.Fatal("Expected server to be non-nil")
}
}
func TestHTTPServerRegistry(t *testing.T) {
registry := NewRegistry()
// Test that HTTP server is registered
servers := registry.ListServers()
found := slices.Contains(servers, "http")
if !found {
t.Error("http server not found in registry")
}
// Test creating HTTP server through registry
wrapper, err := registry.CreateServer("http", map[string]any{}, nil)
if err != nil {
t.Fatalf("Failed to create HTTP server through registry: %v", err)
}
if wrapper == nil {
t.Fatal("Expected wrapper to be non-nil")
}
if wrapper.GetServer() == nil {
t.Fatal("Expected wrapped server to be non-nil")
}
}
func TestExecuteHTTPFetch(t *testing.T) {
// Create a test HTTP server
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/html":
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<!DOCTYPE html>
<html>
<head><title>Test Page</title></head>
<body>
<h1>Hello World</h1>
<p>This is a test paragraph.</p>
</body>
</html>`))
case "/text":
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("This is plain text content"))
case "/large":
// Return content larger than 5MB
w.Header().Set("Content-Type", "text/plain")
largeContent := strings.Repeat("x", 6*1024*1024)
_, _ = w.Write([]byte(largeContent))
case "/error":
w.WriteHeader(http.StatusInternalServerError)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer testServer.Close()
tests := []struct {
name string
params map[string]any
expectError bool
checkResult func(t *testing.T, result *mcp.CallToolResult)
}{
{
name: "fetch HTML as HTML",
params: map[string]any{
"url": testServer.URL + "/html",
"format": "html",
},
expectError: false,
checkResult: func(t *testing.T, result *mcp.CallToolResult) {
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if !strings.Contains(textContent.Text, "<h1>Hello World</h1>") {
t.Error("Expected HTML content to contain h1 tag")
}
} else {
t.Error("Expected text content")
}
},
},
{
name: "fetch HTML as markdown",
params: map[string]any{
"url": testServer.URL + "/html",
"format": "markdown",
},
expectError: false,
checkResult: func(t *testing.T, result *mcp.CallToolResult) {
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if !strings.Contains(textContent.Text, "# Hello World") {
t.Error("Expected markdown content to contain heading")
}
} else {
t.Error("Expected text content")
}
},
},
{
name: "fetch HTML body only",
params: map[string]any{
"url": testServer.URL + "/html",
"format": "html",
"bodyOnly": true,
},
expectError: false,
checkResult: func(t *testing.T, result *mcp.CallToolResult) {
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
content := textContent.Text
if strings.Contains(content, "<html>") || strings.Contains(content, "<head>") {
t.Error("Expected body-only content to not contain html or head tags")
}
if !strings.Contains(content, "<h1>Hello World</h1>") {
t.Error("Expected body-only content to contain h1 tag")
}
} else {
t.Error("Expected text content")
}
},
},
{
name: "fetch plain text as markdown",
params: map[string]any{
"url": testServer.URL + "/text",
"format": "markdown",
},
expectError: false,
checkResult: func(t *testing.T, result *mcp.CallToolResult) {
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if !strings.Contains(textContent.Text, "```") {
t.Error("Expected plain text to be wrapped in code block for markdown")
}
} else {
t.Error("Expected text content")
}
},
},
{
name: "missing URL parameter",
params: map[string]any{
"format": "html",
},
expectError: true,
},
{
name: "missing format parameter",
params: map[string]any{
"url": testServer.URL + "/html",
},
expectError: true,
},
{
name: "invalid format",
params: map[string]any{
"url": testServer.URL + "/html",
"format": "invalid",
},
expectError: true,
},
{
name: "server error response",
params: map[string]any{
"url": testServer.URL + "/error",
"format": "html",
},
expectError: true,
},
{
name: "response too large",
params: map[string]any{
"url": testServer.URL + "/large",
"format": "html",
},
expectError: true,
},
{
name: "invalid URL",
params: map[string]any{
"url": "not-a-valid-url",
"format": "html",
},
expectError: true,
},
{
name: "with custom timeout",
params: map[string]any{
"url": testServer.URL + "/html",
"format": "html",
"timeout": 10,
},
expectError: false,
checkResult: func(t *testing.T, result *mcp.CallToolResult) {
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if !strings.Contains(textContent.Text, "<h1>Hello World</h1>") {
t.Error("Expected HTML content with custom timeout")
}
} else {
t.Error("Expected text content")
}
},
},
}
ctx := context.Background()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: tt.params,
},
}
result, err := executeHTTPFetch(ctx, request)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if tt.expectError {
if !result.IsError {
t.Error("Expected error result but got success")
}
} else {
if result.IsError {
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
t.Errorf("Expected success but got error: %v", textContent.Text)
} else {
t.Error("Expected error to have text content")
}
} else if tt.checkResult != nil {
tt.checkResult(t, result)
}
}
})
}
}
func TestExtractBodyContent(t *testing.T) {
tests := []struct {
name string
html string
expected string
}{
{
name: "extract body content",
html: `<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body>
<h1>Content</h1>
<p>Paragraph</p>
</body>
</html>`,
expected: "\n<h1>Content</h1>\n<p>Paragraph</p>\n\n",
},
{
name: "no body tag",
html: `<div>No body tag</div>`,
expected: `<div>No body tag</div>`,
},
{
name: "empty body",
html: `<html><body></body></html>`,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := extractBodyContent(tt.html)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if result != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, result)
}
})
}
}
-162
View File
@@ -1,162 +0,0 @@
package builtin
import (
"fmt"
"os"
"charm.land/fantasy"
"github.com/mark3labs/mcp-filesystem-server/filesystemserver"
"github.com/mark3labs/mcp-go/server"
)
// BuiltinServerWrapper wraps an external MCP server for builtin use, providing
// a consistent interface for all builtin servers regardless of their implementation.
type BuiltinServerWrapper struct {
server *server.MCPServer
}
// Initialize initializes the wrapped server. For builtin servers, this is typically
// a no-op as the server is initialized during creation. Returns an error if
// initialization fails.
func (w *BuiltinServerWrapper) Initialize() error {
return nil
}
// GetServer returns the wrapped MCP server instance that can be used to handle
// tool calls and other MCP protocol operations.
func (w *BuiltinServerWrapper) GetServer() *server.MCPServer {
return w.server
}
// Registry holds all available builtin servers and their factory functions.
// It provides a centralized registry for creating instances of builtin MCP servers
// with their respective configurations.
type Registry struct {
servers map[string]func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error)
}
// NewRegistry creates a new builtin server registry with all available builtin
// servers registered. The registry includes filesystem (fs), bash, todo, fetch,
// and HTTP servers.
func NewRegistry() *Registry {
r := &Registry{
servers: make(map[string]func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error)),
}
r.registerFilesystemServer()
r.registerBashServer()
r.registerTodoServer()
r.registerFetchServer()
r.registerHTTPServer()
return r
}
// CreateServer creates a new instance of a builtin server by name. The options
// parameter provides server-specific configuration, and the model parameter provides
// an optional LLM for AI-powered features. Returns an error if the server name
// is unknown or if creation fails.
func (r *Registry) CreateServer(name string, options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
factory, exists := r.servers[name]
if !exists {
return nil, fmt.Errorf("unknown builtin server: %s", name)
}
return factory(options, model)
}
// ListServers returns a list of all available builtin server names.
func (r *Registry) ListServers() []string {
names := make([]string, 0, len(r.servers))
for name := range r.servers {
names = append(names, name)
}
return names
}
// registerFilesystemServer registers the filesystem server
func (r *Registry) registerFilesystemServer() {
r.servers["fs"] = func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
var allowedDirs []string
if dirs, ok := options["allowed_directories"]; ok {
switch v := dirs.(type) {
case []string:
allowedDirs = v
case []any:
allowedDirs = make([]string, len(v))
for i, dir := range v {
if s, ok := dir.(string); ok {
allowedDirs[i] = s
} else {
return nil, fmt.Errorf("allowed_directories must be an array of strings")
}
}
case string:
allowedDirs = []string{v}
default:
return nil, fmt.Errorf("allowed_directories must be a string or array of strings")
}
} else {
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get current working directory: %v", err)
}
allowedDirs = []string{cwd}
}
server, err := filesystemserver.NewFilesystemServer(allowedDirs)
if err != nil {
return nil, fmt.Errorf("failed to create filesystem server: %v", err)
}
return &BuiltinServerWrapper{server: server}, nil
}
}
// registerBashServer registers the bash server
func (r *Registry) registerBashServer() {
r.servers["bash"] = func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
server, err := NewBashServer()
if err != nil {
return nil, fmt.Errorf("failed to create bash server: %v", err)
}
return &BuiltinServerWrapper{server: server}, nil
}
}
// registerTodoServer registers the todo server
func (r *Registry) registerTodoServer() {
r.servers["todo"] = func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
server, err := NewTodoServer()
if err != nil {
return nil, fmt.Errorf("failed to create todo server: %v", err)
}
return &BuiltinServerWrapper{server: server}, nil
}
}
// registerFetchServer registers the fetch server
func (r *Registry) registerFetchServer() {
r.servers["fetch"] = func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
server, err := NewFetchServer()
if err != nil {
return nil, fmt.Errorf("failed to create fetch server: %v", err)
}
return &BuiltinServerWrapper{server: server}, nil
}
}
// registerHTTPServer registers the HTTP server
func (r *Registry) registerHTTPServer() {
r.servers["http"] = func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
server, err := NewHTTPServer(model)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP server: %v", err)
}
return &BuiltinServerWrapper{server: server}, nil
}
}
-251
View File
@@ -1,251 +0,0 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// TodoInfo represents a single todo item with content, status, priority, and ID.
// Status can be "pending", "in_progress", or "completed". Priority can be "high",
// "medium", or "low". Each todo must have a unique ID.
type TodoInfo struct {
Content string `json:"content"`
Status string `json:"status"`
Priority string `json:"priority"`
ID string `json:"id"`
}
// TodoServer implements a todo management MCP server with in-memory storage.
// It provides thread-safe operations for reading and writing todo lists, with
// support for task status tracking and priority levels.
type TodoServer struct {
todos []TodoInfo
mutex sync.RWMutex
}
// NewTodoServer creates a new MCP server that provides todo list management capabilities.
// The server includes two tools: "todowrite" for updating the todo list and "todoread"
// for retrieving the current list. Todos are stored in memory and not persisted.
// Returns an error if server initialization fails.
func NewTodoServer() (*server.MCPServer, error) {
todoServer := &TodoServer{
todos: make([]TodoInfo, 0),
}
s := server.NewMCPServer("todo-server", "1.0.0", server.WithToolCapabilities(true))
// Register todowrite tool
todoWriteTool := mcp.NewTool("todowrite",
mcp.WithDescription(todoWriteDescription),
mcp.WithArray("todos",
mcp.Required(),
mcp.Description("The updated todo list"),
mcp.Items(map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{
"type": "string",
"minLength": 1,
"description": "Brief description of the task",
},
"status": map[string]any{
"type": "string",
"enum": []string{"pending", "in_progress", "completed"},
"description": "Current status of the task",
},
"priority": map[string]any{
"type": "string",
"enum": []string{"high", "medium", "low"},
"description": "Priority level of the task",
},
"id": map[string]any{
"type": "string",
"description": "Unique identifier for the todo item",
},
},
"required": []string{"content", "status", "priority", "id"},
}),
),
)
// Register todoread tool
todoReadTool := mcp.NewTool("todoread",
mcp.WithDescription("Use this tool to read your todo list"),
)
s.AddTool(todoWriteTool, todoServer.executeTodoWrite)
s.AddTool(todoReadTool, todoServer.executeTodoRead)
return s, nil
}
// getTodos retrieves all todos from memory
func (ts *TodoServer) getTodos() []TodoInfo {
ts.mutex.RLock()
defer ts.mutex.RUnlock()
// Return a copy to avoid race conditions
todos := make([]TodoInfo, len(ts.todos))
copy(todos, ts.todos)
return todos
}
// formatTodos returns a nice readable format for todos
func formatTodos(todos []TodoInfo) string {
if len(todos) == 0 {
return "\n\nNo todos"
}
var result strings.Builder
result.WriteString("\n\n")
for _, todo := range todos {
var checkbox string
switch todo.Status {
case "completed":
checkbox = "[X]"
case "in_progress":
checkbox = "[~]"
default: // pending
checkbox = "[ ]"
}
result.WriteString(fmt.Sprintf("%s %s\n", checkbox, todo.Content))
}
// Remove trailing newline
output := result.String()
if len(output) > 0 {
output = output[:len(output)-1]
}
return output
}
// setTodos stores todos in memory
func (ts *TodoServer) setTodos(todos []TodoInfo) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
ts.todos = make([]TodoInfo, len(todos))
copy(ts.todos, todos)
}
// executeTodoWrite handles the todowrite tool execution
func (ts *TodoServer) executeTodoWrite(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Parse todos from arguments
todosArg := request.GetArguments()["todos"]
if todosArg == nil {
return mcp.NewToolResultError("todos parameter is required"), nil
}
// Convert to JSON and back to ensure proper structure
todosJSON, err := json.Marshal(todosArg)
if err != nil {
return mcp.NewToolResultError("invalid todos format"), nil
}
var todos []TodoInfo
if err := json.Unmarshal(todosJSON, &todos); err != nil {
return mcp.NewToolResultError("invalid todos structure"), nil
}
// Validate todos
for i, todo := range todos {
if strings.TrimSpace(todo.Content) == "" {
return mcp.NewToolResultError(fmt.Sprintf("todo %d: content cannot be empty", i)), nil
}
if todo.Status != "pending" && todo.Status != "in_progress" && todo.Status != "completed" {
return mcp.NewToolResultError(fmt.Sprintf("todo %d: invalid status '%s'", i, todo.Status)), nil
}
if todo.Priority != "high" && todo.Priority != "medium" && todo.Priority != "low" {
return mcp.NewToolResultError(fmt.Sprintf("todo %d: invalid priority '%s'", i, todo.Priority)), nil
}
if strings.TrimSpace(todo.ID) == "" {
return mcp.NewToolResultError(fmt.Sprintf("todo %d: id cannot be empty", i)), nil
}
}
// Store todos in memory
ts.setTodos(todos)
// Format output in readable format
output := formatTodos(todos)
// Create result with formatted output
result := mcp.NewToolResultText(output)
result.Meta = &mcp.Meta{
AdditionalFields: map[string]any{
"todos": todos,
},
}
return result, nil
}
// executeTodoRead handles the todoread tool execution
func (ts *TodoServer) executeTodoRead(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Get todos from memory
todos := ts.getTodos()
// Format output in readable format
output := formatTodos(todos)
// Create result with formatted output
result := mcp.NewToolResultText(output)
result.Meta = &mcp.Meta{
AdditionalFields: map[string]any{
"todos": todos,
},
}
return result, nil
}
const todoWriteDescription = `Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
It also helps the user understand the progress of the task and overall progress of their requests.
## When to Use This Tool
Use this tool proactively in these scenarios:
1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
3. User explicitly requests todo list - When the user directly asks you to use the todo list
4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
5. After receiving new instructions - Immediately capture user requirements as todos. Feel free to edit the todo list based on new information.
6. After completing a task - Mark it complete and add any new follow-up tasks
7. When you start working on a new task, mark the todo as in_progress. Ideally you should only have one todo as in_progress at a time. Complete existing tasks before starting new ones.
## When NOT to Use This Tool
Skip using this tool when:
1. There is only a single, straightforward task
2. The task is trivial and tracking it provides no organizational benefit
3. The task can be completed in less than 3 trivial steps
4. The task is purely conversational or informational
NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
## Task States and Management
1. **Task States**: Use these states to track progress:
- pending: Task not yet started
- in_progress: Currently working on (limit to ONE task at a time)
- completed: Task finished successfully
2. **Task Management**:
- Update task status in real-time as you work
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
- Only have ONE task in_progress at any time
- Complete current tasks before starting new ones
3. **Task Breakdown**:
- Create specific, actionable items
- Break complex tasks into smaller, manageable steps
- Use clear, descriptive task names
When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.`
-296
View File
@@ -1,296 +0,0 @@
package builtin
import (
"context"
"slices"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestNewTodoServer(t *testing.T) {
server, err := NewTodoServer()
if err != nil {
t.Fatalf("Failed to create todo server: %v", err)
}
if server == nil {
t.Fatal("Expected server to be non-nil")
}
}
func TestTodoServerRegistry(t *testing.T) {
registry := NewRegistry()
// Test that todo server is registered
servers := registry.ListServers()
found := slices.Contains(servers, "todo")
if !found {
t.Error("todo server not found in registry")
}
// Test creating todo server through registry
wrapper, err := registry.CreateServer("todo", map[string]any{}, nil)
if err != nil {
t.Fatalf("Failed to create todo server through registry: %v", err)
}
if wrapper == nil {
t.Fatal("Expected wrapper to be non-nil")
}
if wrapper.GetServer() == nil {
t.Fatal("Expected wrapped server to be non-nil")
}
}
func TestTodoWrite(t *testing.T) {
server := &TodoServer{
todos: make([]TodoInfo, 0),
}
// Create a test request with valid todos
todos := []TodoInfo{
{
Content: "Test task 1",
Status: "pending",
Priority: "high",
ID: "1",
},
{
Content: "Test task 2",
Status: "in_progress",
Priority: "medium",
ID: "2",
},
}
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "todowrite",
Arguments: map[string]any{
"todos": todos,
},
},
}
ctx := context.Background()
result, err := server.executeTodoWrite(ctx, request)
if err != nil {
t.Fatalf("Failed to execute todowrite: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Check that todos were stored
storedTodos := server.getTodos()
if len(storedTodos) != 2 {
t.Errorf("Expected 2 todos, got %d", len(storedTodos))
}
// Verify the content is in readable format
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
expectedOutput := "\n\n[ ] Test task 1\n[~] Test task 2"
if textContent.Text != expectedOutput {
t.Errorf("Expected formatted output:\n%s\nGot:\n%s", expectedOutput, textContent.Text)
}
} else {
t.Error("Expected text content")
}
}
func TestTodoRead(t *testing.T) {
server := &TodoServer{
todos: []TodoInfo{
{
Content: "Existing task",
Status: "pending",
Priority: "low",
ID: "existing-1",
},
},
}
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "todoread",
},
}
ctx := context.Background()
result, err := server.executeTodoRead(ctx, request)
if err != nil {
t.Fatalf("Failed to execute todoread: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Verify the content is in readable format
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
expectedOutput := "\n\n[ ] Existing task"
if textContent.Text != expectedOutput {
t.Errorf("Expected formatted output:\n%s\nGot:\n%s", expectedOutput, textContent.Text)
}
} else {
t.Error("Expected text content")
}
}
func TestTodoValidation(t *testing.T) {
server := &TodoServer{
todos: make([]TodoInfo, 0),
}
// Test invalid status
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "todowrite",
Arguments: map[string]any{
"todos": []TodoInfo{
{
Content: "Test task",
Status: "invalid_status",
Priority: "high",
ID: "1",
},
},
},
},
}
ctx := context.Background()
result, err := server.executeTodoWrite(ctx, request)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Should return an error result
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty error message")
}
}
}
func TestTodoEmptyContent(t *testing.T) {
server := &TodoServer{
todos: make([]TodoInfo, 0),
}
// Test empty content
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "todowrite",
Arguments: map[string]any{
"todos": []TodoInfo{
{
Content: "",
Status: "pending",
Priority: "high",
ID: "1",
},
},
},
},
}
ctx := context.Background()
result, err := server.executeTodoWrite(ctx, request)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Should return an error result
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty error message")
}
}
}
func TestTodoActiveCounting(t *testing.T) {
server := &TodoServer{
todos: make([]TodoInfo, 0),
}
// Create todos with different statuses
todos := []TodoInfo{
{Content: "Task 1", Status: "pending", Priority: "high", ID: "1"},
{Content: "Task 2", Status: "in_progress", Priority: "medium", ID: "2"},
{Content: "Task 3", Status: "completed", Priority: "low", ID: "3"},
{Content: "Task 4", Status: "pending", Priority: "high", ID: "4"},
}
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "todowrite",
Arguments: map[string]any{
"todos": todos,
},
},
}
ctx := context.Background()
result, err := server.executeTodoWrite(ctx, request)
if err != nil {
t.Fatalf("Failed to execute todowrite: %v", err)
}
// Check that metadata contains todos
if result.Meta == nil {
t.Fatal("Expected metadata to be non-nil")
}
metaTodos, ok := result.Meta.AdditionalFields["todos"].([]TodoInfo)
if !ok {
t.Fatal("Expected todos in metadata")
}
if len(metaTodos) != 4 {
t.Errorf("Expected 4 todos in metadata, got %d", len(metaTodos))
}
// Verify the content is in readable format
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
expectedOutput := "\n\n[ ] Task 1\n[~] Task 2\n[X] Task 3\n[ ] Task 4"
if textContent.Text != expectedOutput {
t.Errorf("Expected formatted output:\n%s\nGot:\n%s", expectedOutput, textContent.Text)
}
} else {
t.Error("Expected text content")
}
}
+6 -66
View File
@@ -12,15 +12,13 @@ import (
)
// MCPServerConfig represents configuration for an MCP server, supporting both
// local (stdio), remote (StreamableHTTP/SSE), and builtin (in-process) server types.
// local (stdio) and remote (StreamableHTTP/SSE) server types.
// It maintains backward compatibility with legacy configuration formats.
type MCPServerConfig struct {
Type string `json:"type"`
Command []string `json:"command,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
URL string `json:"url,omitempty"`
Name string `json:"name,omitempty"` // For builtin servers
Options map[string]any `json:"options,omitempty"` // For builtin servers
AllowedTools []string `json:"allowedTools,omitempty" yaml:"allowedTools,omitempty"`
ExcludedTools []string `json:"excludedTools,omitempty" yaml:"excludedTools,omitempty"`
@@ -42,8 +40,6 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
Environment map[string]string `json:"environment,omitempty"`
URL string `json:"url,omitempty"`
Headers []string `json:"headers,omitempty"`
Name string `json:"name,omitempty"`
Options map[string]any `json:"options,omitempty"`
AllowedTools []string `json:"allowedTools,omitempty" yaml:"allowedTools,omitempty"`
ExcludedTools []string `json:"excludedTools,omitempty" yaml:"excludedTools,omitempty"`
}
@@ -68,8 +64,6 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error {
s.Environment = newConfig.Environment
s.URL = newConfig.URL
s.Headers = newConfig.Headers
s.Name = newConfig.Name
s.Options = newConfig.Options
s.AllowedTools = newConfig.AllowedTools
s.ExcludedTools = newConfig.ExcludedTools
return nil
@@ -193,8 +187,6 @@ func (s *MCPServerConfig) GetTransportType() string {
return "stdio"
case "remote":
return "streamable"
case "builtin":
return "inprocess"
default:
return s.Type
}
@@ -230,12 +222,8 @@ func (c *Config) Validate() error {
if serverConfig.URL == "" {
return fmt.Errorf("server %s: url is required for %s transport", serverName, transport)
}
case "inprocess":
if serverConfig.Name == "" {
return fmt.Errorf("server %s: name is required for builtin servers", serverName)
}
default:
return fmt.Errorf("server %s: unsupported transport type '%s'. Supported types: stdio, sse, streamable, inprocess", serverName, transport)
return fmt.Errorf("server %s: unsupported transport type '%s'. Supported types: stdio, sse, streamable", serverName, transport)
}
}
return nil
@@ -304,70 +292,22 @@ func createDefaultConfig(homeDir string) error {
// Write a comprehensive YAML template with examples
content := `# KIT Configuration File
# All command-line flags can be configured here
# This demonstrates the simplified local/remote/builtin server configuration
# MCP Servers configuration
# Add your MCP servers here
# Examples for different server types:
# MCP Servers configuration (for external tool servers)
# Core tools (bash, read, write, edit, grep, find, ls) are built-in and always available.
# Add external MCP servers here for additional tools:
# mcpServers:
# # Local MCP servers - run commands locally via stdio transport
# filesystem-local:
# filesystem:
# type: "local"
# command: ["npx", "@modelcontextprotocol/server-filesystem", "/tmp"]
# environment:
# DEBUG: "true"
# LOG_LEVEL: "info"
#
# sqlite:
# type: "local"
# command: ["uvx", "mcp-server-sqlite", "--db-path", "/tmp/example.db"]
# environment:
# SQLITE_DEBUG: "1"
#
# # Builtin MCP servers - run in-process for optimal performance
# filesystem-builtin:
# type: "builtin"
# name: "fs"
# options:
# allowed_directories: ["/tmp", "/home/user/documents"]
# allowedTools: ["read_file", "write_file", "list_directory"]
#
# # Minimal builtin server - defaults to current working directory
# filesystem-cwd:
# type: "builtin"
# name: "fs"
#
# # Bash server for shell commands
# bash:
# type: "builtin"
# name: "bash"
#
# # Todo server for task management
# todo:
# type: "builtin"
# name: "todo"
#
# # Fetch server for web content
# fetch:
# type: "builtin"
# name: "fetch"
#
# # Remote MCP servers - connect via StreamableHTTP transport
# # Optional 'headers' field can be used for authentication and custom headers
# websearch:
# type: "remote"
# url: "https://api.example.com/mcp"
#
# weather:
# type: "remote"
# url: "https://weather-mcp.example.com"
#
# # Legacy format still supported for backward compatibility:
# # legacy-server:
# # command: npx
# # args: ["@modelcontextprotocol/server-filesystem", "/path"]
# # env:
# # MY_VAR: "value"
mcpServers:
+12 -63
View File
@@ -115,66 +115,25 @@ func TestMCPServerConfig_LegacyFormat(t *testing.T) {
}
}
func TestMCPServerConfig_BuiltinFormat(t *testing.T) {
// Test builtin format with allowed_directories
func TestMCPServerConfig_LocalFormat(t *testing.T) {
jsonData := `{
"type": "builtin",
"name": "fs",
"options": {
"allowed_directories": ["/tmp", "/home/user"]
}
"type": "local",
"command": ["npx", "@modelcontextprotocol/server-filesystem", "/tmp"]
}`
var config MCPServerConfig
err := json.Unmarshal([]byte(jsonData), &config)
if err != nil {
t.Fatalf("Failed to unmarshal builtin format: %v", err)
t.Fatalf("Failed to unmarshal local format: %v", err)
}
if config.Type != "builtin" {
t.Errorf("Expected type 'builtin', got '%s'", config.Type)
if config.Type != "local" {
t.Errorf("Expected type 'local', got '%s'", config.Type)
}
if config.Name != "fs" {
t.Errorf("Expected name 'fs', got '%s'", config.Name)
}
if config.Options == nil {
t.Errorf("Expected options to be set")
}
// Test transport type detection
transportType := config.GetTransportType()
if transportType != "inprocess" {
t.Errorf("Expected transport type 'inprocess', got '%s'", transportType)
}
}
func TestMCPServerConfig_BuiltinFormatMinimal(t *testing.T) {
// Test builtin format without allowed_directories (should default to cwd)
jsonData := `{
"type": "builtin",
"name": "fs"
}`
var config MCPServerConfig
err := json.Unmarshal([]byte(jsonData), &config)
if err != nil {
t.Fatalf("Failed to unmarshal minimal builtin format: %v", err)
}
if config.Type != "builtin" {
t.Errorf("Expected type 'builtin', got '%s'", config.Type)
}
if config.Name != "fs" {
t.Errorf("Expected name 'fs', got '%s'", config.Name)
}
// Test transport type detection
transportType := config.GetTransportType()
if transportType != "inprocess" {
t.Errorf("Expected transport type 'inprocess', got '%s'", transportType)
if transportType != "stdio" {
t.Errorf("Expected transport type 'stdio', got '%s'", transportType)
}
}
@@ -189,12 +148,9 @@ func TestConfig_Validate(t *testing.T) {
Type: "remote",
URL: "https://example.com",
},
"builtin-server": {
Type: "builtin",
Name: "fs",
Options: map[string]any{
"allowed_directories": []string{"/tmp"},
},
"another-local": {
Type: "local",
Command: []string{"echo", "world"},
},
},
}
@@ -243,17 +199,10 @@ func TestEnsureConfigExists(t *testing.T) {
"# KIT Configuration File",
"mcpServers:",
"# Local MCP servers",
"# Builtin MCP servers",
"# Remote MCP servers",
"filesystem-builtin:",
"bash:",
"todo:",
"fetch:",
"type: \"builtin\"",
"type: \"local\"",
"type: \"remote\"",
"# Application settings",
"# Model generation parameters",
"Core tools",
}
for _, expected := range expectedSections {
+135
View File
@@ -0,0 +1,135 @@
package core
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"time"
"charm.land/fantasy"
)
const defaultBashTimeout = 120 * time.Second
const maxBashTimeout = 600 * time.Second
var bannedCommands = []string{
"alias ", "bg ", "bind ", "builtin ",
"caller ", "command ", "compgen ",
"complete ", "compopt ", "coproc ",
"dirs ", "disown ", "enable ",
"fc ", "fg ", "hash ", "help ",
"history ", "jobs ", "kill ",
"logout ", "mapfile ", "popd ",
"pushd ", "readonly ", "select ",
"set ", "shopt ", "source ",
"suspend ", "times ", "trap ",
"type ", "typeset ", "ulimit ",
"umask ", "unalias ", "wait ",
}
type bashArgs struct {
Command string `json:"command"`
Timeout float64 `json:"timeout,omitempty"`
}
// NewBashTool creates the bash core tool.
func NewBashTool() fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "bash",
Description: "Execute a bash command. Returns stdout and stderr. Output is truncated to the last 2000 lines or 50KB. Optionally provide a timeout in seconds.",
Parameters: map[string]any{
"command": map[string]any{
"type": "string",
"description": "Bash command to execute",
},
"timeout": map[string]any{
"type": "number",
"description": "Timeout in seconds (optional, default 120s, max 600s)",
},
},
Required: []string{"command"},
},
handler: executeBash,
}
}
func executeBash(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args bashArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("command parameter is required"), nil
}
if args.Command == "" {
return fantasy.NewTextErrorResponse("command parameter is required"), nil
}
// Check for banned commands
for _, banned := range bannedCommands {
if strings.HasPrefix(args.Command, banned) {
return fantasy.NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", args.Command)), nil
}
}
// Determine timeout
timeout := defaultBashTimeout
if args.Timeout > 0 {
timeout = time.Duration(args.Timeout) * time.Second
if timeout > maxBashTimeout {
timeout = maxBashTimeout
}
}
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "bash", "-c", args.Command)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if cmdCtx.Err() == context.DeadlineExceeded {
return fantasy.NewTextErrorResponse(fmt.Sprintf("command timed out after %v", timeout)), nil
}
}
// Build result
var result strings.Builder
if stdout.Len() > 0 {
result.WriteString(stdout.String())
}
if stderr.Len() > 0 {
if result.Len() > 0 {
result.WriteString("\n")
}
result.WriteString("STDERR:\n")
result.WriteString(stderr.String())
}
if exitCode != 0 {
if result.Len() > 0 {
result.WriteString("\n")
}
result.WriteString(fmt.Sprintf("Exit code: %d", exitCode))
}
output := result.String()
if output == "" {
output = "(no output)"
}
// Truncate from tail (keep last N lines, most relevant for bash)
tr := truncateTail(output, defaultMaxLines, defaultMaxBytes)
if exitCode != 0 {
return fantasy.NewTextErrorResponse(tr.Content), nil
}
return fantasy.NewTextResponse(tr.Content), nil
}
+205
View File
@@ -0,0 +1,205 @@
package core
import (
"context"
"fmt"
"os"
"strings"
"unicode"
"charm.land/fantasy"
)
type editArgs struct {
Path string `json:"path"`
OldText string `json:"old_text"`
NewText string `json:"new_text"`
}
// NewEditTool creates the edit core tool.
func NewEditTool() fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "edit",
Description: "Edit a file by replacing exact text. The old_text must match exactly (including whitespace). Use this for precise, surgical edits. Fails if old_text is not found or matches multiple locations.",
Parameters: map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to edit (relative or absolute)",
},
"old_text": map[string]any{
"type": "string",
"description": "Exact text to find and replace (must match exactly)",
},
"new_text": map[string]any{
"type": "string",
"description": "New text to replace the old text with",
},
},
Required: []string{"path", "old_text", "new_text"},
},
handler: executeEdit,
}
}
func executeEdit(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args editArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("path, old_text, and new_text parameters are required"), nil
}
if args.Path == "" {
return fantasy.NewTextErrorResponse("path parameter is required"), nil
}
absPath, err := resolvePath(args.Path)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
}
contentBytes, err := os.ReadFile(absPath)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to read file: %v", err)), nil
}
content := string(contentBytes)
// Normalize line endings for matching
normalized := strings.ReplaceAll(content, "\r\n", "\n")
normalizedOld := strings.ReplaceAll(args.OldText, "\r\n", "\n")
// Try exact match first
count := strings.Count(normalized, normalizedOld)
// If no exact match, try fuzzy matching
if count == 0 {
if idx, matchLen := fuzzyMatch(normalized, normalizedOld); idx >= 0 {
// Apply fuzzy match
newContent := normalized[:idx] + args.NewText + normalized[idx+matchLen:]
if err := os.WriteFile(absPath, []byte(newContent), 0644); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
}
diff := generateDiff(absPath, normalized, newContent, idx)
return fantasy.NewTextResponse(fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff)), nil
}
return fantasy.NewTextErrorResponse(fmt.Sprintf("old_text not found in %s", args.Path)), nil
}
if count > 1 {
return fantasy.NewTextErrorResponse(fmt.Sprintf("found %d matches for old_text in %s. Provide more context to identify the correct match.", count, args.Path)), nil
}
// Apply the edit
newContent := strings.Replace(normalized, normalizedOld, args.NewText, 1)
if err := os.WriteFile(absPath, []byte(newContent), 0644); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
}
idx := strings.Index(normalized, normalizedOld)
diff := generateDiff(absPath, normalized, newContent, idx)
return fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff)), nil
}
// fuzzyMatch tries to find old_text with relaxed matching:
// - Strips trailing whitespace per line
// - Normalizes unicode quotes to ASCII
// - Normalizes unicode dashes/spaces
// Returns (index, matchLength) or (-1, 0) if not found.
func fuzzyMatch(content, search string) (int, int) {
normalizedContent := normalizeForFuzzy(content)
normalizedSearch := normalizeForFuzzy(search)
idx := strings.Index(normalizedContent, normalizedSearch)
if idx < 0 {
return -1, 0
}
// Map back to original content position
// Since normalization can change lengths, we need to find the
// corresponding region in the original content
origIdx := mapFuzzyIndex(content, normalizedContent, idx)
origEnd := mapFuzzyIndex(content, normalizedContent, idx+len(normalizedSearch))
return origIdx, origEnd - origIdx
}
func normalizeForFuzzy(s string) string {
// Strip trailing whitespace per line
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimRightFunc(line, unicode.IsSpace)
}
result := strings.Join(lines, "\n")
// Normalize smart quotes
replacer := strings.NewReplacer(
"\u201c", "\"", // left double quote
"\u201d", "\"", // right double quote
"\u2018", "'", // left single quote
"\u2019", "'", // right single quote
"\u2013", "-", // en dash
"\u2014", "-", // em dash
"\u00a0", " ", // non-breaking space
)
return replacer.Replace(result)
}
func mapFuzzyIndex(original, normalized string, normIdx int) int {
// Simple approach: count runes up to normIdx in normalized,
// then advance that many runes in original.
// This works because our normalization only replaces runes 1:1.
origRunes := []rune(original)
normRunes := []rune(normalized)
if normIdx >= len(normRunes) {
return len(original)
}
// Count bytes for the first normIdx runes in original
byteCount := 0
for i := 0; i < normIdx && i < len(origRunes); i++ {
byteCount += len(string(origRunes[i]))
}
return byteCount
}
// generateDiff creates a simple unified diff showing the change.
func generateDiff(path, old, new string, changeIdx int) string {
oldLines := strings.Split(old, "\n")
newLines := strings.Split(new, "\n")
// Find the line number where the change starts
lineNum := strings.Count(old[:changeIdx], "\n") + 1
// Show context around the change
contextLines := 3
start := lineNum - contextLines - 1
if start < 0 {
start = 0
}
var diff strings.Builder
diff.WriteString(fmt.Sprintf("--- %s\n+++ %s\n", path, path))
// Find changed region
endOld := min(lineNum+contextLines+countNewlines(old[changeIdx:])+1, len(oldLines))
endNew := min(lineNum+contextLines+countNewlines(new[changeIdx:])+1, len(newLines))
diff.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", start+1, endOld-start, start+1, endNew-start))
// Very simplified diff: show old lines as removed, new lines as added
// around the change region
for i := start; i < endOld && i < len(oldLines); i++ {
prefix := " "
if i >= lineNum-1 && i < lineNum-1+countNewlines(old[changeIdx:])+1 {
prefix = "-"
}
diff.WriteString(fmt.Sprintf("%s %s\n", prefix, oldLines[i]))
}
return diff.String()
}
func countNewlines(s string) int {
return strings.Count(s, "\n")
}
+131
View File
@@ -0,0 +1,131 @@
package core
import (
"bytes"
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"charm.land/fantasy"
)
type findArgs struct {
Pattern string `json:"pattern"`
Path string `json:"path,omitempty"`
Limit int `json:"limit,omitempty"`
}
// NewFindTool creates the find core tool.
func NewFindTool() fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "find",
Description: "Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to 1000 results or 50KB.",
Parameters: map[string]any{
"pattern": map[string]any{
"type": "string",
"description": "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
},
"path": map[string]any{
"type": "string",
"description": "Directory to search in (default: current directory)",
},
"limit": map[string]any{
"type": "number",
"description": "Maximum number of results (default: 1000)",
},
},
Required: []string{"pattern"},
},
handler: executeFind,
}
}
func executeFind(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args findArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("pattern parameter is required"), nil
}
if args.Pattern == "" {
return fantasy.NewTextErrorResponse("pattern parameter is required"), nil
}
limit := 1000
if args.Limit > 0 {
limit = args.Limit
}
searchPath := "."
if args.Path != "" {
resolved, err := resolvePath(args.Path)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
}
searchPath = resolved
}
// Try fd first (faster, respects .gitignore by default)
result, err := findWithFd(ctx, args.Pattern, searchPath, limit)
if err == nil {
return result, nil
}
// Fall back to find + globbing
return findWithFind(ctx, args.Pattern, searchPath, limit)
}
func findWithFd(ctx context.Context, pattern, searchPath string, limit int) (fantasy.ToolResponse, error) {
fdArgs := []string{
"--glob", pattern,
"--hidden",
"--max-results", strconv.Itoa(limit),
".", // search current or specified path
}
cmd := exec.CommandContext(ctx, "fd", fdArgs...)
cmd.Dir = searchPath
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fantasy.ToolResponse{}, fmt.Errorf("fd failed: %w: %s", err, stderr.String())
}
output := strings.TrimSpace(stdout.String())
if output == "" {
return fantasy.NewTextResponse("No files found."), nil
}
tr := truncateHead(output, limit, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}
func findWithFind(ctx context.Context, pattern, searchPath string, limit int) (fantasy.ToolResponse, error) {
// Use find with -name for simple patterns
findArgs := []string{searchPath, "-name", pattern, "-type", "f"}
cmd := exec.CommandContext(ctx, "find", findArgs...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
_ = cmd.Run()
output := strings.TrimSpace(stdout.String())
if output == "" {
return fantasy.NewTextResponse("No files found."), nil
}
// Apply limit
lines := strings.Split(output, "\n")
if len(lines) > limit {
lines = lines[:limit]
output = strings.Join(lines, "\n")
output += fmt.Sprintf("\n[truncated: showing %d of more results]", limit)
}
tr := truncateHead(output, limit, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}
+181
View File
@@ -0,0 +1,181 @@
package core
import (
"bytes"
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"charm.land/fantasy"
)
type grepArgs struct {
Pattern string `json:"pattern"`
Path string `json:"path,omitempty"`
Glob string `json:"glob,omitempty"`
IgnoreCase bool `json:"ignore_case,omitempty"`
Literal bool `json:"literal,omitempty"`
Context int `json:"context,omitempty"`
Limit int `json:"limit,omitempty"`
}
// NewGrepTool creates the grep core tool.
func NewGrepTool() fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "grep",
Description: "Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to 100 matches or 50KB. Long lines are truncated to 500 chars.",
Parameters: map[string]any{
"pattern": map[string]any{
"type": "string",
"description": "Search pattern (regex or literal string)",
},
"path": map[string]any{
"type": "string",
"description": "Directory or file to search (default: current directory)",
},
"glob": map[string]any{
"type": "string",
"description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'",
},
"ignore_case": map[string]any{
"type": "boolean",
"description": "Case-insensitive search (default: false)",
},
"literal": map[string]any{
"type": "boolean",
"description": "Treat pattern as literal string instead of regex (default: false)",
},
"context": map[string]any{
"type": "number",
"description": "Number of context lines before and after each match (default: 0)",
},
"limit": map[string]any{
"type": "number",
"description": "Maximum number of matches to return (default: 100)",
},
},
Required: []string{"pattern"},
},
handler: executeGrep,
}
}
func executeGrep(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args grepArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("pattern parameter is required"), nil
}
if args.Pattern == "" {
return fantasy.NewTextErrorResponse("pattern parameter is required"), nil
}
limit := 100
if args.Limit > 0 {
limit = args.Limit
}
searchPath := "."
if args.Path != "" {
resolved, err := resolvePath(args.Path)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
}
searchPath = resolved
}
// Build ripgrep command
rgArgs := []string{
"--line-number",
"--no-heading",
"--color=never",
"--max-count=" + strconv.Itoa(limit),
}
if args.IgnoreCase {
rgArgs = append(rgArgs, "--ignore-case")
}
if args.Literal {
rgArgs = append(rgArgs, "--fixed-strings")
}
if args.Context > 0 {
rgArgs = append(rgArgs, fmt.Sprintf("--context=%d", args.Context))
}
if args.Glob != "" {
rgArgs = append(rgArgs, "--glob="+args.Glob)
}
rgArgs = append(rgArgs, args.Pattern, searchPath)
cmd := exec.CommandContext(ctx, "rg", rgArgs...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
// rg exits with 1 when no matches found (not an error)
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
if exitErr.ExitCode() == 1 {
return fantasy.NewTextResponse("No matches found."), nil
}
if exitErr.ExitCode() == 2 {
return fantasy.NewTextErrorResponse(fmt.Sprintf("grep error: %s", stderr.String())), nil
}
}
// rg not found — fall back to grep
return grepFallback(ctx, args, searchPath, limit)
}
output := stdout.String()
if output == "" {
return fantasy.NewTextResponse("No matches found."), nil
}
// Truncate long lines
lines := strings.Split(output, "\n")
for i, line := range lines {
lines[i] = truncateLine(line, grepMaxLineLen)
}
output = strings.Join(lines, "\n")
tr := truncateHead(output, limit, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}
// grepFallback uses standard grep when rg is not available.
func grepFallback(ctx context.Context, args grepArgs, searchPath string, limit int) (fantasy.ToolResponse, error) {
grepArgs := []string{"-rn", "--color=never"}
if args.IgnoreCase {
grepArgs = append(grepArgs, "-i")
}
if args.Literal {
grepArgs = append(grepArgs, "-F")
}
if args.Context > 0 {
grepArgs = append(grepArgs, fmt.Sprintf("-C%d", args.Context))
}
if args.Glob != "" {
grepArgs = append(grepArgs, "--include="+args.Glob)
}
grepArgs = append(grepArgs, args.Pattern, searchPath)
cmd := exec.CommandContext(ctx, "grep", grepArgs...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
_ = cmd.Run()
output := stdout.String()
if output == "" {
return fantasy.NewTextResponse("No matches found."), nil
}
tr := truncateHead(output, limit, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}
+97
View File
@@ -0,0 +1,97 @@
package core
import (
"context"
"fmt"
"os"
"sort"
"strings"
"charm.land/fantasy"
)
type lsArgs struct {
Path string `json:"path,omitempty"`
Limit int `json:"limit,omitempty"`
}
// NewLsTool creates the ls core tool.
func NewLsTool() fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "ls",
Description: "List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to 500 entries or 50KB.",
Parameters: map[string]any{
"path": map[string]any{
"type": "string",
"description": "Directory to list (default: current directory)",
},
"limit": map[string]any{
"type": "number",
"description": "Maximum number of entries to return (default: 500)",
},
},
Required: []string{},
},
handler: executeLs,
}
}
func executeLs(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args lsArgs
_ = parseArgs(call.Input, &args) // optional args
limit := 500
if args.Limit > 0 {
limit = args.Limit
}
dirPath := "."
if args.Path != "" {
resolved, err := resolvePath(args.Path)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
}
dirPath = resolved
}
info, err := os.Stat(dirPath)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("cannot access '%s': %v", args.Path, err)), nil
}
if !info.IsDir() {
return fantasy.NewTextErrorResponse(fmt.Sprintf("'%s' is not a directory", args.Path)), nil
}
entries, err := os.ReadDir(dirPath)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to read directory: %v", err)), nil
}
// Sort alphabetically (case-insensitive)
sort.Slice(entries, func(i, j int) bool {
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
})
var result strings.Builder
count := 0
for _, entry := range entries {
if count >= limit {
result.WriteString(fmt.Sprintf("\n[truncated: showing %d of %d entries]", limit, len(entries)))
break
}
name := entry.Name()
if entry.IsDir() {
name += "/"
}
result.WriteString(name + "\n")
count++
}
output := result.String()
if output == "" {
return fantasy.NewTextResponse("(empty directory)"), nil
}
return fantasy.NewTextResponse(strings.TrimRight(output, "\n")), nil
}
+144
View File
@@ -0,0 +1,144 @@
package core
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"charm.land/fantasy"
)
type readArgs struct {
Path string `json:"path"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
}
// NewReadTool creates the read core tool.
func NewReadTool() fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "read",
Description: "Read the contents of a file. Output is truncated to 2000 lines or 50KB. Use offset/limit for large files. Use offset to continue reading until complete.",
Parameters: map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to read (relative or absolute)",
},
"offset": map[string]any{
"type": "number",
"description": "Line number to start reading from (1-indexed)",
},
"limit": map[string]any{
"type": "number",
"description": "Maximum number of lines to read",
},
},
Required: []string{"path"},
},
handler: executeRead,
}
}
func executeRead(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args readArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("path parameter is required"), nil
}
if args.Path == "" {
return fantasy.NewTextErrorResponse("path parameter is required"), nil
}
absPath, err := resolvePath(args.Path)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
}
// Check if path is a directory
info, err := os.Stat(absPath)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("cannot access '%s': %v", args.Path, err)), nil
}
if info.IsDir() {
return readDirectory(absPath)
}
content, err := os.ReadFile(absPath)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to read file: %v", err)), nil
}
lines := strings.Split(string(content), "\n")
totalLines := len(lines)
// Apply offset (1-indexed)
offset := 0
if args.Offset > 0 {
offset = args.Offset - 1
if offset >= totalLines {
return fantasy.NewTextResponse(fmt.Sprintf("offset %d exceeds file length (%d lines)", args.Offset, totalLines)), nil
}
lines = lines[offset:]
}
// Apply limit
maxLines := defaultMaxLines
if args.Limit > 0 {
maxLines = args.Limit
}
if len(lines) > maxLines {
lines = lines[:maxLines]
}
// Number lines
var result strings.Builder
for i, line := range lines {
lineNum := offset + i + 1
result.WriteString(fmt.Sprintf("%d: %s\n", lineNum, line))
}
output := result.String()
tr := truncateHead(output, 0, defaultMaxBytes)
// Add truncation notice
if len(lines) < totalLines-offset {
tr.Content += fmt.Sprintf("\n[showing lines %d-%d of %d total. Use offset=%d to continue reading]",
offset+1, offset+len(lines), totalLines, offset+len(lines)+1)
}
return fantasy.NewTextResponse(tr.Content), nil
}
func readDirectory(absPath string) (fantasy.ToolResponse, error) {
entries, err := os.ReadDir(absPath)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to read directory: %v", err)), nil
}
var result strings.Builder
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
name += "/"
}
result.WriteString(name + "\n")
}
tr := truncateHead(result.String(), 500, defaultMaxBytes)
return fantasy.NewTextResponse(tr.Content), nil
}
// resolvePath resolves a path to an absolute path relative to cwd.
func resolvePath(path string) (string, error) {
if filepath.IsAbs(path) {
return filepath.Clean(path), nil
}
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get working directory: %w", err)
}
return filepath.Clean(filepath.Join(cwd, path)), nil
}
+75
View File
@@ -0,0 +1,75 @@
// Package core provides the built-in core tools for KIT's coding agent.
// These tools are direct fantasy.AgentTool implementations — no MCP layer,
// no JSON-RPC, no serialization overhead. They match the pi coding agent's
// core tool set: bash, read, write, edit, grep, find, ls.
package core
import (
"context"
"encoding/json"
"fmt"
"charm.land/fantasy"
)
// coreTool is the base implementation for all core tools. It implements
// the fantasy.AgentTool interface with typed parameters and direct execution.
type coreTool struct {
info fantasy.ToolInfo
handler func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error)
providerOptions fantasy.ProviderOptions
}
func (t *coreTool) Info() fantasy.ToolInfo { return t.info }
func (t *coreTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions }
func (t *coreTool) SetProviderOptions(opts fantasy.ProviderOptions) { t.providerOptions = opts }
func (t *coreTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return t.handler(ctx, call)
}
// parseArgs unmarshals the JSON input from a tool call into the target struct.
func parseArgs(input string, target any) error {
if input == "" || input == "{}" {
return nil
}
if err := json.Unmarshal([]byte(input), target); err != nil {
return fmt.Errorf("invalid arguments: %w", err)
}
return nil
}
// CodingTools returns the default set of core tools for a coding agent:
// bash, read, write, edit. This matches pi's codingTools collection.
func CodingTools() []fantasy.AgentTool {
return []fantasy.AgentTool{
NewBashTool(),
NewReadTool(),
NewWriteTool(),
NewEditTool(),
}
}
// ReadOnlyTools returns tools for read-only exploration:
// read, grep, find, ls. This matches pi's readOnlyTools collection.
func ReadOnlyTools() []fantasy.AgentTool {
return []fantasy.AgentTool{
NewReadTool(),
NewGrepTool(),
NewFindTool(),
NewLsTool(),
}
}
// AllTools returns all available core tools.
func AllTools() []fantasy.AgentTool {
return []fantasy.AgentTool{
NewBashTool(),
NewReadTool(),
NewWriteTool(),
NewEditTool(),
NewGrepTool(),
NewFindTool(),
NewLsTool(),
}
}
+132
View File
@@ -0,0 +1,132 @@
package core
import (
"fmt"
"strings"
)
const (
defaultMaxLines = 2000
defaultMaxBytes = 50 * 1024 // 50KB
grepMaxLineLen = 500
)
// TruncationResult describes how output was truncated.
type TruncationResult struct {
Content string
Truncated bool
TruncBy string // "lines", "bytes", or ""
Total int // total lines before truncation
Kept int // lines kept after truncation
}
// truncateTail keeps the last maxLines lines and at most maxBytes bytes.
// Used for bash output where the tail is most relevant.
func truncateTail(content string, maxLines, maxBytes int) TruncationResult {
if maxLines <= 0 {
maxLines = defaultMaxLines
}
if maxBytes <= 0 {
maxBytes = defaultMaxBytes
}
lines := strings.Split(content, "\n")
total := len(lines)
if len(content) <= maxBytes && total <= maxLines {
return TruncationResult{Content: content, Total: total, Kept: total}
}
// Truncate by lines first (keep tail)
truncBy := ""
if total > maxLines {
lines = lines[total-maxLines:]
truncBy = "lines"
}
result := strings.Join(lines, "\n")
// Then truncate by bytes if still too large
if len(result) > maxBytes {
// Find a line boundary near the byte limit
result = result[len(result)-maxBytes:]
// Discard partial first line
if idx := strings.Index(result, "\n"); idx >= 0 {
result = result[idx+1:]
}
truncBy = "bytes"
}
kept := strings.Count(result, "\n") + 1
if truncBy != "" {
header := fmt.Sprintf("[truncated %d/%d lines, showing last %d lines]\n", total-kept, total, kept)
result = header + result
}
return TruncationResult{
Content: result,
Truncated: truncBy != "",
TruncBy: truncBy,
Total: total,
Kept: kept,
}
}
// truncateHead keeps the first maxLines lines and at most maxBytes bytes.
// Used for read, grep, find, ls output where the head is most relevant.
func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
if maxLines <= 0 {
maxLines = defaultMaxLines
}
if maxBytes <= 0 {
maxBytes = defaultMaxBytes
}
lines := strings.Split(content, "\n")
total := len(lines)
if len(content) <= maxBytes && total <= maxLines {
return TruncationResult{Content: content, Total: total, Kept: total}
}
truncBy := ""
if total > maxLines {
lines = lines[:maxLines]
truncBy = "lines"
}
result := strings.Join(lines, "\n")
if len(result) > maxBytes {
result = result[:maxBytes]
// Discard partial last line
if idx := strings.LastIndex(result, "\n"); idx >= 0 {
result = result[:idx]
}
truncBy = "bytes"
}
kept := strings.Count(result, "\n") + 1
if truncBy != "" {
result += fmt.Sprintf("\n[truncated %d/%d lines, showing first %d lines]", total-kept, total, kept)
}
return TruncationResult{
Content: result,
Truncated: truncBy != "",
TruncBy: truncBy,
Total: total,
Kept: kept,
}
}
// truncateLine truncates a single line to maxChars, appending "..." if cut.
func truncateLine(line string, maxChars int) string {
if maxChars <= 0 {
maxChars = grepMaxLineLen
}
if len(line) <= maxChars {
return line
}
return line[:maxChars] + "..."
}
+64
View File
@@ -0,0 +1,64 @@
package core
import (
"context"
"fmt"
"os"
"path/filepath"
"charm.land/fantasy"
)
type writeArgs struct {
Path string `json:"path"`
Content string `json:"content"`
}
// NewWriteTool creates the write core tool.
func NewWriteTool() fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "write",
Description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
Parameters: map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to write (relative or absolute)",
},
"content": map[string]any{
"type": "string",
"description": "Content to write to the file",
},
},
Required: []string{"path", "content"},
},
handler: executeWrite,
}
}
func executeWrite(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args writeArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("path and content parameters are required"), nil
}
if args.Path == "" {
return fantasy.NewTextErrorResponse("path parameter is required"), nil
}
absPath, err := resolvePath(args.Path)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
}
// Create parent directories
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to create directories: %v", err)), nil
}
if err := os.WriteFile(absPath, []byte(args.Content), 0644); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
}
return fantasy.NewTextResponse(fmt.Sprintf("Wrote %d bytes to %s", len(args.Content), args.Path)), nil
}
+395
View File
@@ -0,0 +1,395 @@
package message
import (
"encoding/json"
"errors"
"fmt"
"time"
"charm.land/fantasy"
)
// ContentPart is the marker interface for all message content block types.
// A message contains a heterogeneous slice of ContentPart values, enabling
// rich structured messages that carry text, reasoning, tool calls, tool
// results, and finish markers in a single message.
type ContentPart interface {
isPart() // marker — prevents external implementations
}
// --- Concrete content block types ---
// TextContent holds plain text content within a message.
type TextContent struct {
Text string `json:"text"`
}
func (TextContent) isPart() {}
// ReasoningContent holds extended thinking / reasoning output from the LLM.
// Provider-specific metadata (signatures, etc.) is preserved for round-trip
// fidelity when the conversation is sent back to the provider.
type ReasoningContent struct {
Thinking string `json:"thinking"`
Signature string `json:"signature,omitempty"` // Anthropic
}
func (ReasoningContent) isPart() {}
// ToolCall represents a tool invocation initiated by the LLM. It is stored
// as a content part within an assistant message, not as a separate message.
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Input string `json:"input"` // JSON string of arguments
Finished bool `json:"finished"`
}
func (ToolCall) isPart() {}
// ToolResult represents the result of executing a tool. It is stored as a
// content part within a tool-role message, linked to a ToolCall by ID.
type ToolResult struct {
ToolCallID string `json:"tool_call_id"`
Name string `json:"name"`
Content string `json:"content"`
IsError bool `json:"is_error"`
}
func (ToolResult) isPart() {}
// Finish marks the end of an assistant turn, carrying the stop reason.
type Finish struct {
Reason string `json:"reason"` // "end_turn", "tool_use", "max_tokens", etc.
}
func (Finish) isPart() {}
// --- Message container ---
// MessageRole identifies the sender of a message.
type MessageRole string
const (
RoleUser MessageRole = "user"
RoleAssistant MessageRole = "assistant"
RoleTool MessageRole = "tool"
RoleSystem MessageRole = "system"
)
// Message is a single conversation message containing a heterogeneous slice
// of ContentPart blocks. This design (borrowed from crush) enables a single
// assistant message to carry text, reasoning, and multiple tool calls as
// discrete, typed blocks rather than flattening everything into strings.
type Message struct {
ID string `json:"id"`
Role MessageRole `json:"role"`
Parts []ContentPart `json:"parts"`
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// --- Typed accessors ---
// Content returns the concatenated text from all TextContent parts.
func (m *Message) Content() string {
var text string
for _, part := range m.Parts {
if c, ok := part.(TextContent); ok {
if text != "" {
text += "\n"
}
text += c.Text
}
}
return text
}
// ToolCalls returns all ToolCall parts from this message.
func (m *Message) ToolCalls() []ToolCall {
var calls []ToolCall
for _, part := range m.Parts {
if c, ok := part.(ToolCall); ok {
calls = append(calls, c)
}
}
return calls
}
// ToolResults returns all ToolResult parts from this message.
func (m *Message) ToolResults() []ToolResult {
var results []ToolResult
for _, part := range m.Parts {
if r, ok := part.(ToolResult); ok {
results = append(results, r)
}
}
return results
}
// Reasoning returns the ReasoningContent if present, or a zero value.
func (m *Message) Reasoning() ReasoningContent {
for _, part := range m.Parts {
if r, ok := part.(ReasoningContent); ok {
return r
}
}
return ReasoningContent{}
}
// AddPart appends a content part and updates the timestamp.
func (m *Message) AddPart(part ContentPart) {
m.Parts = append(m.Parts, part)
m.UpdatedAt = time.Now()
}
// AddToolCall appends or updates a ToolCall part. If a call with the same
// ID already exists, it is replaced (supports streaming where partial calls
// arrive before the final version).
func (m *Message) AddToolCall(tc ToolCall) {
for i, part := range m.Parts {
if existing, ok := part.(ToolCall); ok && existing.ID == tc.ID {
m.Parts[i] = tc
m.UpdatedAt = time.Now()
return
}
}
m.Parts = append(m.Parts, tc)
m.UpdatedAt = time.Now()
}
// --- Type-tagged JSON serialization ---
type partType string
const (
textType partType = "text"
reasoningType partType = "reasoning"
toolCallType partType = "tool_call"
toolResultType partType = "tool_result"
finishType partType = "finish"
)
type partWrapper struct {
Type partType `json:"type"`
Data json.RawMessage `json:"data"`
}
// MarshalParts serializes a slice of ContentPart to JSON using type-tagged
// wrappers. Each part becomes {"type":"...", "data":{...}}.
func MarshalParts(parts []ContentPart) ([]byte, error) {
wrappers := make([]partWrapper, 0, len(parts))
for _, part := range parts {
var pt partType
switch part.(type) {
case TextContent:
pt = textType
case ReasoningContent:
pt = reasoningType
case ToolCall:
pt = toolCallType
case ToolResult:
pt = toolResultType
case Finish:
pt = finishType
default:
return nil, fmt.Errorf("unknown content part type: %T", part)
}
data, err := json.Marshal(part)
if err != nil {
return nil, fmt.Errorf("failed to marshal %s part: %w", pt, err)
}
wrappers = append(wrappers, partWrapper{Type: pt, Data: data})
}
return json.Marshal(wrappers)
}
// UnmarshalParts deserializes type-tagged JSON back into a slice of ContentPart.
func UnmarshalParts(data []byte) ([]ContentPart, error) {
var wrappers []partWrapper
if err := json.Unmarshal(data, &wrappers); err != nil {
return nil, fmt.Errorf("failed to unmarshal parts array: %w", err)
}
parts := make([]ContentPart, 0, len(wrappers))
for _, w := range wrappers {
var part ContentPart
switch w.Type {
case textType:
var p TextContent
if err := json.Unmarshal(w.Data, &p); err != nil {
return nil, fmt.Errorf("failed to unmarshal text part: %w", err)
}
part = p
case reasoningType:
var p ReasoningContent
if err := json.Unmarshal(w.Data, &p); err != nil {
return nil, fmt.Errorf("failed to unmarshal reasoning part: %w", err)
}
part = p
case toolCallType:
var p ToolCall
if err := json.Unmarshal(w.Data, &p); err != nil {
return nil, fmt.Errorf("failed to unmarshal tool_call part: %w", err)
}
part = p
case toolResultType:
var p ToolResult
if err := json.Unmarshal(w.Data, &p); err != nil {
return nil, fmt.Errorf("failed to unmarshal tool_result part: %w", err)
}
part = p
case finishType:
var p Finish
if err := json.Unmarshal(w.Data, &p); err != nil {
return nil, fmt.Errorf("failed to unmarshal finish part: %w", err)
}
part = p
default:
return nil, fmt.Errorf("unknown part type: %s", w.Type)
}
parts = append(parts, part)
}
return parts, nil
}
// --- Fantasy bridge ---
// ToFantasyMessages converts a Message to one or more fantasy.Message values.
// An assistant message with tool calls produces a single fantasy message with
// mixed TextPart and ToolCallPart content. Tool-role messages produce
// ToolResultPart entries.
func (m *Message) ToFantasyMessages() []fantasy.Message {
switch m.Role {
case RoleAssistant:
var parts []fantasy.MessagePart
// Add reasoning if present
reasoning := m.Reasoning()
if reasoning.Thinking != "" {
parts = append(parts, fantasy.ReasoningPart{
Text: reasoning.Thinking,
})
}
// Add text content
if text := m.Content(); text != "" {
parts = append(parts, fantasy.TextPart{Text: text})
}
// Add tool calls
for _, tc := range m.ToolCalls() {
parts = append(parts, fantasy.ToolCallPart{
ToolCallID: tc.ID,
ToolName: tc.Name,
Input: tc.Input,
})
}
if len(parts) == 0 {
return nil
}
return []fantasy.Message{{
Role: fantasy.MessageRoleAssistant,
Content: parts,
}}
case RoleTool:
var parts []fantasy.MessagePart
for _, result := range m.ToolResults() {
var output fantasy.ToolResultOutputContent
if result.IsError {
output = fantasy.ToolResultOutputContentError{
Error: errors.New(result.Content),
}
} else {
output = fantasy.ToolResultOutputContentText{
Text: result.Content,
}
}
parts = append(parts, fantasy.ToolResultPart{
ToolCallID: result.ToolCallID,
Output: output,
})
}
if len(parts) == 0 {
return nil
}
return []fantasy.Message{{
Role: fantasy.MessageRoleTool,
Content: parts,
}}
case RoleUser:
text := m.Content()
if text == "" {
return nil
}
return []fantasy.Message{{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: text}},
}}
case RoleSystem:
text := m.Content()
if text == "" {
return nil
}
return []fantasy.Message{{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: text}},
}}
default:
return nil
}
}
// FromFantasyMessage converts a fantasy.Message into our Message type,
// extracting all content parts into the appropriate block types.
func FromFantasyMessage(msg fantasy.Message) Message {
m := Message{
Role: MessageRole(msg.Role),
Parts: make([]ContentPart, 0),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
for _, part := range msg.Content {
switch p := part.(type) {
case fantasy.TextPart:
if p.Text != "" {
m.Parts = append(m.Parts, TextContent{Text: p.Text})
}
case fantasy.ToolCallPart:
m.Parts = append(m.Parts, ToolCall{
ID: p.ToolCallID,
Name: p.ToolName,
Input: p.Input,
Finished: true,
})
case fantasy.ToolResultPart:
result := ToolResult{
ToolCallID: p.ToolCallID,
}
switch r := p.Output.(type) {
case fantasy.ToolResultOutputContentText:
result.Content = r.Text
case fantasy.ToolResultOutputContentError:
result.Content = r.Error.Error()
result.IsError = true
}
m.Parts = append(m.Parts, result)
case fantasy.ReasoningPart:
if p.Text != "" {
m.Parts = append(m.Parts, ReasoningContent{
Thinking: p.Text,
})
}
}
}
return m
}
+7 -5
View File
@@ -5,6 +5,8 @@ import (
"sync"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/message"
)
// Manager manages session state and auto-saving functionality.
@@ -70,7 +72,7 @@ func (m *Manager) ReplaceAllMessages(msgs []fantasy.Message) error {
defer m.mutex.Unlock()
// Clear existing messages
m.session.Messages = []Message{}
m.session.Messages = []message.Message{}
// Add all new messages
for _, msg := range msgs {
@@ -104,9 +106,9 @@ func (m *Manager) GetMessages() []fantasy.Message {
m.mutex.RLock()
defer m.mutex.RUnlock()
messages := make([]fantasy.Message, len(m.session.Messages))
for i, msg := range m.session.Messages {
messages[i] = msg.ConvertToFantasyMessage()
var messages []fantasy.Message
for _, msg := range m.session.Messages {
messages = append(messages, msg.ToFantasyMessages()...)
}
return messages
@@ -118,7 +120,7 @@ func (m *Manager) GetSession() *Session {
defer m.mutex.RUnlock()
sessionCopy := *m.session
sessionCopy.Messages = make([]Message, len(m.session.Messages))
sessionCopy.Messages = make([]message.Message, len(m.session.Messages))
copy(sessionCopy.Messages, m.session.Messages)
return &sessionCopy
+89 -131
View File
@@ -9,6 +9,8 @@ import (
"time"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/message"
)
// Session represents a complete conversation session with metadata.
@@ -25,8 +27,10 @@ type Session struct {
UpdatedAt time.Time `json:"updated_at"`
// Metadata contains contextual information about the session
Metadata Metadata `json:"metadata"`
// Messages is the ordered list of all messages in this session
Messages []Message `json:"messages"`
// Messages is the ordered list of all messages in this session, stored
// as custom content blocks (crush-style). Each message contains a
// heterogeneous Parts slice serialized as type-tagged JSON.
Messages []message.Message `json:"messages"`
}
// Metadata contains session metadata that provides context about the
@@ -40,52 +44,27 @@ type Metadata struct {
Model string `json:"model"`
}
// Message represents a single message in the conversation session.
// Messages can be from different roles (user, assistant, tool) and may
// include tool calls for assistant messages or tool results for tool messages.
type Message struct {
// ID is a unique identifier for this message, auto-generated if not provided
ID string `json:"id"`
// Role indicates who sent the message ("user", "assistant", "tool", or "system")
Role string `json:"role"`
// Content is the text content of the message
Content string `json:"content"`
// Timestamp is when the message was created
Timestamp time.Time `json:"timestamp"`
// ToolCalls contains any tool invocations made by the assistant in this message
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
// ToolCallID links a tool result message to its corresponding tool call
ToolCallID string `json:"tool_call_id,omitempty"`
}
// ToolCall represents a tool invocation within an assistant message.
type ToolCall struct {
// ID is a unique identifier for this tool call, used to link results
ID string `json:"id"`
// Name is the name of the tool being invoked
Name string `json:"name"`
// Arguments contains the parameters passed to the tool, typically as JSON
Arguments any `json:"arguments"`
}
// NewSession creates a new session with default values.
func NewSession() *Session {
return &Session{
Version: "1.0",
Version: "2.0",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Messages: []Message{},
Messages: []message.Message{},
Metadata: Metadata{},
}
}
// AddMessage adds a message to the session.
func (s *Session) AddMessage(msg Message) {
func (s *Session) AddMessage(msg message.Message) {
if msg.ID == "" {
msg.ID = generateMessageID()
}
if msg.Timestamp.IsZero() {
msg.Timestamp = time.Now()
if msg.CreatedAt.IsZero() {
msg.CreatedAt = time.Now()
}
if msg.UpdatedAt.IsZero() {
msg.UpdatedAt = time.Now()
}
s.Messages = append(s.Messages, msg)
@@ -98,11 +77,54 @@ func (s *Session) SetMetadata(metadata Metadata) {
s.UpdatedAt = time.Now()
}
// sessionJSON is the on-disk format with parts serialized as JSON strings.
type sessionJSON struct {
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata Metadata `json:"metadata"`
Messages []messageJSON `json:"messages"`
}
type messageJSON struct {
ID string `json:"id"`
Role string `json:"role"`
Parts json.RawMessage `json:"parts"` // type-tagged JSON array
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SaveToFile saves the session to a JSON file.
func (s *Session) SaveToFile(filePath string) error {
s.UpdatedAt = time.Now()
data, err := json.MarshalIndent(s, "", " ")
sj := sessionJSON{
Version: s.Version,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
Metadata: s.Metadata,
Messages: make([]messageJSON, len(s.Messages)),
}
for i, msg := range s.Messages {
parts, err := message.MarshalParts(msg.Parts)
if err != nil {
return fmt.Errorf("failed to marshal parts for message %s: %w", msg.ID, err)
}
sj.Messages[i] = messageJSON{
ID: msg.ID,
Role: string(msg.Role),
Parts: parts,
Model: msg.Model,
Provider: msg.Provider,
CreatedAt: msg.CreatedAt,
UpdatedAt: msg.UpdatedAt,
}
}
data, err := json.MarshalIndent(sj, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal session: %v", err)
}
@@ -117,107 +139,43 @@ func LoadFromFile(filePath string) (*Session, error) {
return nil, fmt.Errorf("failed to read session file: %v", err)
}
var session Session
if err := json.Unmarshal(data, &session); err != nil {
var sj sessionJSON
if err := json.Unmarshal(data, &sj); err != nil {
return nil, fmt.Errorf("failed to unmarshal session: %v", err)
}
return &session, nil
session := &Session{
Version: sj.Version,
CreatedAt: sj.CreatedAt,
UpdatedAt: sj.UpdatedAt,
Metadata: sj.Metadata,
Messages: make([]message.Message, len(sj.Messages)),
}
for i, mj := range sj.Messages {
parts, err := message.UnmarshalParts(mj.Parts)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal parts for message %s: %w", mj.ID, err)
}
session.Messages[i] = message.Message{
ID: mj.ID,
Role: message.MessageRole(mj.Role),
Parts: parts,
Model: mj.Model,
Provider: mj.Provider,
CreatedAt: mj.CreatedAt,
UpdatedAt: mj.UpdatedAt,
}
}
return session, nil
}
// ConvertFromFantasyMessage converts a fantasy.Message to a session Message.
// ConvertFromFantasyMessage converts a fantasy.Message to a message.Message.
// This function bridges between the fantasy message format and the
// session's internal message format for JSON persistence.
func ConvertFromFantasyMessage(msg fantasy.Message) Message {
sessionMsg := Message{
Role: string(msg.Role),
Timestamp: time.Now(),
}
// Extract text content and tool calls from message parts
var textParts []string
for _, part := range msg.Content {
switch p := part.(type) {
case fantasy.TextPart:
textParts = append(textParts, p.Text)
case fantasy.ToolCallPart:
sessionMsg.ToolCalls = append(sessionMsg.ToolCalls, ToolCall{
ID: p.ToolCallID,
Name: p.ToolName,
Arguments: p.Input,
})
case fantasy.ToolResultPart:
// Tool result messages — store the tool call ID
sessionMsg.ToolCallID = p.ToolCallID
// Marshal result for storage
if p.Output != nil {
if resultBytes, err := json.Marshal(p.Output); err == nil {
textParts = append(textParts, string(resultBytes))
}
}
}
}
// Join all text parts
for i, t := range textParts {
if i > 0 {
sessionMsg.Content += "\n"
}
sessionMsg.Content += t
}
return sessionMsg
}
// ConvertToFantasyMessage converts a session Message to a fantasy.Message.
// This method bridges between the session's internal message format and
// the fantasy message format used by the LLM providers.
func (m *Message) ConvertToFantasyMessage() fantasy.Message {
msg := fantasy.Message{
Role: fantasy.MessageRole(m.Role),
}
// Build content parts based on role
switch m.Role {
case "assistant":
// Add text content if present
if m.Content != "" {
msg.Content = append(msg.Content, fantasy.TextPart{Text: m.Content})
}
// Add tool calls if present
for _, tc := range m.ToolCalls {
var inputStr string
if str, ok := tc.Arguments.(string); ok {
inputStr = str
} else if argBytes, err := json.Marshal(tc.Arguments); err == nil {
inputStr = string(argBytes)
}
msg.Content = append(msg.Content, fantasy.ToolCallPart{
ToolCallID: tc.ID,
ToolName: tc.Name,
Input: inputStr,
})
}
case "tool":
// Tool result message
msg.Role = fantasy.MessageRoleTool
var resultContent fantasy.ToolResultOutputContent
resultContent = fantasy.ToolResultOutputContentText{Text: m.Content}
msg.Content = append(msg.Content, fantasy.ToolResultPart{
ToolCallID: m.ToolCallID,
Output: resultContent,
})
case "user":
msg.Content = append(msg.Content, fantasy.TextPart{Text: m.Content})
case "system":
msg.Content = append(msg.Content, fantasy.TextPart{Text: m.Content})
default:
msg.Content = append(msg.Content, fantasy.TextPart{Text: m.Content})
}
return msg
// session's internal message format for persistence.
func ConvertFromFantasyMessage(msg fantasy.Message) message.Message {
return message.FromFantasyMessage(msg)
}
// generateMessageID generates a unique message ID.
-20
View File
@@ -9,7 +9,6 @@ import (
"time"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/builtin"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
@@ -270,8 +269,6 @@ func (p *MCPConnectionPool) createMCPClient(ctx context.Context, serverName stri
return p.createSSEClient(ctx, serverConfig)
case "streamable":
return p.createStreamableClient(ctx, serverConfig)
case "inprocess":
return p.createBuiltinClient(ctx, serverName, serverConfig)
default:
return nil, fmt.Errorf("unsupported transport type '%s' for server %s", transportType, serverName)
}
@@ -371,23 +368,6 @@ func (p *MCPConnectionPool) createStreamableClient(ctx context.Context, serverCo
return streamableClient, nil
}
// createBuiltinClient creates a builtin client
func (p *MCPConnectionPool) createBuiltinClient(ctx context.Context, serverName string, serverConfig config.MCPServerConfig) (client.MCPClient, error) {
registry := builtin.NewRegistry()
builtinServer, err := registry.CreateServer(serverConfig.Name, serverConfig.Options, p.model)
if err != nil {
return nil, fmt.Errorf("failed to create builtin server: %v", err)
}
inProcessClient, err := client.NewInProcessClient(builtinServer.GetServer())
if err != nil {
return nil, fmt.Errorf("failed to create in-process client: %v", err)
}
return inProcessClient, nil
}
// initializeClient initializes the client
func (p *MCPConnectionPool) initializeClient(ctx context.Context, client client.MCPClient) error {
initCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"strings"
"charm.land/fantasy"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/mcp-go/mcp"
)
// MCPToolManager manages MCP (Model Context Protocol) tools and clients across multiple servers.
+7 -13
View File
@@ -77,33 +77,27 @@ func TestMCPToolManager_LoadTools_GracefulFailure(t *testing.T) {
t.Logf("LoadTools failed gracefully with error: %v", err)
}
// TestMCPToolManager_ToolWithoutProperties tests handling of tools with no input properties
func TestMCPToolManager_ToolWithoutProperties(t *testing.T) {
// TestMCPToolManager_EmptyConfig tests handling of empty MCP config
func TestMCPToolManager_EmptyConfig(t *testing.T) {
manager := NewMCPToolManager()
// Create a config with a builtin todo server (which has tools with properties)
cfg := &config.Config{
MCPServers: map[string]config.MCPServerConfig{
"todo-server": {
Type: "builtin",
Name: "todo",
},
},
MCPServers: map[string]config.MCPServerConfig{},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Load the tools - this should work fine
// Load the tools - should succeed with no servers
err := manager.LoadTools(ctx, cfg)
if err != nil {
t.Fatalf("Failed to load tools: %v", err)
}
// Get the loaded tools
// Get the loaded tools - should be empty
tools := manager.GetTools()
if len(tools) == 0 {
t.Fatal("No tools were loaded")
if len(tools) != 0 {
t.Fatalf("Expected 0 tools, got %d", len(tools))
}
// Test that we can get tool info for each tool
+13 -8
View File
@@ -2,21 +2,26 @@ package sdk
import (
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/session"
)
// Message is an alias for session.Message providing SDK users with access
// Message is an alias for message.Message providing SDK users with access
// to message structures for conversation history and tool interactions.
type Message = session.Message
type Message = message.Message
// ToolCall is an alias for session.ToolCall representing a tool invocation
// ToolCall is an alias for message.ToolCall representing a tool invocation
// with its name, arguments, and result within a conversation.
type ToolCall = session.ToolCall
type ToolCall = message.ToolCall
// ConvertToFantasyMessage converts an SDK message to the underlying fantasy message
// format used by the agent for LLM interactions.
func ConvertToFantasyMessage(msg *Message) fantasy.Message {
return msg.ConvertToFantasyMessage()
// ToolResult is an alias for message.ToolResult representing the result
// of executing a tool.
type ToolResult = message.ToolResult
// ConvertToFantasyMessages converts an SDK message to the underlying fantasy
// messages used by the agent for LLM interactions.
func ConvertToFantasyMessages(msg *Message) []fantasy.Message {
return msg.ToFantasyMessages()
}
// ConvertFromFantasyMessage converts a fantasy message from the agent to an SDK