diff --git a/AGENTS.md b/AGENTS.md index 1e85ae74..7f08240e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,30 +1,53 @@ -# Agent Development Guide +# MCPHost Development Context ## Build/Test Commands -- **Build**: `go build -o mcphost .` or `go install` -- **Run**: `go run main.go` or `./mcphost` +- **Build**: `go build -o output/mcphost` or use `./contribute/build.sh` - **Test**: `go test ./...` (run all tests) -- **Test single package**: `go test ./internal/config` -- **Lint**: `go vet ./...` and `gofmt -s -w .` -- **Dependencies**: `go mod tidy` and `go mod download` +- **Test single package**: `go test ./pkg/llm/anthropic` +- **Lint**: `go vet ./...` (built-in Go linter) +- **Format**: `go fmt ./...` +- **Dependencies**: `go mod tidy` ## Code Style Guidelines -- **Imports**: Standard library first, then third-party, then local packages (separated by blank lines) -- **Naming**: Use camelCase for variables/functions, PascalCase for exported types +- **Package structure**: `pkg/` for reusable packages, `cmd/` for CLI commands +- **Imports**: Standard library first, then third-party, then local packages with blank lines between groups +- **Naming**: Use camelCase for unexported, PascalCase for exported; descriptive names (e.g., `CreateMessage`, `mcpClients`) +- **Interfaces**: Keep small and focused (e.g., `llm.Provider`, `llm.Message`) - **Error handling**: Always check errors, wrap with context using `fmt.Errorf("context: %v", err)` -- **Comments**: Use `//` for single line, document exported functions/types -- **Structs**: Use struct tags for JSON/YAML serialization (`json:"field" yaml:"field"`) -- **Interfaces**: Keep interfaces small and focused (e.g., `tool.BaseTool`, `model.ToolCallingChatModel`) +- **Logging**: Use `github.com/charmbracelet/log` with structured logging: `log.Info("message", "key", value)` +- **Types**: Prefer `any` over `interface{}` (modernize hint from linter) +- **JSON tags**: Use snake_case for JSON fields, include omitempty where appropriate +- **Context**: Always pass `context.Context` as first parameter for operations that may block -## Architecture -- **cmd/**: CLI commands and flag handling using Cobra -- **internal/**: Private application code (agent, config, models, tools, ui) -- **main.go**: Entry point, delegates to cmd package -- **go.mod**: Go 1.23+ required, uses Eino framework for LLM integration +## Architecture Notes +- Multi-provider LLM support (Anthropic, OpenAI, Ollama, Google) +- MCP (Model Context Protocol) client-server architecture for tool integration +- Provider pattern for LLM abstraction with common `llm.Provider` interface +- History management with message pruning based on configurable window size +- Tool calling support across all providers with unified `llm.Tool` interface -## Key Patterns -- Use context.Context for cancellation and timeouts -- Implement proper resource cleanup with defer statements -- Use viper for configuration management (supports YAML/JSON) -- Follow MCP (Model Context Protocol) for tool integration -- Use structured logging and error wrapping \ No newline at end of file +## MCP Configuration Schema +MCPHost supports a simplified configuration schema with two server types: + +### New Simplified Format +- **Local servers** (`"type": "local"`): Run commands locally via stdio transport + - `command`: Array of command and arguments (e.g., `["npx", "server", "args"]`) + - `environment`: Key-value map of environment variables +- **Remote servers** (`"type": "remote"`): Connect via StreamableHTTP transport + - `url`: Server endpoint URL + - Automatically uses StreamableHTTP for optimal performance + +### Legacy Format Support +- Maintains full backward compatibility with existing configurations +- Automatic detection and conversion of legacy formats +- Custom `UnmarshalJSON` method handles format migration seamlessly + +### Transport Mapping +- `"local"` type → `stdio` transport (launches local processes) +- `"remote"` type → `streamable` transport (StreamableHTTP protocol) +- Legacy `transport` field still supported for backward compatibility + +### Configuration Files +- Primary: `~/.mcphost.yml` or `~/.mcphost.json` +- Legacy: `~/.mcp.yml` or `~/.mcp.json` +- Custom location via `--config` flag \ No newline at end of file diff --git a/README.md b/README.md index 4ac10130..23f52c00 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ go install github.com/mark3labs/mcphost@latest ## Configuration ⚙️ -### MCP-server +### MCP Servers MCPHost will automatically create a configuration file in your home directory if it doesn't exist. It looks for config files in this order: - `.mcphost.yml` or `.mcphost.json` (preferred) - `.mcp.yml` or `.mcp.json` (backwards compatibility) @@ -94,96 +94,122 @@ MCPHost will automatically create a configuration file in your home directory if You can also specify a custom location using the `--config` flag. -#### STDIO -The configuration for an STDIO MCP-server should be defined as the following: +### Simplified Configuration Schema + +MCPHost now supports a simplified configuration schema with two server types: + +#### Local Servers +For local MCP servers that run commands on your machine: +```json +{ + "mcpServers": { + "filesystem": { + "type": "local", + "command": ["npx", "@modelcontextprotocol/server-filesystem", "/tmp"], + "environment": { + "DEBUG": "true", + "LOG_LEVEL": "info" + }, + "allowedTools": ["read_file", "write_file"], + "excludedTools": ["delete_file"] + }, + "sqlite": { + "type": "local", + "command": ["uvx", "mcp-server-sqlite", "--db-path", "/tmp/foo.db"], + "environment": { + "SQLITE_DEBUG": "1" + } + } + } +} +``` + +Each local server entry requires: +- `type`: Must be set to `"local"` +- `command`: Array containing the command and all its arguments +- `environment`: (Optional) Object with environment variables as key-value pairs +- `allowedTools`: (Optional) Array of tool names to include (whitelist) +- `excludedTools`: (Optional) Array of tool names to exclude (blacklist) + +#### Remote Servers +For remote MCP servers accessible via HTTP: +```json +{ + "mcpServers": { + "websearch": { + "type": "remote", + "url": "https://api.example.com/mcp" + }, + "weather": { + "type": "remote", + "url": "https://weather-mcp.example.com" + } + } +} +``` + +Each remote server entry requires: +- `type`: Must be set to `"remote"` +- `url`: The URL where the MCP server is accessible + +Remote servers automatically use the StreamableHTTP transport for optimal performance. + +**Note**: `allowedTools` and `excludedTools` are mutually exclusive - you can only use one per server. + +### Legacy Configuration Support + +MCPHost maintains full backward compatibility with the previous configuration format: + +#### Legacy STDIO Format ```json { "mcpServers": { "sqlite": { "command": "uvx", - "args": [ - "mcp-server-sqlite", - "--db-path", - "/tmp/foo.db" - ] - }, - "filesystem": { - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem", - "/tmp" - ], - "allowedTools": ["read_file", "write_file"], - "excludedTools": ["delete_file"] + "args": ["mcp-server-sqlite", "--db-path", "/tmp/foo.db"], + "env": { + "DEBUG": "true" + } } } } ``` -Each STDIO entry requires: -- `command`: The command to run (e.g., `uvx`, `npx`) -- `args`: Array of arguments for the command: - - For SQLite server: `mcp-server-sqlite` with database path - - For filesystem server: `@modelcontextprotocol/server-filesystem` with directory path -- `allowedTools`: (Optional) Array of tool names to include (whitelist) -- `excludedTools`: (Optional) Array of tool names to exclude (blacklist) - -**Note**: `allowedTools` and `excludedTools` are mutually exclusive - you can only use one per server. - -### Server Side Events (SSE) - -For SSE the following config should be used: +#### Legacy SSE Format ```json { "mcpServers": { "server_name": { - "url": "http://some_jhost:8000/sse", - "headers":[ - "Authorization: Bearer my-token" - ] + "url": "http://some_host:8000/sse", + "headers": ["Authorization: Bearer my-token"] } } } ``` -Each SSE entry requires: -- `url`: The URL where the MCP server is accessible. -- `headers`: (Optional) Array of headers that will be attached to the requests - -### Streamable HTTP - -For Streamable HTTP transport, use the following configuration: +#### Legacy Streamable HTTP Format ```json { "mcpServers": { "websearch": { "transport": "streamable", "url": "https://api.example.com/mcp", - "headers": [ - "Authorization: Bearer your-api-token", - "Content-Type: application/json" - ] + "headers": ["Authorization: Bearer your-api-token"] } } } ``` -Each Streamable HTTP entry requires: -- `transport`: Must be set to `"streamable"` -- `url`: The URL where the MCP server is accessible -- `headers`: (Optional) Array of headers that will be attached to the requests - ### Transport Types MCPHost supports three transport types: -- **`stdio`** (default): Launches a local process and communicates via stdin/stdout -- **`sse`**: Connects to a server using Server-Sent Events -- **`streamable`**: Connects to a server using Streamable HTTP protocol +- **`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) -If no `transport` field is specified, MCPHost will automatically detect the transport type: -- If `command` is present → `stdio` -- If `url` is present → `sse` +The simplified schema automatically maps: +- `"local"` type → `stdio` transport +- `"remote"` type → `streamable` transport ### System Prompt @@ -253,9 +279,8 @@ Scripts combine YAML configuration with prompts in a single executable file. The # This script uses the container-use MCP server from https://github.com/dagger/container-use mcpServers: container-use: - command: cu - args: - - "stdio" + type: "local" + command: ["cu", "stdio"] prompt: | Create 2 variations of a simple hello world app using Flask and FastAPI. Each in their own environment. Give me the URL of each app @@ -270,9 +295,8 @@ Or alternatively, omit the `prompt:` field and place the prompt after the frontm # This script uses the container-use MCP server from https://github.com/dagger/container-use mcpServers: container-use: - command: cu - args: - - "stdio" + type: "local" + command: ["cu", "stdio"] --- Create 2 variations of a simple hello world app using Flask and FastAPI. Each in their own environment. Give me the URL of each app @@ -293,8 +317,8 @@ Example script with variables: --- mcpServers: filesystem: - command: npx - args: ["-y", "@modelcontextprotocol/server-filesystem", "${directory}"] + type: "local" + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "${directory}"] --- Hello ${name}! Please list the files in ${directory} and tell me about them. ``` @@ -422,11 +446,16 @@ All command-line flags can be configured via the config file. MCPHost will look Example config file (`~/.mcphost.yml`): ```yaml -# MCP Servers +# MCP Servers - New Simplified Format mcpServers: filesystem: - command: npx - args: ["@modelcontextprotocol/server-filesystem", "/path/to/files"] + type: "local" + command: ["npx", "@modelcontextprotocol/server-filesystem", "/path/to/files"] + environment: + DEBUG: "true" + websearch: + type: "remote" + url: "https://api.example.com/mcp" # Application settings model: "anthropic:claude-sonnet-4-20250514" diff --git a/example-config.yml b/example-config.yml new file mode 100644 index 00000000..c7a491d6 --- /dev/null +++ b/example-config.yml @@ -0,0 +1,33 @@ +# Example MCPHost Configuration with Simplified Schema +# This demonstrates the new simplified local/remote server configuration + +mcpServers: + # Local MCP server - runs a command locally + filesystem: + type: "local" + command: ["npx", "@modelcontextprotocol/server-filesystem", "/tmp"] + environment: + DEBUG: "true" + LOG_LEVEL: "info" + + # Another local server with different command + sqlite: + type: "local" + command: ["uvx", "mcp-server-sqlite", "--db-path", "/tmp/example.db"] + environment: + SQLITE_DEBUG: "1" + + # Remote MCP server - connects via StreamableHTTP + websearch: + type: "remote" + url: "https://api.example.com/mcp" + + # Another remote server + weather: + type: "remote" + url: "https://weather-mcp.example.com" + +# Application settings +model: "anthropic:claude-sonnet-4-20250514" +max-steps: 10 +debug: false \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 133eed3e..8277e714 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -9,14 +10,75 @@ import ( // MCPServerConfig represents configuration for an MCP server type MCPServerConfig struct { + Type string `json:"type"` + Command []string `json:"command,omitempty"` + Environment map[string]string `json:"environment,omitempty"` + URL string `json:"url,omitempty"` + AllowedTools []string `json:"allowedTools,omitempty"` + ExcludedTools []string `json:"excludedTools,omitempty"` + + // Legacy fields for backward compatibility Transport string `json:"transport,omitempty"` - Command string `json:"command,omitempty"` Args []string `json:"args,omitempty"` Env map[string]any `json:"env,omitempty"` - URL string `json:"url,omitempty"` Headers []string `json:"headers,omitempty"` - AllowedTools []string `json:"allowedTools,omitempty"` - ExcludedTools []string `json:"excludedTools,omitempty"` +} + +// UnmarshalJSON handles both new and legacy config formats +func (s *MCPServerConfig) UnmarshalJSON(data []byte) error { + // First try to unmarshal as the new format + type newFormat struct { + Type string `json:"type"` + Command []string `json:"command,omitempty"` + Environment map[string]string `json:"environment,omitempty"` + URL string `json:"url,omitempty"` + AllowedTools []string `json:"allowedTools,omitempty"` + ExcludedTools []string `json:"excludedTools,omitempty"` + } + + // Also try legacy format + type legacyFormat struct { + Transport string `json:"transport,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]any `json:"env,omitempty"` + URL string `json:"url,omitempty"` + Headers []string `json:"headers,omitempty"` + 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 != "" { + s.Type = newConfig.Type + s.Command = newConfig.Command + s.Environment = newConfig.Environment + s.URL = newConfig.URL + 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 != "" { + s.Command = append([]string{legacyConfig.Command}, legacyConfig.Args...) + } + s.Args = legacyConfig.Args + s.Env = legacyConfig.Env + s.URL = legacyConfig.URL + s.Headers = legacyConfig.Headers + s.AllowedTools = legacyConfig.AllowedTools + s.ExcludedTools = legacyConfig.ExcludedTools + + return nil } // Config represents the application configuration @@ -41,11 +103,24 @@ type Config struct { // GetTransportType returns the transport type for the server config func (s *MCPServerConfig) GetTransportType() string { + // New simplified format + if s.Type != "" { + switch s.Type { + case "local": + return "stdio" + case "remote": + return "streamable" + default: + return s.Type + } + } + + // Legacy format support if s.Transport != "" { return s.Transport } // Backward compatibility: infer transport type - if s.Command != "" { + if len(s.Command) > 0 { return "stdio" } if s.URL != "" { @@ -64,7 +139,8 @@ func (c *Config) Validate() error { transport := serverConfig.GetTransportType() switch transport { case "stdio": - if serverConfig.Command == "" { + // Check both new and legacy command formats + if len(serverConfig.Command) == 0 && serverConfig.Transport == "" { return fmt.Errorf("server %s: command is required for stdio transport", serverName) } case "sse", "streamable": @@ -140,27 +216,29 @@ func createDefaultConfig(homeDir string) error { # MCP Servers configuration # Add your MCP servers here -# Examples for different transport types: +# Examples for different server types: # mcpServers: -# # STDIO transport (default) - launches local processes +# # Local servers - run commands locally # filesystem: -# command: npx -# args: ["@modelcontextprotocol/server-filesystem", "/path/to/allowed/files"] +# type: "local" +# command: ["npx", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/files"] +# environment: +# MY_ENV_VAR: "my_env_var_value" # sqlite: -# command: uvx -# args: ["mcp-server-sqlite", "--db-path", "/tmp/example.db"] +# type: "local" +# command: ["uvx", "mcp-server-sqlite", "--db-path", "/tmp/example.db"] # -# # SSE transport - connects to remote servers via Server-Sent Events -# remote-sse: -# transport: sse -# url: "https://api.example.com/sse" -# headers: ["Authorization: Bearer your-token"] -# -# # Streamable HTTP transport - connects via Streamable HTTP protocol +# # Remote servers - connect via StreamableHTTP # websearch: -# transport: streamable +# type: "remote" # url: "https://api.example.com/mcp" -# headers: ["Authorization: Bearer your-api-token"] +# +# # Legacy format still supported for backward compatibility: +# # legacy-server: +# # command: npx +# # args: ["@modelcontextprotocol/server-filesystem", "/path"] +# # env: +# # MY_VAR: "value" mcpServers: diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..b41b12c7 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,128 @@ +package config + +import ( + "encoding/json" + "testing" +) + +func TestMCPServerConfig_NewFormat(t *testing.T) { + // Test new simplified format + jsonData := `{ + "type": "local", + "command": ["bun", "x", "my-mcp-command"], + "environment": { + "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" { + t.Errorf("Expected transport type 'stdio', got '%s'", transportType) + } +} + +func TestMCPServerConfig_RemoteFormat(t *testing.T) { + // Test remote format + jsonData := `{ + "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" { + t.Errorf("Expected transport type 'streamable', got '%s'", transportType) + } +} + +func TestMCPServerConfig_LegacyFormat(t *testing.T) { + // Test legacy format still works + jsonData := `{ + "command": "npx", + "args": ["@modelcontextprotocol/server-filesystem", "/path"], + "env": { + "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" { + t.Errorf("Expected transport type 'stdio', got '%s'", transportType) + } +} + +func TestConfig_Validate(t *testing.T) { + config := &Config{ + MCPServers: map[string]MCPServerConfig{ + "local-server": { + Type: "local", + Command: []string{"echo", "hello"}, + }, + "remote-server": { + Type: "remote", + URL: "https://example.com", + }, + }, + } + + 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 54c13e4a..90ddd80f 100644 --- a/internal/tools/mcp.go +++ b/internal/tools/mcp.go @@ -218,12 +218,33 @@ func (m *MCPToolManager) createMCPClient(ctx context.Context, serverName string, switch transportType { case "stdio": // STDIO client - env := make([]string, 0, len(serverConfig.Env)) - for k, v := range serverConfig.Env { - env = append(env, fmt.Sprintf("%s=%v", k, v)) + var env []string + var command string + var args []string + + // Handle command and environment + if len(serverConfig.Command) > 0 { + command = serverConfig.Command[0] + if len(serverConfig.Command) > 1 { + 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 { + env = append(env, fmt.Sprintf("%s=%v", k, v)) + } } - stdioClient, err := client.NewStdioMCPClient(serverConfig.Command, env, serverConfig.Args...) + stdioClient, err := client.NewStdioMCPClient(command, env, args...) if err != nil { return nil, fmt.Errorf("failed to create stdio client: %v", err) } diff --git a/internal/tools/mcp_test.go b/internal/tools/mcp_test.go index bf2422f4..a32b559a 100644 --- a/internal/tools/mcp_test.go +++ b/internal/tools/mcp_test.go @@ -15,8 +15,7 @@ func TestMCPToolManager_LoadTools_WithTimeout(t *testing.T) { cfg := &config.Config{ MCPServers: map[string]config.MCPServerConfig{ "test-server": { - Command: "non-existent-command", - Args: []string{"arg1", "arg2"}, + Command: []string{"non-existent-command", "arg1", "arg2"}, }, }, } @@ -50,12 +49,10 @@ func TestMCPToolManager_LoadTools_GracefulFailure(t *testing.T) { cfg := &config.Config{ MCPServers: map[string]config.MCPServerConfig{ "bad-server-1": { - Command: "non-existent-command-1", - Args: []string{"arg1"}, + Command: []string{"non-existent-command-1", "arg1"}, }, "bad-server-2": { - Command: "non-existent-command-2", - Args: []string{"arg2"}, + Command: []string{"non-existent-command-2", "arg2"}, }, }, }