mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
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:
+19
-64
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>`
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)`
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)`
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.`
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user