builtin servers

This commit is contained in:
Ed Zynda
2025-06-24 16:29:44 +03:00
parent 0d6742286b
commit d9983b9524
11 changed files with 682 additions and 44 deletions
+5 -1
View File
@@ -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
+64 -3
View File
@@ -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"
+15 -2
View File
@@ -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"
+6 -2
View File
@@ -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
+13 -4
View File
@@ -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=
+190
View File
@@ -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("<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 = 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.
<good-example>
pytest /foo/bar/tests
</good-example>
<bad-example>
cd /foo/bar && pytest tests
</bad-example>`
+124
View File
@@ -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")
}
}
}
+117
View File
@@ -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
}
}
+34 -12
View File
@@ -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"
+87 -17
View File
@@ -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)
}
}
}
+27 -3
View File
@@ -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
}