diff --git a/examples/mcp-servers.json b/examples/mcp-servers.json new file mode 100644 index 000000000..3fde76d23 --- /dev/null +++ b/examples/mcp-servers.json @@ -0,0 +1,59 @@ +{ + "servers": [ + { + "name": "filesystem", + "description": "File system operations with path-based access control", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + "requires_path": true, + "path_arg_index": -1, + "capabilities": ["read", "write", "list", "search"], + "auto_enable": "with_path" + }, + { + "name": "git", + "description": "Git repository operations", + "command": "npx", + "args": ["-y", "@cyanheads/git-mcp-server"], + "requires_path": true, + "path_arg_index": -1, + "capabilities": ["diff", "log", "status", "branch"], + "auto_enable": "if_match", + "enable_if": { + "file_exists": ".git" + } + }, + { + "name": "postgres", + "description": "PostgreSQL database operations", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": { + "POSTGRES_CONNECTION": "" + }, + "capabilities": ["query", "schema"], + "auto_enable": "if_match", + "enable_if": { + "env_set": "POSTGRES_CONNECTION" + } + }, + { + "name": "python", + "description": "Python code execution in sandboxed environment", + "command": "python", + "args": ["-m", "mcp_server_python"], + "requires_path": false, + "capabilities": ["execute", "eval"], + "auto_enable": "never" + }, + { + "name": "web", + "description": "Web browsing and content extraction", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"], + "requires_path": false, + "capabilities": ["browse", "screenshot", "extract"], + "auto_enable": "never" + } + ] +} diff --git a/server/mcp.go b/server/mcp.go new file mode 100644 index 000000000..40c9e97f0 --- /dev/null +++ b/server/mcp.go @@ -0,0 +1,114 @@ +// Package server provides MCP (Model Context Protocol) integration for Ollama. +// +// MCP Architecture: +// +// ┌─────────────────────────────────────────────────────────────────┐ +// │ Public API (this file) │ +// │ GetMCPServersForTools() - Get servers for --tools flag │ +// │ GetMCPManager() - Get manager for explicit configs │ +// │ GetMCPManagerForPath() - Get manager for tools path │ +// │ ListMCPServers() - List available server definitions │ +// └─────────────────────────────────────────────────────────────────┘ +// │ +// ┌───────────────────┴───────────────────┐ +// ▼ ▼ +// ┌─────────────────────┐ ┌─────────────────────┐ +// │ MCPDefinitions │ │ MCPSessionManager │ +// │ (mcp_definitions) │ │ (mcp_sessions) │ +// │ │ │ │ +// │ Static config of │ │ Runtime sessions │ +// │ available servers │ │ with connections │ +// └─────────────────────┘ └─────────────────────┘ +// │ +// ▼ +// ┌─────────────────────┐ +// │ MCPManager │ +// │ (mcp_manager) │ +// │ │ +// │ Multi-client mgmt │ +// │ Tool execution │ +// └─────────────────────┘ +// │ +// ▼ +// ┌─────────────────────┐ +// │ MCPClient │ +// │ (mcp_client) │ +// │ │ +// │ Single JSON-RPC │ +// │ connection │ +// └─────────────────────┘ + +package server + +import ( + "os" + "path/filepath" + "strings" + + "github.com/ollama/ollama/api" +) + +// ============================================================================ +// Public API - Clean interface for external code +// ============================================================================ + +// GetMCPServersForTools returns the MCP server configs that should be enabled +// for the given tools spec. It handles path normalization: +// - "." or "true" → current working directory +// - "~/path" → expands to home directory +// - relative paths → resolved to absolute paths +// +// Returns the server configs and the resolved absolute path. +// On error, still returns the resolved path so callers can implement fallback. +// This is used by the --tools CLI flag. +func GetMCPServersForTools(toolsSpec string) ([]api.MCPServerConfig, string, error) { + // Normalize the tools path first (needed even for fallback on error) + toolsPath := toolsSpec + if toolsSpec == "." || toolsSpec == "true" { + if cwd, err := os.Getwd(); err == nil { + toolsPath = cwd + } + } + + // Expand tilde to home directory + if strings.HasPrefix(toolsPath, "~") { + if home := os.Getenv("HOME"); home != "" { + toolsPath = filepath.Join(home, toolsPath[1:]) + } + } + + // Resolve to absolute path + if absPath, err := filepath.Abs(toolsPath); err == nil { + toolsPath = absPath + } + + // Load definitions + defs, err := LoadMCPDefinitions() + if err != nil { + return nil, toolsPath, err + } + + ctx := AutoEnableContext{ToolsPath: toolsPath} + return defs.GetAutoEnableServers(ctx), toolsPath, nil +} + +// GetMCPManager returns an MCP manager for the given session and configs. +// If a session with matching configs already exists, it will be reused. +func GetMCPManager(sessionID string, configs []api.MCPServerConfig) (*MCPManager, error) { + return GetMCPSessionManager().GetOrCreateManager(sessionID, configs) +} + +// GetMCPManagerForPath returns an MCP manager for servers that auto-enable +// for the given tools path. Used by CLI: `ollama run model --tools /path` +func GetMCPManagerForPath(model string, toolsPath string) (*MCPManager, error) { + return GetMCPSessionManager().GetManagerForToolsPath(model, toolsPath) +} + +// ListMCPServers returns information about all available MCP server definitions. +func ListMCPServers() ([]MCPServerInfo, error) { + defs, err := LoadMCPDefinitions() + if err != nil { + return nil, err + } + return defs.ListServers(), nil +} diff --git a/server/mcp_command_resolver.go b/server/mcp_command_resolver.go index 1b56ed3aa..054282bef 100644 --- a/server/mcp_command_resolver.go +++ b/server/mcp_command_resolver.go @@ -8,13 +8,40 @@ import ( "sync" ) +// ============================================================================= +// Command Resolver Interface & Default Implementation +// ============================================================================= +// +// SECURITY REVIEW: This component determines which executables are launched +// for MCP servers. Changes here should be reviewed carefully. + +// CommandResolverInterface defines the contract for command resolution. +// Implementations resolve command names (like "npx", "python") to actual +// executable paths, with support for fallbacks and environment overrides. +// +// This interface enables dependency injection for testing MCPClient without +// requiring actual executables to be present on the system. +type CommandResolverInterface interface { + // ResolveCommand finds the actual executable for a command name. + // Returns the resolved path/command or an error if not found. + ResolveCommand(command string) (string, error) + + // ResolveForEnvironment resolves a command, checking environment + // variable overrides first (e.g., OLLAMA_NPX_COMMAND for "npx"). + // Returns the original command as fallback if resolution fails. + ResolveForEnvironment(command string) string +} + // CommandResolver handles resolving commands to their actual executables -// with fallback detection for different system configurations +// with fallback detection for different system configurations. type CommandResolver struct { mu sync.RWMutex resolved map[string]string } +// Ensure CommandResolver implements CommandResolverInterface +var _ CommandResolverInterface = (*CommandResolver)(nil) + // NewCommandResolver creates a new command resolver func NewCommandResolver() *CommandResolver { return &CommandResolver{ diff --git a/server/mcp_definitions.go b/server/mcp_definitions.go new file mode 100644 index 000000000..0b2160374 --- /dev/null +++ b/server/mcp_definitions.go @@ -0,0 +1,280 @@ +package server + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/ollama/ollama/api" +) + +// AutoEnableMode determines when a server auto-enables with --tools +type AutoEnableMode string + +const ( + // AutoEnableNever means the server must be explicitly configured (default) + AutoEnableNever AutoEnableMode = "never" + // AutoEnableAlways means the server enables whenever --tools is used + AutoEnableAlways AutoEnableMode = "always" + // AutoEnableWithPath means the server enables when --tools has a path + AutoEnableWithPath AutoEnableMode = "with_path" + // AutoEnableIfMatch means the server enables if EnableIf condition matches + AutoEnableIfMatch AutoEnableMode = "if_match" +) + +// EnableCondition specifies conditions for AutoEnableIfMatch mode +type EnableCondition struct { + // FileExists checks if a specific file exists in the tools path + FileExists string `json:"file_exists,omitempty"` + // EnvSet checks if an environment variable is set (non-empty) + EnvSet string `json:"env_set,omitempty"` +} + +// AutoEnableContext provides context for auto-enable decisions +type AutoEnableContext struct { + // ToolsPath is the path from --tools flag (may be empty) + ToolsPath string + // Env contains environment variables (optional, falls back to os.Getenv) + Env map[string]string +} + +// MCPDefinitions holds available MCP server definitions loaded from configuration. +// This is the static configuration of what servers CAN be used. +type MCPDefinitions struct { + Servers map[string]MCPServerDefinition `json:"servers"` +} + +// MCPServerDefinition defines an available MCP server type +type MCPServerDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` + RequiresPath bool `json:"requires_path,omitempty"` + PathArgIndex int `json:"path_arg_index,omitempty"` + Env map[string]string `json:"env,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + + // AutoEnable determines when this server auto-enables with --tools + // Default is "never" (must be explicitly configured via API) + AutoEnable AutoEnableMode `json:"auto_enable,omitempty"` + + // EnableIf specifies conditions for AutoEnableIfMatch mode + EnableIf EnableCondition `json:"enable_if,omitempty"` +} + +// MCPServerInfo provides information about an available MCP server +type MCPServerInfo struct { + Name string `json:"name"` + Description string `json:"description"` + RequiresPath bool `json:"requires_path"` + Capabilities []string `json:"capabilities,omitempty"` + AutoEnable AutoEnableMode `json:"auto_enable,omitempty"` +} + +// DefaultMCPServers returns minimal built-in MCP server definitions +// Full examples are provided in examples/mcp-servers.json +func DefaultMCPServers() map[string]MCPServerDefinition { + // Only include filesystem by default - it requires only npx which is commonly available + // Users can add more servers via ~/.ollama/mcp-servers.json + return map[string]MCPServerDefinition{ + "filesystem": { + Name: "filesystem", + Description: "File system operations with path-based access control", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-filesystem"}, + RequiresPath: true, + PathArgIndex: -1, + Capabilities: []string{"read", "write", "list", "search"}, + AutoEnable: AutoEnableWithPath, // Enable when --tools has a path + }, + } +} + +// LoadMCPDefinitions loads MCP server definitions from configuration files. +// Priority order: user config (~/.ollama) > system config (/etc/ollama) > defaults +func LoadMCPDefinitions() (*MCPDefinitions, error) { + defs := &MCPDefinitions{ + Servers: DefaultMCPServers(), + } + + // Load from user config if exists + configPaths := []string{ + filepath.Join(os.Getenv("HOME"), ".ollama", "mcp-servers.json"), + "/etc/ollama/mcp-servers.json", + "./mcp-servers.json", + } + + for _, path := range configPaths { + if err := defs.LoadFromFile(path); err == nil { + slog.Debug("Loaded MCP definitions", "path", path) + break + } + } + + // Load from environment variable if set + if mcpConfig := os.Getenv("OLLAMA_MCP_SERVERS"); mcpConfig != "" { + if err := defs.LoadFromJSON([]byte(mcpConfig)); err != nil { + return nil, fmt.Errorf("failed to parse OLLAMA_MCP_SERVERS: %w", err) + } + } + + return defs, nil +} + +// LoadFromFile loads MCP server definitions from a JSON file +func (d *MCPDefinitions) LoadFromFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return d.LoadFromJSON(data) +} + +// LoadFromJSON loads MCP server definitions from JSON data +func (d *MCPDefinitions) LoadFromJSON(data []byte) error { + var config struct { + Servers []MCPServerDefinition `json:"servers"` + } + + if err := json.Unmarshal(data, &config); err != nil { + return err + } + + for _, server := range config.Servers { + d.Servers[server.Name] = server + } + + return nil +} + +// ListServers returns information about all available MCP servers +func (d *MCPDefinitions) ListServers() []MCPServerInfo { + var servers []MCPServerInfo + for _, def := range d.Servers { + servers = append(servers, MCPServerInfo{ + Name: def.Name, + Description: def.Description, + RequiresPath: def.RequiresPath, + Capabilities: def.Capabilities, + AutoEnable: def.AutoEnable, + }) + } + return servers +} + +// GetAutoEnableServers returns servers that should auto-enable for the given context. +// This method checks each server's AutoEnable mode and EnableIf conditions. +func (d *MCPDefinitions) GetAutoEnableServers(ctx AutoEnableContext) []api.MCPServerConfig { + var configs []api.MCPServerConfig + + for _, def := range d.Servers { + if !d.shouldAutoEnable(def, ctx) { + continue + } + + config, err := d.buildConfigForAutoEnable(def, ctx) + if err != nil { + slog.Warn("Failed to build config for auto-enable server", + "name", def.Name, "error", err) + continue + } + + configs = append(configs, config) + } + + return configs +} + +// shouldAutoEnable checks if a server should auto-enable for the given context +func (d *MCPDefinitions) shouldAutoEnable(def MCPServerDefinition, ctx AutoEnableContext) bool { + switch def.AutoEnable { + case AutoEnableNever, "": + return false + + case AutoEnableAlways: + return true + + case AutoEnableWithPath: + return ctx.ToolsPath != "" + + case AutoEnableIfMatch: + return d.checkEnableCondition(def.EnableIf, ctx) + + default: + return false + } +} + +// checkEnableCondition evaluates an EnableCondition against the context +func (d *MCPDefinitions) checkEnableCondition(cond EnableCondition, ctx AutoEnableContext) bool { + // All specified conditions must match (AND logic) + + if cond.FileExists != "" { + checkPath := filepath.Join(ctx.ToolsPath, cond.FileExists) + if _, err := os.Stat(checkPath); err != nil { + return false + } + } + + if cond.EnvSet != "" { + // Check context env first, fall back to os.Getenv + val := "" + if ctx.Env != nil { + val = ctx.Env[cond.EnvSet] + } + if val == "" { + val = os.Getenv(cond.EnvSet) + } + if val == "" { + return false + } + } + + return true +} + +// buildConfigForAutoEnable creates an MCPServerConfig for auto-enabled servers +func (d *MCPDefinitions) buildConfigForAutoEnable(def MCPServerDefinition, ctx AutoEnableContext) (api.MCPServerConfig, error) { + // Resolve the command using the command resolver + resolvedCommand := DefaultCommandResolver.ResolveForEnvironment(def.Command) + + config := api.MCPServerConfig{ + Name: def.Name, + Command: resolvedCommand, + Args: append([]string{}, def.Args...), // Copy args + Env: make(map[string]string), + } + + // Copy environment variables + for k, v := range def.Env { + config.Env[k] = v + } + + // Add path if required + if def.RequiresPath { + if ctx.ToolsPath == "" { + return config, fmt.Errorf("server '%s' requires a path but none provided", def.Name) + } + + // Validate path exists + if _, err := os.Stat(ctx.ToolsPath); err != nil { + return config, fmt.Errorf("invalid path for server '%s': %w", def.Name, err) + } + + // Add path to args at specified position + if def.PathArgIndex < 0 { + config.Args = append(config.Args, ctx.ToolsPath) + } else if def.PathArgIndex <= len(config.Args) { + config.Args = append(config.Args[:def.PathArgIndex], + append([]string{ctx.ToolsPath}, config.Args[def.PathArgIndex:]...)...) + } else { + // PathArgIndex out of bounds, append to end + config.Args = append(config.Args, ctx.ToolsPath) + } + } + + return config, nil +} diff --git a/server/mcp_sessions.go b/server/mcp_sessions.go new file mode 100644 index 000000000..3302d94a9 --- /dev/null +++ b/server/mcp_sessions.go @@ -0,0 +1,178 @@ +package server + +import ( + "crypto/sha256" + "encoding/hex" + "log/slog" + "reflect" + "sync" + "time" + + "github.com/ollama/ollama/api" +) + +// MCPSessionManager manages active MCP sessions with automatic cleanup. +// This is the runtime component that tracks active connections. +type MCPSessionManager struct { + mu sync.RWMutex + sessions map[string]*MCPSession // session ID -> session + ttl time.Duration // session timeout + stopCleanup chan struct{} // signals cleanup goroutine to stop +} + +// MCPSession wraps an MCPManager with session metadata +type MCPSession struct { + *MCPManager + lastAccess time.Time + sessionID string + configs []api.MCPServerConfig +} + +var ( + globalSessionManager *MCPSessionManager + sessionManagerOnce sync.Once +) + +// GetMCPSessionManager returns the singleton MCP session manager +func GetMCPSessionManager() *MCPSessionManager { + sessionManagerOnce.Do(func() { + globalSessionManager = &MCPSessionManager{ + sessions: make(map[string]*MCPSession), + ttl: 30 * time.Minute, // Sessions expire after 30 min + stopCleanup: make(chan struct{}), + } + // Start cleanup goroutine + go globalSessionManager.cleanupExpired() + }) + return globalSessionManager +} + +// GetOrCreateManager gets existing or creates new MCP manager for session +func (sm *MCPSessionManager) GetOrCreateManager(sessionID string, configs []api.MCPServerConfig) (*MCPManager, error) { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Check if session exists and configs match + if session, exists := sm.sessions[sessionID]; exists { + if configsMatch(session.configs, configs) { + session.lastAccess = time.Now() + slog.Debug("Reusing existing MCP session", "session", sessionID, "clients", len(session.clients)) + return session.MCPManager, nil + } + // Configs changed, shutdown old session + slog.Info("MCP configs changed, recreating session", "session", sessionID) + session.Shutdown() + delete(sm.sessions, sessionID) + } + + // Create new session + slog.Info("Creating new MCP session", "session", sessionID, "configs", len(configs)) + manager := NewMCPManager(10) + for _, config := range configs { + if err := manager.AddServer(config); err != nil { + slog.Warn("Failed to add MCP server", "name", config.Name, "error", err) + } + } + + sm.sessions[sessionID] = &MCPSession{ + MCPManager: manager, + lastAccess: time.Now(), + sessionID: sessionID, + configs: configs, + } + + return manager, nil +} + +// GetManagerForToolsPath creates a manager for a tools directory path. +// It uses the definitions system to get auto-enabled servers for the path. +func (sm *MCPSessionManager) GetManagerForToolsPath(model string, toolsPath string) (*MCPManager, error) { + // Generate consistent session ID for model + tools path + sessionID := generateToolsSessionID(model, toolsPath) + + // Use definitions to get auto-enabled servers (single source of truth) + defs, err := LoadMCPDefinitions() + if err != nil { + return nil, err + } + + ctx := AutoEnableContext{ToolsPath: toolsPath} + configs := defs.GetAutoEnableServers(ctx) + + return sm.GetOrCreateManager(sessionID, configs) +} + +// cleanupExpired removes expired sessions +func (sm *MCPSessionManager) cleanupExpired() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-sm.stopCleanup: + return + case <-ticker.C: + sm.mu.Lock() + now := time.Now() + for sessionID, session := range sm.sessions { + if now.Sub(session.lastAccess) > sm.ttl { + slog.Info("Cleaning up expired MCP session", "session", sessionID) + session.Shutdown() + delete(sm.sessions, sessionID) + } + } + sm.mu.Unlock() + } + } +} + +// Shutdown closes all sessions and stops the cleanup goroutine +func (sm *MCPSessionManager) Shutdown() { + // Signal cleanup goroutine to stop + close(sm.stopCleanup) + + sm.mu.Lock() + defer sm.mu.Unlock() + + slog.Info("Shutting down MCP session manager", "sessions", len(sm.sessions)) + for sessionID, session := range sm.sessions { + slog.Debug("Shutting down session", "session", sessionID) + session.Shutdown() + } + sm.sessions = make(map[string]*MCPSession) +} + +// configsMatch checks if two sets of MCP configs are equivalent +func configsMatch(a, b []api.MCPServerConfig) bool { + if len(a) != len(b) { + return false + } + return reflect.DeepEqual(a, b) +} + +// generateToolsSessionID creates a consistent session ID for model + tools path +func generateToolsSessionID(model, toolsPath string) string { + h := sha256.New() + h.Write([]byte(model)) + h.Write([]byte(toolsPath)) + return "tools-" + hex.EncodeToString(h.Sum(nil))[:16] +} + +// GenerateSessionID creates a session ID based on the request +func GenerateSessionID(req api.ChatRequest) string { + // If explicit session ID provided + if req.SessionID != "" { + return req.SessionID + } + + // For interactive mode with tools path + if req.ToolsPath != "" { + return generateToolsSessionID(req.Model, req.ToolsPath) + } + + // Default: use request-specific ID (no persistence) + h := sha256.New() + h.Write([]byte(time.Now().Format(time.RFC3339Nano))) + h.Write([]byte(req.Model)) + return "req-" + hex.EncodeToString(h.Sum(nil))[:16] +}