From d9983b9524d96ba240a5bad72e2170879dcaa5b7 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Tue, 24 Jun 2025 16:29:44 +0300 Subject: [PATCH] builtin servers --- AGENTS.md | 6 +- README.md | 67 +++++++++++- example-config.yml | 17 ++- go.mod | 8 +- go.sum | 17 ++- internal/builtin/bash.go | 190 +++++++++++++++++++++++++++++++++ internal/builtin/bash_test.go | 124 +++++++++++++++++++++ internal/builtin/registry.go | 117 ++++++++++++++++++++ internal/config/config.go | 46 +++++--- internal/config/config_test.go | 104 +++++++++++++++--- internal/tools/mcp.go | 30 +++++- 11 files changed, 682 insertions(+), 44 deletions(-) create mode 100644 internal/builtin/bash.go create mode 100644 internal/builtin/bash_test.go create mode 100644 internal/builtin/registry.go diff --git a/AGENTS.md b/AGENTS.md index 7f08240e..35530b87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ - Tool calling support across all providers with unified `llm.Tool` interface ## MCP Configuration Schema -MCPHost supports a simplified configuration schema with two server types: +MCPHost supports a simplified configuration schema with three server types: ### New Simplified Format - **Local servers** (`"type": "local"`): Run commands locally via stdio transport @@ -36,6 +36,9 @@ MCPHost supports a simplified configuration schema with two server types: - **Remote servers** (`"type": "remote"`): Connect via StreamableHTTP transport - `url`: Server endpoint URL - Automatically uses StreamableHTTP for optimal performance +- **Builtin servers** (`"type": "builtin"`): Run in-process for optimal performance + - `name`: Internal name of the builtin server (e.g., `"fs"`) + - `options`: Configuration options specific to the builtin server ### Legacy Format Support - Maintains full backward compatibility with existing configurations @@ -45,6 +48,7 @@ MCPHost supports a simplified configuration schema with two server types: ### Transport Mapping - `"local"` type → `stdio` transport (launches local processes) - `"remote"` type → `streamable` transport (StreamableHTTP protocol) +- `"builtin"` type → `inprocess` transport (in-process execution) - Legacy `transport` field still supported for backward compatibility ### Configuration Files diff --git a/README.md b/README.md index 23f52c00..51975938 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ You can also specify a custom location using the `--config` flag. ### Simplified Configuration Schema -MCPHost now supports a simplified configuration schema with two server types: +MCPHost now supports a simplified configuration schema with three server types: #### Local Servers For local MCP servers that run commands on your machine: @@ -154,6 +154,60 @@ Each remote server entry requires: Remote servers automatically use the StreamableHTTP transport for optimal performance. +#### Builtin Servers +For builtin MCP servers that run in-process for optimal performance: +```json +{ + "mcpServers": { + "filesystem": { + "type": "builtin", + "name": "fs", + "options": { + "allowed_directories": ["/tmp", "/home/user/documents"] + }, + "allowedTools": ["read_file", "write_file", "list_directory"] + }, + "filesystem-cwd": { + "type": "builtin", + "name": "fs" + } + } +} +``` + +Each builtin server entry requires: +- `type`: Must be set to `"builtin"` +- `name`: Internal name of the builtin server (e.g., `"fs"` for filesystem) +- `options`: Configuration options specific to the builtin server + +**Available Builtin Servers:** +- `fs` (filesystem): Secure filesystem access with configurable allowed directories + - `allowed_directories`: Array of directory paths that the server can access (defaults to current working directory if not specified) + +### Tool Filtering + +All MCP server types support tool filtering to restrict which tools are available: + +- **`allowedTools`**: Whitelist - only specified tools are available from the server +- **`excludedTools`**: Blacklist - all tools except specified ones are available + +```json +{ + "mcpServers": { + "filesystem-readonly": { + "type": "builtin", + "name": "fs", + "allowedTools": ["read_file", "list_directory"] + }, + "filesystem-safe": { + "type": "local", + "command": ["npx", "@modelcontextprotocol/server-filesystem", "/tmp"], + "excludedTools": ["delete_file"] + } + } +} +``` + **Note**: `allowedTools` and `excludedTools` are mutually exclusive - you can only use one per server. ### Legacy Configuration Support @@ -202,14 +256,16 @@ MCPHost maintains full backward compatibility with the previous configuration fo ### Transport Types -MCPHost supports three transport types: +MCPHost supports four transport types: - **`stdio`**: Launches a local process and communicates via stdin/stdout (used by `"local"` servers) - **`sse`**: Connects to a server using Server-Sent Events (legacy format) - **`streamable`**: Connects to a server using Streamable HTTP protocol (used by `"remote"` servers) +- **`inprocess`**: Runs builtin servers in-process for optimal performance (used by `"builtin"` servers) The simplified schema automatically maps: - `"local"` type → `stdio` transport - `"remote"` type → `streamable` transport +- `"builtin"` type → `inprocess` transport ### System Prompt @@ -448,11 +504,16 @@ Example config file (`~/.mcphost.yml`): ```yaml # MCP Servers - New Simplified Format mcpServers: - filesystem: + filesystem-local: type: "local" command: ["npx", "@modelcontextprotocol/server-filesystem", "/path/to/files"] environment: DEBUG: "true" + filesystem-builtin: + type: "builtin" + name: "fs" + options: + allowed_directories: ["/tmp", "/home/user/documents"] websearch: type: "remote" url: "https://api.example.com/mcp" diff --git a/example-config.yml b/example-config.yml index c7a491d6..84a38e71 100644 --- a/example-config.yml +++ b/example-config.yml @@ -1,15 +1,28 @@ # Example MCPHost Configuration with Simplified Schema -# This demonstrates the new simplified local/remote server configuration +# This demonstrates the new simplified local/remote/builtin server configuration mcpServers: # Local MCP server - runs a command locally - filesystem: + filesystem-local: type: "local" command: ["npx", "@modelcontextprotocol/server-filesystem", "/tmp"] environment: DEBUG: "true" LOG_LEVEL: "info" + # Builtin MCP server - runs 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" + # Another local server with different command sqlite: type: "local" diff --git a/go.mod b/go.mod index 23b9866e..71c290c3 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/cloudwego/eino-ext/components/model/ollama v0.0.0-20250609074000-b7f307dffa18 github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250609074000-b7f307dffa18 github.com/getkin/kin-openapi v0.118.0 + github.com/mark3labs/mcp-filesystem-server v0.11.1 github.com/mark3labs/mcp-go v0.32.0 github.com/ollama/ollama v0.5.12 github.com/spf13/cobra v1.8.1 @@ -51,16 +52,19 @@ require ( github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250605072634-0f875e04269d // indirect + github.com/djherbis/times v1.6.0 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/evanphx/json-patch v0.5.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect @@ -109,8 +113,8 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.12.0 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.37.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect google.golang.org/grpc v1.71.0 // indirect google.golang.org/protobuf v1.36.6 // indirect diff --git a/go.sum b/go.sum index da314fd9..1b3b67f8 100644 --- a/go.sum +++ b/go.sum @@ -104,6 +104,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/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.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -119,6 +121,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/getkin/kin-openapi v0.118.0 h1:z43njxPmJ7TaPpMSCQb7PN0dEYno4tyBPQcrFdHoLuM= github.com/getkin/kin-openapi v0.118.0/go.mod h1:l5e9PaFUo9fyLJCPGQeXI2ML8c3P8BHOEV2VaAVf/pc= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= @@ -139,6 +143,8 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/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/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -196,6 +202,8 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-filesystem-server v0.11.1 h1:7uKIZRMaKWfgvtDj/uLAvo0+7Mwb8gxo5DJywhqFW88= +github.com/mark3labs/mcp-filesystem-server v0.11.1/go.mod h1:xDqJizVYWZ5a31Mt4xuYbVku2AR/kT56H3O0SbpANoQ= github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8= github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= @@ -344,19 +352,20 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= diff --git a/internal/builtin/bash.go b/internal/builtin/bash.go new file mode 100644 index 00000000..0ac69957 --- /dev/null +++ b/internal/builtin/bash.go @@ -0,0 +1,190 @@ +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 bash MCP server +func NewBashServer() (*server.MCPServer, error) { + s := server.NewMCPServer("bash-server", "1.0.0", server.WithToolCapabilities(true)) + + // Register the bash tool using the builder pattern + bashTool := mcp.NewTool("bash", + 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 + if timeoutDuration > maxTimeout { + timeout = maxTimeout + } else { + timeout = timeoutDuration + } + } + + // 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("\n%s\n\n\n%s\n", 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 = 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 mcphost 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. + + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + ` diff --git a/internal/builtin/bash_test.go b/internal/builtin/bash_test.go new file mode 100644 index 00000000..b372596c --- /dev/null +++ b/internal/builtin/bash_test.go @@ -0,0 +1,124 @@ +package builtin + +import ( + "context" + "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 := false + for _, name := range servers { + if name == "bash" { + found = true + break + } + } + + if !found { + t.Error("bash server not found in registry") + } + + // Test creating bash server through registry + wrapper, err := registry.CreateServer("bash", map[string]any{}) + 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: "bash", + 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: "bash", + 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") + } + } +} diff --git a/internal/builtin/registry.go b/internal/builtin/registry.go new file mode 100644 index 00000000..00a6133d --- /dev/null +++ b/internal/builtin/registry.go @@ -0,0 +1,117 @@ +package builtin + +import ( + "fmt" + "os" + + "github.com/mark3labs/mcp-filesystem-server/filesystemserver" + "github.com/mark3labs/mcp-go/server" +) + +// BuiltinServerWrapper wraps an external MCP server for builtin use +type BuiltinServerWrapper struct { + server *server.MCPServer +} + +// Initialize initializes the wrapped server +func (w *BuiltinServerWrapper) Initialize() error { + // The server is already initialized when created + return nil +} + +// GetServer returns the wrapped MCP server +func (w *BuiltinServerWrapper) GetServer() *server.MCPServer { + return w.server +} + +// Registry holds all available builtin servers +type Registry struct { + servers map[string]func(options map[string]any) (*BuiltinServerWrapper, error) +} + +// NewRegistry creates a new builtin server registry +func NewRegistry() *Registry { + r := &Registry{ + servers: make(map[string]func(options map[string]any) (*BuiltinServerWrapper, error)), + } + + // Register builtin servers + r.registerFilesystemServer() + r.registerBashServer() + + return r +} + +// CreateServer creates a new instance of a builtin server +func (r *Registry) CreateServer(name string, options map[string]any) (*BuiltinServerWrapper, error) { + factory, exists := r.servers[name] + if !exists { + return nil, fmt.Errorf("unknown builtin server: %s", name) + } + + return factory(options) +} + +// ListServers returns a list of 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) (*BuiltinServerWrapper, error) { + // Extract allowed directories from options + 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 { + // Default to current working directory if no directories specified + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get current working directory: %v", err) + } + allowedDirs = []string{cwd} + } + + // Create the filesystem server + 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) (*BuiltinServerWrapper, error) { + // Create the bash server + server, err := NewBashServer() + if err != nil { + return nil, fmt.Errorf("failed to create bash server: %v", err) + } + + return &BuiltinServerWrapper{server: server}, nil + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8277e714..60118fd7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,14 +14,16 @@ type MCPServerConfig struct { 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"` ExcludedTools []string `json:"excludedTools,omitempty"` - + // Legacy fields for backward compatibility - Transport string `json:"transport,omitempty"` - Args []string `json:"args,omitempty"` - Env map[string]any `json:"env,omitempty"` - Headers []string `json:"headers,omitempty"` + Transport string `json:"transport,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]any `json:"env,omitempty"` + Headers []string `json:"headers,omitempty"` } // UnmarshalJSON handles both new and legacy config formats @@ -32,10 +34,12 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error { Command []string `json:"command,omitempty"` Environment map[string]string `json:"environment,omitempty"` URL string `json:"url,omitempty"` + Name string `json:"name,omitempty"` + Options map[string]any `json:"options,omitempty"` AllowedTools []string `json:"allowedTools,omitempty"` ExcludedTools []string `json:"excludedTools,omitempty"` } - + // Also try legacy format type legacyFormat struct { Transport string `json:"transport,omitempty"` @@ -47,7 +51,7 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error { AllowedTools []string `json:"allowedTools,omitempty"` ExcludedTools []string `json:"excludedTools,omitempty"` } - + // Try new format first var newConfig newFormat if err := json.Unmarshal(data, &newConfig); err == nil && newConfig.Type != "" { @@ -55,17 +59,19 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error { s.Command = newConfig.Command s.Environment = newConfig.Environment s.URL = newConfig.URL + s.Name = newConfig.Name + s.Options = newConfig.Options s.AllowedTools = newConfig.AllowedTools s.ExcludedTools = newConfig.ExcludedTools return nil } - + // Fall back to legacy format var legacyConfig legacyFormat if err := json.Unmarshal(data, &legacyConfig); err != nil { return err } - + // Convert legacy format to new format s.Transport = legacyConfig.Transport if legacyConfig.Command != "" { @@ -77,7 +83,7 @@ func (s *MCPServerConfig) UnmarshalJSON(data []byte) error { s.Headers = legacyConfig.Headers s.AllowedTools = legacyConfig.AllowedTools s.ExcludedTools = legacyConfig.ExcludedTools - + return nil } @@ -110,11 +116,13 @@ func (s *MCPServerConfig) GetTransportType() string { return "stdio" case "remote": return "streamable" + case "builtin": + return "inprocess" default: return s.Type } } - + // Legacy format support if s.Transport != "" { return s.Transport @@ -147,8 +155,12 @@ 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", serverName, transport) + return fmt.Errorf("server %s: unsupported transport type '%s'. Supported types: stdio, sse, streamable, inprocess", serverName, transport) } } return nil @@ -228,6 +240,16 @@ func createDefaultConfig(homeDir string) error { # type: "local" # command: ["uvx", "mcp-server-sqlite", "--db-path", "/tmp/example.db"] # +# # Builtin servers - run in-process for optimal performance +# filesystem-builtin: +# type: "builtin" +# name: "fs" +# options: +# allowed_directories: ["/tmp", "/home/user/documents"] +# filesystem-cwd: +# type: "builtin" +# name: "fs" # Defaults to current working directory if no options specified +# # # Remote servers - connect via StreamableHTTP # websearch: # type: "remote" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b41b12c7..f8825fd3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -14,29 +14,29 @@ func TestMCPServerConfig_NewFormat(t *testing.T) { "MY_ENV_VAR": "my_env_var_value" } }` - + var config MCPServerConfig err := json.Unmarshal([]byte(jsonData), &config) if err != nil { t.Fatalf("Failed to unmarshal new format: %v", err) } - + if config.Type != "local" { t.Errorf("Expected type 'local', got '%s'", config.Type) } - + if len(config.Command) != 3 { t.Errorf("Expected 3 command parts, got %d", len(config.Command)) } - + if config.Command[0] != "bun" || config.Command[1] != "x" || config.Command[2] != "my-mcp-command" { t.Errorf("Command parts incorrect: %v", config.Command) } - + if config.Environment["MY_ENV_VAR"] != "my_env_var_value" { t.Errorf("Environment variable not set correctly") } - + // Test transport type detection transportType := config.GetTransportType() if transportType != "stdio" { @@ -50,21 +50,21 @@ func TestMCPServerConfig_RemoteFormat(t *testing.T) { "type": "remote", "url": "https://my-mcp-server.com" }` - + var config MCPServerConfig err := json.Unmarshal([]byte(jsonData), &config) if err != nil { t.Fatalf("Failed to unmarshal remote format: %v", err) } - + if config.Type != "remote" { t.Errorf("Expected type 'remote', got '%s'", config.Type) } - + if config.URL != "https://my-mcp-server.com" { t.Errorf("Expected URL 'https://my-mcp-server.com', got '%s'", config.URL) } - + // Test transport type detection transportType := config.GetTransportType() if transportType != "streamable" { @@ -81,25 +81,25 @@ func TestMCPServerConfig_LegacyFormat(t *testing.T) { "MY_VAR": "value" } }` - + var config MCPServerConfig err := json.Unmarshal([]byte(jsonData), &config) if err != nil { t.Fatalf("Failed to unmarshal legacy format: %v", err) } - + if len(config.Command) != 3 { t.Errorf("Expected 3 command parts, got %d", len(config.Command)) } - + if config.Command[0] != "npx" || config.Command[1] != "@modelcontextprotocol/server-filesystem" || config.Command[2] != "/path" { t.Errorf("Command parts incorrect: %v", config.Command) } - + if config.Env["MY_VAR"] != "value" { t.Errorf("Legacy environment variable not set correctly") } - + // Test transport type detection transportType := config.GetTransportType() if transportType != "stdio" { @@ -107,6 +107,69 @@ func TestMCPServerConfig_LegacyFormat(t *testing.T) { } } +func TestMCPServerConfig_BuiltinFormat(t *testing.T) { + // Test builtin format with allowed_directories + jsonData := `{ + "type": "builtin", + "name": "fs", + "options": { + "allowed_directories": ["/tmp", "/home/user"] + } + }` + + var config MCPServerConfig + err := json.Unmarshal([]byte(jsonData), &config) + if err != nil { + t.Fatalf("Failed to unmarshal 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) + } + + 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) + } +} + func TestConfig_Validate(t *testing.T) { config := &Config{ MCPServers: map[string]MCPServerConfig{ @@ -118,11 +181,18 @@ 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"}, + }, + }, }, } - + err := config.Validate() if err != nil { t.Errorf("Validation failed: %v", err) } -} \ No newline at end of file +} diff --git a/internal/tools/mcp.go b/internal/tools/mcp.go index 90ddd80f..1f9d1a47 100644 --- a/internal/tools/mcp.go +++ b/internal/tools/mcp.go @@ -14,6 +14,7 @@ import ( "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/client/transport" "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcphost/internal/builtin" "github.com/mark3labs/mcphost/internal/config" ) @@ -221,7 +222,7 @@ func (m *MCPToolManager) createMCPClient(ctx context.Context, serverName string, var env []string var command string var args []string - + // Handle command and environment if len(serverConfig.Command) > 0 { command = serverConfig.Command[0] @@ -229,14 +230,14 @@ func (m *MCPToolManager) createMCPClient(ctx context.Context, serverName string, args = serverConfig.Command[1:] } } - + // Convert environment variables if serverConfig.Environment != nil { for k, v := range serverConfig.Environment { env = append(env, fmt.Sprintf("%s=%s", k, v)) } } - + // Legacy environment support if serverConfig.Env != nil { for k, v := range serverConfig.Env { @@ -303,6 +304,10 @@ func (m *MCPToolManager) createMCPClient(ctx context.Context, serverName string, return streamableClient, nil + case "inprocess": + // Builtin server + return m.createBuiltinClient(ctx, serverName, serverConfig) + default: return nil, fmt.Errorf("unsupported transport type '%s' for server %s", transportType, serverName) } @@ -326,3 +331,22 @@ func (m *MCPToolManager) initializeClient(ctx context.Context, client client.MCP } return nil } + +// createBuiltinClient creates an in-process MCP client for builtin servers +func (m *MCPToolManager) createBuiltinClient(ctx context.Context, serverName string, serverConfig config.MCPServerConfig) (client.MCPClient, error) { + registry := builtin.NewRegistry() + + // Create the builtin server + builtinServer, err := registry.CreateServer(serverConfig.Name, serverConfig.Options) + if err != nil { + return nil, fmt.Errorf("failed to create builtin server: %v", err) + } + + // Create an in-process client that wraps the builtin server + inProcessClient, err := client.NewInProcessClient(builtinServer.GetServer()) + if err != nil { + return nil, fmt.Errorf("failed to create in-process client: %v", err) + } + + return inProcessClient, nil +}