diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21a647c..2cdb0de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,3 +15,5 @@ jobs: with: go-version-file: 'go.mod' - run: go test ./... -race + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/README.md b/README.md index c26f306..94284f2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Discuss the Project on [Discord](https://discord.gg/RqSS2NQVsY) - [Requirements](#requirements-) - [Environment Setup](#environment-setup-) - [Installation](#installation-) +- [SDK Usage](#sdk-usage-) - [Configuration](#configuration-) - [MCP Servers](#mcp-servers) - [Environment Variable Substitution](#environment-variable-substitution) @@ -126,6 +127,52 @@ mcphost --provider-url https://192.168.1.100:443 --tls-skip-verify go install github.com/mark3labs/mcphost@latest ``` +## SDK Usage 🛠️ + +MCPHost also provides a Go SDK for programmatic access without spawning OS processes. The SDK maintains identical behavior to the CLI, including configuration loading, environment variables, and defaults. + +### Quick Example + +```go +package main + +import ( + "context" + "fmt" + "github.com/mark3labs/mcphost/sdk" +) + +func main() { + ctx := context.Background() + + // Create MCPHost instance with default configuration + host, err := sdk.New(ctx, nil) + if err != nil { + panic(err) + } + defer host.Close() + + // Send a prompt and get response + response, err := host.Prompt(ctx, "What is 2+2?") + if err != nil { + panic(err) + } + + fmt.Println(response) +} +``` + +### SDK Features + +- ✅ Programmatic access without spawning processes +- ✅ Identical configuration behavior to CLI +- ✅ Session management (save/load/clear) +- ✅ Tool execution callbacks for monitoring +- ✅ Streaming support +- ✅ Full compatibility with all providers and MCP servers + +For detailed SDK documentation, examples, and API reference, see the [SDK README](sdk/README.md). + ## Configuration ⚙️ ### MCP Servers diff --git a/cmd/root.go b/cmd/root.go index 15b6cfc..19008e4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -126,10 +126,10 @@ func GetRootCommand(v string) *cobra.Command { return rootCmd } -func initConfig() { +func InitConfig() { if configFile != "" { // Use config file from the flag - if err := loadConfigWithEnvSubstitution(configFile); err != nil { + if err := LoadConfigWithEnvSubstitution(configFile); err != nil { fmt.Fprintf(os.Stderr, "Error reading config file '%s': %v\n", configFile, err) os.Exit(1) } @@ -163,7 +163,7 @@ func initConfig() { if err := viper.ReadInConfig(); err == nil { // Config file found, now reload it with env substitution configPath := viper.ConfigFileUsed() - if err := loadConfigWithEnvSubstitution(configPath); err != nil { + if err := LoadConfigWithEnvSubstitution(configPath); err != nil { // Only exit on environment variable substitution errors if strings.Contains(err.Error(), "environment variable substitution failed") { fmt.Fprintf(os.Stderr, "Error reading config file '%s': %v\n", configPath, err) @@ -202,8 +202,8 @@ func initConfig() { } -// loadConfigWithEnvSubstitution loads a config file with environment variable substitution -func loadConfigWithEnvSubstitution(configPath string) error { +// LoadConfigWithEnvSubstitution loads a config file with environment variable substitution +func LoadConfigWithEnvSubstitution(configPath string) error { // Read raw config file content rawContent, err := os.ReadFile(configPath) if err != nil { @@ -252,7 +252,7 @@ func configToUiTheme(theme config.Theme) ui.Theme { } func init() { - cobra.OnInitialize(initConfig) + cobra.OnInitialize(InitConfig) var theme config.Theme err := config.FilepathOr("theme", &theme) if err == nil && viper.InConfig("theme") { diff --git a/internal/hooks/config_test.go b/internal/hooks/config_test.go index 2f9a268..ec84ac0 100644 --- a/internal/hooks/config_test.go +++ b/internal/hooks/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "reflect" + "sort" "testing" ) @@ -137,6 +138,8 @@ hooks: } paths = append(paths, path) } + // Sort paths to ensure deterministic order + sort.Strings(paths) // Load configuration got, err := LoadHooksConfig(paths...) diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..e10b42d --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,148 @@ +# MCPHost SDK + +The MCPHost SDK allows you to use MCPHost programmatically from Go applications without spawning OS processes. + +## Installation + +```bash +go get github.com/mark3labs/mcphost +``` + +## Basic Usage + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/mark3labs/mcphost/sdk" +) + +func main() { + ctx := context.Background() + + // Create MCPHost instance with default configuration + host, err := sdk.New(ctx, nil) + if err != nil { + log.Fatal(err) + } + defer host.Close() + + // Send a prompt + response, err := host.Prompt(ctx, "What is 2+2?") + if err != nil { + log.Fatal(err) + } + + fmt.Println(response) +} +``` + +## Configuration + +The SDK behaves identically to the CLI: +- Loads configuration from `~/.mcphost.yml` by default +- Creates default configuration if none exists +- Respects all environment variables (`MCPHOST_*`) +- Uses the same defaults as the CLI + +### Options + +You can override specific settings: + +```go +host, err := sdk.New(ctx, &sdk.Options{ + Model: "ollama:llama3", // Override model + SystemPrompt: "You are a helpful bot", // Override system prompt + ConfigFile: "/path/to/config.yml", // Use specific config file + MaxSteps: 10, // Override max steps + Streaming: true, // Enable streaming + Quiet: true, // Suppress debug output +}) +``` + +## Advanced Usage + +### With Tool Callbacks + +Monitor tool execution in real-time: + +```go +response, err := host.PromptWithCallbacks( + ctx, + "List files in the current directory", + func(name, args string) { + fmt.Printf("Calling tool: %s\n", name) + }, + func(name, args, result string, isError bool) { + if isError { + fmt.Printf("Tool %s failed: %s\n", name, result) + } else { + fmt.Printf("Tool %s succeeded\n", name) + } + }, + func(chunk string) { + fmt.Print(chunk) // Stream output + }, +) +``` + +### Session Management + +Maintain conversation context: + +```go +// First message +host.Prompt(ctx, "My name is Alice") + +// Second message (remembers context) +response, _ := host.Prompt(ctx, "What's my name?") +// Response: "Your name is Alice" + +// Save session +host.SaveSession("./session.json") + +// Load session later +host.LoadSession("./session.json") + +// Clear session +host.ClearSession() +``` + +## API Reference + +### Types + +- `MCPHost` - Main SDK type +- `Options` - Configuration options +- `Message` - Conversation message +- `ToolCall` - Tool invocation details + +### Methods + +- `New(ctx, opts)` - Create new MCPHost instance +- `Prompt(ctx, message)` - Send message and get response +- `PromptWithCallbacks(ctx, message, ...)` - Send message with progress callbacks +- `LoadSession(path)` - Load session from file +- `SaveSession(path)` - Save session to file +- `ClearSession()` - Clear conversation history +- `GetSessionManager()` - Get session manager for advanced usage +- `GetModelString()` - Get current model string +- `Close()` - Clean up resources + +## Environment Variables + +All CLI environment variables work with the SDK: + +- `MCPHOST_MODEL` - Override model +- `ANTHROPIC_API_KEY` - Anthropic API key +- `OPENAI_API_KEY` - OpenAI API key +- `GEMINI_API_KEY` - Google API key +- etc. + +## License + +Same as MCPHost CLI \ No newline at end of file diff --git a/sdk/examples/basic/main.go b/sdk/examples/basic/main.go new file mode 100644 index 0000000..85a11c3 --- /dev/null +++ b/sdk/examples/basic/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "fmt" + "log" + + "github.com/mark3labs/mcphost/sdk" +) + +func main() { + ctx := context.Background() + + // Example 1: Use all defaults (loads ~/.mcphost.yml) + fmt.Println("=== Example 1: Default configuration ===") + host, err := sdk.New(ctx, nil) + if err != nil { + log.Fatal(err) + } + defer host.Close() + + response, err := host.Prompt(ctx, "What is 2+2?") + if err != nil { + log.Fatal(err) + } + fmt.Printf("Response: %s\n\n", response) + + // Example 2: Override model + fmt.Println("=== Example 2: Custom model ===") + host2, err := sdk.New(ctx, &sdk.Options{ + Model: "ollama:qwen3:8b", + }) + if err != nil { + log.Fatal(err) + } + defer host2.Close() + + response, err = host2.Prompt(ctx, "Tell me a short joke") + if err != nil { + log.Fatal(err) + } + fmt.Printf("Response: %s\n\n", response) + + // Example 3: With callbacks + fmt.Println("=== Example 3: With tool callbacks ===") + host3, err := sdk.New(ctx, nil) + if err != nil { + log.Fatal(err) + } + defer host3.Close() + + response, err = host3.PromptWithCallbacks( + ctx, + "List files in the current directory", + func(name, args string) { + fmt.Printf("🔧 Calling tool: %s\n", name) + }, + func(name, args, result string, isError bool) { + if isError { + fmt.Printf("❌ Tool %s failed\n", name) + } else { + fmt.Printf("✅ Tool %s completed\n", name) + } + }, + func(chunk string) { + fmt.Print(chunk) // Stream output + }, + ) + if err != nil { + log.Fatal(err) + } + fmt.Printf("\nFinal response: %s\n", response) + + // Example 4: Session management + fmt.Println("\n=== Example 4: Session management ===") + host4, err := sdk.New(ctx, nil) + if err != nil { + log.Fatal(err) + } + defer host4.Close() + + // First message + _, err = host4.Prompt(ctx, "Remember that my favorite color is blue") + if err != nil { + log.Fatal(err) + } + + // Second message (should remember context) + response, err = host4.Prompt(ctx, "What's my favorite color?") + if err != nil { + log.Fatal(err) + } + fmt.Printf("Response: %s\n", response) + + // Save session + if err := host4.SaveSession("./session.json"); err != nil { + log.Fatal(err) + } + fmt.Println("Session saved to ./session.json") +} diff --git a/sdk/examples/scripting/main.go b/sdk/examples/scripting/main.go new file mode 100644 index 0000000..f5d802e --- /dev/null +++ b/sdk/examples/scripting/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/mark3labs/mcphost/sdk" +) + +func main() { + ctx := context.Background() + + // Create MCPHost with environment variable for API key + // Expects ANTHROPIC_API_KEY or appropriate provider key to be set + host, err := sdk.New(ctx, &sdk.Options{ + Quiet: true, // Suppress debug output for scripting + }) + if err != nil { + log.Fatal(err) + } + defer host.Close() + + // Process command line arguments + if len(os.Args) < 2 { + fmt.Println("Usage: go run main.go \"your prompt here\"") + os.Exit(1) + } + + prompt := os.Args[1] + + // Send prompt and get response + response, err := host.Prompt(ctx, prompt) + if err != nil { + log.Fatal(err) + } + + // Output only the response (useful for piping) + fmt.Println(response) +} diff --git a/sdk/mcphost.go b/sdk/mcphost.go new file mode 100644 index 0000000..a984555 --- /dev/null +++ b/sdk/mcphost.go @@ -0,0 +1,220 @@ +package sdk + +import ( + "context" + "fmt" + + "github.com/cloudwego/eino/schema" + "github.com/mark3labs/mcphost/cmd" + "github.com/mark3labs/mcphost/internal/agent" + "github.com/mark3labs/mcphost/internal/config" + "github.com/mark3labs/mcphost/internal/models" + "github.com/mark3labs/mcphost/internal/session" + "github.com/spf13/viper" +) + +// MCPHost provides programmatic access to mcphost +type MCPHost struct { + agent *agent.Agent + sessionMgr *session.Manager + modelString string +} + +// Options for creating MCPHost (all optional - will use CLI defaults) +type Options struct { + Model string // Override model (e.g., "anthropic:claude-3-sonnet") + SystemPrompt string // Override system prompt + ConfigFile string // Override config file path + MaxSteps int // Override max steps (0 = use default) + Streaming bool // Enable streaming (default from config) + Quiet bool // Suppress debug output +} + +// New creates MCPHost instance using the same initialization as CLI +func New(ctx context.Context, opts *Options) (*MCPHost, error) { + if opts == nil { + opts = &Options{} + } + + // Initialize config exactly like CLI does + cmd.InitConfig() + + // Apply overrides after initialization + if opts.ConfigFile != "" { + // Load specific config file + if err := cmd.LoadConfigWithEnvSubstitution(opts.ConfigFile); err != nil { + return nil, fmt.Errorf("failed to load config file: %v", err) + } + } + + // Override viper settings with options + if opts.Model != "" { + viper.Set("model", opts.Model) + } + if opts.SystemPrompt != "" { + viper.Set("system-prompt", opts.SystemPrompt) + } + if opts.MaxSteps > 0 { + viper.Set("max-steps", opts.MaxSteps) + } + // Only override streaming if explicitly set + viper.Set("stream", opts.Streaming) + + // Load MCP configuration using existing function + mcpConfig, err := config.LoadAndValidateConfig() + if err != nil { + return nil, fmt.Errorf("failed to load MCP config: %v", err) + } + + // Load system prompt using existing function + systemPrompt, err := config.LoadSystemPrompt(viper.GetString("system-prompt")) + if err != nil { + return nil, fmt.Errorf("failed to load system prompt: %v", err) + } + + // Create model configuration (same as CLI in root.go:387-406) + temperature := float32(viper.GetFloat64("temperature")) + topP := float32(viper.GetFloat64("top-p")) + topK := int32(viper.GetInt("top-k")) + numGPU := int32(viper.GetInt("num-gpu-layers")) + mainGPU := int32(viper.GetInt("main-gpu")) + + modelConfig := &models.ProviderConfig{ + ModelString: viper.GetString("model"), + SystemPrompt: systemPrompt, + ProviderAPIKey: viper.GetString("provider-api-key"), + ProviderURL: viper.GetString("provider-url"), + MaxTokens: viper.GetInt("max-tokens"), + Temperature: &temperature, + TopP: &topP, + TopK: &topK, + StopSequences: viper.GetStringSlice("stop-sequences"), + NumGPU: &numGPU, + MainGPU: &mainGPU, + TLSSkipVerify: viper.GetBool("tls-skip-verify"), + } + + // Create agent using existing factory (same as CLI in root.go:431-440) + a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{ + ModelConfig: modelConfig, + MCPConfig: mcpConfig, + SystemPrompt: systemPrompt, + MaxSteps: viper.GetInt("max-steps"), + StreamingEnabled: viper.GetBool("stream"), + ShowSpinner: false, // No spinner for SDK + Quiet: opts.Quiet, + }) + if err != nil { + return nil, fmt.Errorf("failed to create agent: %v", err) + } + + // Create session manager + sessionMgr := session.NewManager("") + + return &MCPHost{ + agent: a, + sessionMgr: sessionMgr, + modelString: viper.GetString("model"), + }, nil +} + +// Prompt sends a message and returns the response +func (m *MCPHost) Prompt(ctx context.Context, message string) (string, error) { + // Get messages from session + messages := m.sessionMgr.GetMessages() + + // Add new user message + userMsg := schema.UserMessage(message) + messages = append(messages, userMsg) + + // Call agent (same as CLI does in root.go:902) + result, err := m.agent.GenerateWithLoop(ctx, messages, + nil, // onToolCall + nil, // onToolExecution + nil, // onToolResult + nil, // onResponse + nil, // onToolCallContent + ) + if err != nil { + return "", err + } + + // Update session with all messages from the conversation + // This preserves the complete history including tool calls + if err := m.sessionMgr.ReplaceAllMessages(result.ConversationMessages); err != nil { + return "", fmt.Errorf("failed to update session: %v", err) + } + + return result.FinalResponse.Content, nil +} + +// PromptWithCallbacks sends a message with callbacks for tool execution +func (m *MCPHost) PromptWithCallbacks( + ctx context.Context, + message string, + onToolCall func(name, args string), + onToolResult func(name, args, result string, isError bool), + onStreaming func(chunk string), +) (string, error) { + // Get messages from session + messages := m.sessionMgr.GetMessages() + + // Add new user message + userMsg := schema.UserMessage(message) + messages = append(messages, userMsg) + + // Call agent with callbacks + result, err := m.agent.GenerateWithLoopAndStreaming(ctx, messages, + onToolCall, + nil, // onToolExecution + onToolResult, + nil, // onResponse + nil, // onToolCallContent + onStreaming, + ) + if err != nil { + return "", err + } + + // Update session + if err := m.sessionMgr.ReplaceAllMessages(result.ConversationMessages); err != nil { + return "", fmt.Errorf("failed to update session: %v", err) + } + + return result.FinalResponse.Content, nil +} + +// GetSessionManager returns the current session manager +func (m *MCPHost) GetSessionManager() *session.Manager { + return m.sessionMgr +} + +// LoadSession loads a session from file +func (m *MCPHost) LoadSession(path string) error { + s, err := session.LoadFromFile(path) + if err != nil { + return err + } + m.sessionMgr = session.NewManagerWithSession(s, path) + return nil +} + +// SaveSession saves the current session to file +func (m *MCPHost) SaveSession(path string) error { + return m.sessionMgr.GetSession().SaveToFile(path) +} + +// ClearSession clears the current session history +func (m *MCPHost) ClearSession() { + m.sessionMgr = session.NewManager("") +} + +// GetModelString returns the current model string +func (m *MCPHost) GetModelString() string { + return m.modelString +} + +// Close cleans up resources +func (m *MCPHost) Close() error { + return m.agent.Close() +} diff --git a/sdk/mcphost_test.go b/sdk/mcphost_test.go new file mode 100644 index 0000000..6da1256 --- /dev/null +++ b/sdk/mcphost_test.go @@ -0,0 +1,77 @@ +package sdk_test + +import ( + "context" + "testing" + + "github.com/mark3labs/mcphost/sdk" +) + +func TestNew(t *testing.T) { + ctx := context.Background() + + // Test default initialization + host, err := sdk.New(ctx, nil) + if err != nil { + t.Fatalf("Failed to create MCPHost with defaults: %v", err) + } + defer host.Close() + + if host.GetModelString() == "" { + t.Error("Model string should not be empty") + } +} + +func TestNewWithOptions(t *testing.T) { + ctx := context.Background() + + opts := &sdk.Options{ + Model: "anthropic:claude-3-haiku-20240307", + MaxSteps: 5, + Quiet: true, + } + + host, err := sdk.New(ctx, opts) + if err != nil { + t.Fatalf("Failed to create MCPHost with options: %v", err) + } + defer host.Close() + + if host.GetModelString() != opts.Model { + t.Errorf("Expected model %s, got %s", opts.Model, host.GetModelString()) + } +} + +func TestSessionManagement(t *testing.T) { + ctx := context.Background() + + host, err := sdk.New(ctx, &sdk.Options{Quiet: true}) + if err != nil { + t.Fatalf("Failed to create MCPHost: %v", err) + } + defer host.Close() + + // Test clear session + host.ClearSession() + mgr := host.GetSessionManager() + if mgr.MessageCount() != 0 { + t.Error("Session should be empty after clear") + } + + // Test save/load session (would need actual implementation) + tempFile := t.TempDir() + "/session.json" + + // Add a message first + _, err = host.Prompt(ctx, "test message") + if err == nil { // Only if we have a working model + if err := host.SaveSession(tempFile); err != nil { + t.Errorf("Failed to save session: %v", err) + } + + // Clear and reload + host.ClearSession() + if err := host.LoadSession(tempFile); err != nil { + t.Errorf("Failed to load session: %v", err) + } + } +} diff --git a/sdk/types.go b/sdk/types.go new file mode 100644 index 0000000..f1a1a2a --- /dev/null +++ b/sdk/types.go @@ -0,0 +1,22 @@ +package sdk + +import ( + "github.com/cloudwego/eino/schema" + "github.com/mark3labs/mcphost/internal/session" +) + +// Message is an alias for session.Message for SDK users +type Message = session.Message + +// ToolCall is an alias for session.ToolCall +type ToolCall = session.ToolCall + +// ConvertToSchemaMessage converts SDK message to schema message +func ConvertToSchemaMessage(msg *Message) *schema.Message { + return msg.ConvertToSchemaMessage() +} + +// ConvertFromSchemaMessage converts schema message to SDK message +func ConvertFromSchemaMessage(msg *schema.Message) Message { + return session.ConvertFromSchemaMessage(msg) +}