This commit is contained in:
Vel 2026-01-06 08:25:48 +01:00 committed by GitHub
commit 1185088d12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 4766 additions and 227 deletions

View File

@ -8,6 +8,7 @@ import (
"math"
"os"
"reflect"
"sort"
"strconv"
"strings"
"time"
@ -177,6 +178,26 @@ type ChatRequest struct {
// each with an associated log probability. Only applies when Logprobs is true.
// Valid values are 0-20. Default is 0 (only return the selected token's logprob).
TopLogprobs int `json:"top_logprobs,omitempty"`
// MCPServers is an optional list of MCP (Model Context Protocol) servers
// that provide tools for autonomous execution during the chat.
MCPServers []MCPServerConfig `json:"mcp_servers,omitempty"`
// MaxToolRounds limits the number of tool execution rounds to prevent
// infinite loops. Defaults to 15 if not specified.
MaxToolRounds int `json:"max_tool_rounds,omitempty"`
// ToolTimeout sets the timeout for individual tool executions.
// Defaults to 30 seconds if not specified.
ToolTimeout *Duration `json:"tool_timeout,omitempty"`
// SessionID is an optional session identifier for maintaining MCP state
// across multiple API calls. If not provided, a new session is created.
SessionID string `json:"session_id,omitempty"`
// ToolsPath is the file path passed via --tools flag in interactive mode.
// Used to generate consistent session IDs for tool continuity.
ToolsPath string `json:"tools_path,omitempty"`
}
type Tools []Tool
@ -199,11 +220,12 @@ type Message struct {
Content string `json:"content"`
// Thinking contains the text that was inside thinking tags in the
// original model output when ChatRequest.Think is enabled.
Thinking string `json:"thinking,omitempty"`
Images []ImageData `json:"images,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Thinking string `json:"thinking,omitempty"`
Images []ImageData `json:"images,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolResults []ToolResult `json:"tool_results,omitempty"` // MCP tool results
ToolName string `json:"tool_name,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
func (m *Message) UnmarshalJSON(b []byte) error {
@ -223,6 +245,13 @@ type ToolCall struct {
Function ToolCallFunction `json:"function"`
}
type ToolResult struct {
ToolName string `json:"tool_name"`
Arguments ToolCallFunctionArguments `json:"arguments,omitempty"`
Content string `json:"content"`
Error string `json:"error,omitempty"`
}
type ToolCallFunction struct {
Index int `json:"index"`
Name string `json:"name"`
@ -439,18 +468,106 @@ func (tp ToolProperty) ToTypeScriptType() string {
}
if len(tp.Type) == 1 {
return mapToTypeScriptType(tp.Type[0])
return tp.mapSingleType(tp.Type[0])
}
var types []string
for _, t := range tp.Type {
types = append(types, mapToTypeScriptType(t))
types = append(types, tp.mapSingleType(t))
}
return strings.Join(types, " | ")
}
// mapToTypeScriptType maps JSON Schema types to TypeScript types
func mapToTypeScriptType(jsonType string) string {
// mapSingleType maps a single JSON Schema type to TypeScript, using Items for arrays
func (tp ToolProperty) mapSingleType(jsonType string) string {
switch jsonType {
case "string":
return "string"
case "number", "integer":
return "number"
case "boolean":
return "boolean"
case "array":
return tp.arrayTypeString()
case "object":
return "Record<string, any>"
case "null":
return "null"
default:
return "any"
}
}
// arrayTypeString generates TypeScript type for arrays, using Items schema
func (tp ToolProperty) arrayTypeString() string {
if tp.Items == nil {
return "any[]"
}
itemsMap, ok := tp.Items.(map[string]interface{})
if !ok {
return "any[]"
}
// Check if items has a type
itemType, ok := itemsMap["type"].(string)
if !ok {
return "any[]"
}
// For object types with properties, generate inline type
if itemType == "object" {
if props, ok := itemsMap["properties"].(map[string]interface{}); ok {
return objectTypeFromProperties(props) + "[]"
}
}
// Simple type array
return mapSimpleType(itemType) + "[]"
}
// objectTypeFromProperties generates a TypeScript inline object type from properties
func objectTypeFromProperties(props map[string]interface{}) string {
if len(props) == 0 {
return "Record<string, any>"
}
// Collect and sort field names for consistent output
names := make([]string, 0, len(props))
for name := range props {
names = append(names, name)
}
sort.Strings(names)
var fields []string
for _, name := range names {
propDef := props[name]
propMap, ok := propDef.(map[string]interface{})
if !ok {
fields = append(fields, name+": any")
continue
}
propType := "any"
if t, ok := propMap["type"].(string); ok {
propType = mapSimpleType(t)
// Handle nested arrays
if t == "array" {
if items, ok := propMap["items"].(map[string]interface{}); ok {
if itemType, ok := items["type"].(string); ok {
propType = mapSimpleType(itemType) + "[]"
}
}
}
}
fields = append(fields, name+": "+propType)
}
return "{" + strings.Join(fields, ", ") + "}"
}
// mapSimpleType maps JSON Schema types to TypeScript (without recursion)
func mapSimpleType(jsonType string) string {
switch jsonType {
case "string":
return "string"
@ -514,6 +631,21 @@ type Logprob struct {
TopLogprobs []TokenLogprob `json:"top_logprobs,omitempty"`
}
// MCPServerConfig represents configuration for an MCP (Model Context Protocol) server
type MCPServerConfig struct {
// Name is a unique identifier for the MCP server
Name string `json:"name"`
// Command is the executable command to start the MCP server
Command string `json:"command"`
// Args are optional command-line arguments for the MCP server
Args []string `json:"args,omitempty"`
// Env are optional environment variables for the MCP server
Env map[string]string `json:"env,omitempty"`
}
// ChatResponse is the response returned by [Client.Chat]. Its fields are
// similar to [GenerateResponse].
type ChatResponse struct {
@ -853,8 +985,6 @@ type GenerateResponse struct {
Metrics
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
DebugInfo *DebugInfo `json:"_debug_info,omitempty"`
// Logprobs contains log probability information for the generated tokens,

View File

@ -22,6 +22,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@ -49,6 +50,13 @@ import (
const ConnectInstructions = "To sign in, navigate to:\n %s\n\n"
// Tool detection and buffering configuration
const (
DefaultToolBufferDelay = 500 * time.Millisecond
MinToolBufferDelay = 100 * time.Millisecond
MaxToolBufferDelay = 2 * time.Second
)
// ensureThinkingSupport emits a warning if the model does not advertise thinking support
func ensureThinkingSupport(ctx context.Context, client *api.Client, name string) {
if name == "" {
@ -416,6 +424,41 @@ func RunHandler(cmd *cobra.Command, args []string) error {
opts.KeepAlive = &api.Duration{Duration: d}
}
toolsSpec, err := cmd.Flags().GetString("tools")
if err != nil {
return err
}
if toolsSpec != "" {
mcpServers, toolsPath, err := server.GetMCPServersForTools(toolsSpec)
if err != nil {
// If definitions fail to load, fall back to basic filesystem support
fmt.Fprintf(os.Stderr, "Warning: Failed to load MCP definitions: %v\n", err)
mcpServers = []api.MCPServerConfig{
{
Name: "filesystem",
Command: "npx",
Args: []string{"-y", "@modelcontextprotocol/server-filesystem", toolsPath},
},
}
}
if len(mcpServers) == 0 {
fmt.Fprintf(os.Stderr, "Warning: No MCP servers matched for --tools context\n")
} else {
// Log what servers are being enabled
serverNames := make([]string, 0, len(mcpServers))
for _, srv := range mcpServers {
serverNames = append(serverNames, srv.Name)
}
fmt.Fprintf(os.Stderr, "Enabling MCP servers: %s\n", strings.Join(serverNames, ", "))
if toolsPath != "" {
fmt.Fprintf(os.Stderr, "Tools path: %s\n", toolsPath)
}
}
opts.MCPServers = mcpServers
}
prompts := args[1:]
// prepend stdin to the prompt if provided
if !term.IsTerminal(int(os.Stdin.Fd())) {
@ -786,6 +829,62 @@ func ListRunningHandler(cmd *cobra.Command, args []string) error {
return nil
}
func ListToolsHandler(cmd *cobra.Command, args []string) {
servers, err := server.ListMCPServers()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading MCP servers: %v\n", err)
return
}
if len(servers) == 0 {
fmt.Println("No MCP servers configured.")
fmt.Println("\nTo add MCP servers, create ~/.ollama/mcp-servers.json")
fmt.Println("See https://ollama.com/docs/mcp for configuration details.")
return
}
fmt.Println("Available MCP Servers:")
fmt.Println()
var data [][]string
for _, s := range servers {
autoEnable := string(s.AutoEnable)
if autoEnable == "" {
autoEnable = "never"
}
capabilities := "-"
if len(s.Capabilities) > 0 {
capabilities = strings.Join(s.Capabilities, ", ")
}
requiresPath := "no"
if s.RequiresPath {
requiresPath = "yes"
}
data = append(data, []string{s.Name, s.Description, autoEnable, requiresPath, capabilities})
}
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"NAME", "DESCRIPTION", "AUTO-ENABLE", "REQUIRES PATH", "CAPABILITIES"})
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetHeaderLine(false)
table.SetBorder(false)
table.SetNoWhiteSpace(true)
table.SetTablePadding(" ")
table.AppendBulk(data)
table.Render()
fmt.Println()
fmt.Println("Auto-enable modes:")
fmt.Println(" never - Must be explicitly configured via API")
fmt.Println(" always - Enables whenever --tools is used")
fmt.Println(" with_path - Enables when --tools has a path argument")
fmt.Println(" if_match - Enables when conditions match (e.g., file exists)")
}
func DeleteHandler(cmd *cobra.Command, args []string) error {
client, err := api.ClientFromEnvironment()
if err != nil {
@ -1189,6 +1288,7 @@ type runOptions struct {
Think *api.ThinkValue
HideThinking bool
ShowConnect bool
MCPServers []api.MCPServerConfig
}
func (r runOptions) Copy() runOptions {
@ -1218,6 +1318,12 @@ func (r runOptions) Copy() runOptions {
think = &cThink
}
var mcpServers []api.MCPServerConfig
if r.MCPServers != nil {
mcpServers = make([]api.MCPServerConfig, len(r.MCPServers))
copy(mcpServers, r.MCPServers)
}
return runOptions{
Model: r.Model,
ParentModel: r.ParentModel,
@ -1233,6 +1339,7 @@ func (r runOptions) Copy() runOptions {
Think: think,
HideThinking: r.HideThinking,
ShowConnect: r.ShowConnect,
MCPServers: mcpServers,
}
}
@ -1241,6 +1348,237 @@ type displayResponseState struct {
wordBuffer string
}
// StreamingToolDetector maintains state for detecting tool calls across streaming chunks
type StreamingToolDetector struct {
inXMLToolCall bool
xmlStartBuffer strings.Builder
inJSONToolCall bool
jsonBuffer strings.Builder
jsonDepth int
inString bool
escapeNext bool
// tailBuffer holds potential partial tag matches from end of previous chunk
tailBuffer string
}
// NewStreamingToolDetector creates a new stateful tool detector
func NewStreamingToolDetector() *StreamingToolDetector {
return &StreamingToolDetector{}
}
// maxTagLength is the longest tag we need to detect across chunk boundaries
const maxTagLength = 12 // len("</tool_call>")
// Process handles a chunk of streaming content and separates tool calls from regular content
func (s *StreamingToolDetector) Process(chunk string) (displayContent string, hasIncompleteToolCall bool) {
// Prepend any buffered tail from previous chunk
if s.tailBuffer != "" {
chunk = s.tailBuffer + chunk
s.tailBuffer = ""
}
var result strings.Builder
for i := 0; i < len(chunk); i++ {
ch := chunk[i]
// Check if we're near the end and might have a partial tag
// Buffer potential partial matches for next chunk
remainingLen := len(chunk) - i
if !s.inXMLToolCall && !s.inJSONToolCall && remainingLen < maxTagLength {
// Check if remaining content could be start of a tag
remaining := chunk[i:]
if couldBePartialTag(remaining) {
s.tailBuffer = remaining
break // Stop processing, buffer the rest
}
}
// Handle XML tool calls
if !s.inXMLToolCall && i+11 <= len(chunk) && chunk[i:i+11] == "<tool_call>" {
s.inXMLToolCall = true
s.xmlStartBuffer.Reset()
s.xmlStartBuffer.WriteString("<tool_call>")
i += 10 // Skip past "<tool_call>"
continue
}
if s.inXMLToolCall {
s.xmlStartBuffer.WriteByte(ch)
if i+12 <= len(chunk) && chunk[i:i+12] == "</tool_call>" {
// Complete XML tool call - skip it entirely
s.inXMLToolCall = false
s.xmlStartBuffer.Reset()
i += 11 // Skip past "</tool_call>"
continue
}
continue
}
// Handle JSON tool calls
if !s.inJSONToolCall && !s.inXMLToolCall {
// Look for start of JSON tool call pattern
if i+8 <= len(chunk) && chunk[i:i+8] == `{"name":` {
// Check if "arguments" appears nearby (tool call signature)
lookahead := chunk[i:]
if len(lookahead) > 200 {
lookahead = lookahead[:200]
}
if strings.Contains(lookahead, `"arguments":`) {
s.inJSONToolCall = true
s.jsonBuffer.Reset()
s.jsonBuffer.WriteByte(ch)
s.jsonDepth = 1
s.inString = false
s.escapeNext = false
continue
}
}
}
if s.inJSONToolCall {
s.jsonBuffer.WriteByte(ch)
// Track JSON structure to find the end
if s.escapeNext {
s.escapeNext = false
continue
}
if ch == '\\' && s.inString {
s.escapeNext = true
continue
}
if ch == '"' && !s.escapeNext {
s.inString = !s.inString
continue
}
if !s.inString {
if ch == '{' {
s.jsonDepth++
} else if ch == '}' {
s.jsonDepth--
if s.jsonDepth == 0 {
// Complete JSON tool call - skip it
s.inJSONToolCall = false
s.jsonBuffer.Reset()
continue
}
}
}
continue
}
// Regular content
result.WriteByte(ch)
}
// Check if we have incomplete tool calls or buffered tail that need buffering
hasIncompleteToolCall = s.inXMLToolCall || s.inJSONToolCall || s.tailBuffer != ""
return result.String(), hasIncompleteToolCall
}
// couldBePartialTag checks if a string could be the start of a tool call tag
// Only returns true for patterns that are specific enough to likely be tool calls
func couldBePartialTag(s string) bool {
// Require at least 2 chars to avoid false positives on common single chars like < or {
if len(s) < 2 {
return false
}
// Check for partial XML tags - must start with "<t" or "</"
xmlPrefixes := []string{"<t", "<to", "<too", "<tool", "<tool_", "<tool_c", "<tool_ca", "<tool_cal", "<tool_call",
"</", "</t", "</to", "</too", "</tool", "</tool_", "</tool_c", "</tool_ca", "</tool_cal", "</tool_call"}
for _, prefix := range xmlPrefixes {
if strings.HasPrefix(s, prefix) {
return true
}
}
// Check for partial JSON tool call start - must have at least `{"`
jsonPrefixes := []string{`{"`, `{"n`, `{"na`, `{"nam`, `{"name`, `{"name"`, `{"name":`}
for _, prefix := range jsonPrefixes {
if strings.HasPrefix(s, prefix) {
return true
}
}
return false
}
// Reset clears the detector state
func (s *StreamingToolDetector) Reset() {
s.inXMLToolCall = false
s.xmlStartBuffer.Reset()
s.inJSONToolCall = false
s.jsonBuffer.Reset()
s.jsonDepth = 0
s.inString = false
s.escapeNext = false
s.tailBuffer = ""
}
// findJSONEnd finds the end of a JSON object starting from the beginning of the string
// Returns the index of the closing brace, or -1 if not found
func findJSONEnd(s string) int {
braceCount := 0
inString := false
escapeNext := false
for i, ch := range s {
if escapeNext {
escapeNext = false
continue
}
if ch == '\\' && inString {
escapeNext = true
continue
}
if ch == '"' && !escapeNext {
inString = !inString
continue
}
if !inString {
if ch == '{' {
braceCount++
} else if ch == '}' {
braceCount--
if braceCount == 0 {
return i
}
}
}
}
return -1
}
// getToolBufferDelay returns the configured tool buffer delay
// Can be overridden with OLLAMA_TOOL_BUFFER_DELAY environment variable (in milliseconds)
func getToolBufferDelay() time.Duration {
if delayStr := os.Getenv("OLLAMA_TOOL_BUFFER_DELAY"); delayStr != "" {
if delayMs, err := strconv.Atoi(delayStr); err == nil {
delay := time.Duration(delayMs) * time.Millisecond
// Clamp to reasonable bounds
if delay < MinToolBufferDelay {
return MinToolBufferDelay
}
if delay > MaxToolBufferDelay {
return MaxToolBufferDelay
}
return delay
}
}
return DefaultToolBufferDelay
}
func displayResponse(content string, wordWrap bool, state *displayResponseState) {
termWidth, _, _ := term.GetSize(int(os.Stdout.Fd()))
if wordWrap && termWidth >= 10 {
@ -1327,6 +1665,7 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT)
defer signal.Stop(sigChan)
go func() {
<-sigChan
@ -1339,6 +1678,18 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
var fullResponse strings.Builder
var thinkTagOpened bool = false
var thinkTagClosed bool = false
var toolCallsDisplayed bool = false
// Streaming tool detector for better chunk handling
toolDetector := NewStreamingToolDetector()
// Buffer for accumulating content before display
var contentBuffer strings.Builder
var bufferTimer *time.Timer
var bufferMutex sync.Mutex
// Get configurable buffer delay
bufferDelay := getToolBufferDelay()
role := "assistant"
@ -1370,20 +1721,84 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
thinkTagClosed = true
state = &displayResponseState{}
}
// purposefully not putting thinking blocks in the response, which would
// only be needed if we later added tool calling to the cli (they get
// filtered out anyway since current models don't expect them unless you're
// about to finish some tool calls)
// Use stateful tool detector for better streaming chunk handling
displayContent, hasIncompleteToolCall := toolDetector.Process(content)
// Store full response for context
fullResponse.WriteString(content)
// Buffer management based on tool detection
if hasIncompleteToolCall {
// We have an incomplete tool call - buffer the content
bufferMutex.Lock()
contentBuffer.WriteString(displayContent)
// Cancel any existing timer
if bufferTimer != nil {
bufferTimer.Stop()
}
// Set a new timer to flush the buffer after a delay
bufferTimer = time.AfterFunc(bufferDelay, func() {
bufferMutex.Lock()
defer bufferMutex.Unlock()
bufferedContent := contentBuffer.String()
contentBuffer.Reset()
// Reset tool detector state when flushing
toolDetector.Reset()
// Only display if there's actual content after filtering
if strings.TrimSpace(bufferedContent) != "" {
displayResponse(bufferedContent, opts.WordWrap, state)
}
})
bufferMutex.Unlock()
} else {
// No incomplete tool call - display immediately
if strings.TrimSpace(displayContent) != "" {
displayResponse(displayContent, opts.WordWrap, state)
}
}
// Display tool calls cleanly if detected
if response.Message.ToolCalls != nil {
toolCalls := response.Message.ToolCalls
if len(toolCalls) > 0 {
if len(toolCalls) > 0 && !toolCallsDisplayed {
// Flush any buffered content before showing tool calls
bufferMutex.Lock()
if contentBuffer.Len() > 0 {
bufferedContent := contentBuffer.String()
contentBuffer.Reset()
if strings.TrimSpace(bufferedContent) != "" {
displayResponse(bufferedContent, opts.WordWrap, state)
}
}
if bufferTimer != nil {
bufferTimer.Stop()
bufferTimer = nil
}
bufferMutex.Unlock()
// Add newline for clean separation
fmt.Println()
fmt.Print(renderToolCalls(toolCalls, false))
toolCallsDisplayed = true
}
}
displayResponse(content, opts.WordWrap, state)
// Display tool results if available
if response.Message.ToolResults != nil {
toolResults := response.Message.ToolResults
if len(toolResults) > 0 {
fmt.Print(renderToolResults(toolResults, false))
fmt.Println() // New line after results
// Reset flag to allow next round's tool calls to be displayed
toolCallsDisplayed = false
}
}
return nil
}
@ -1393,11 +1808,12 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
}
req := &api.ChatRequest{
Model: opts.Model,
Messages: opts.Messages,
Format: json.RawMessage(opts.Format),
Options: opts.Options,
Think: opts.Think,
Model: opts.Model,
Messages: opts.Messages,
Format: json.RawMessage(opts.Format),
Options: opts.Options,
Think: opts.Think,
MCPServers: opts.MCPServers,
}
if opts.KeepAlive != nil {
@ -1418,6 +1834,20 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
}
return nil, err
}
// Flush any remaining buffered content
bufferMutex.Lock()
if bufferTimer != nil {
bufferTimer.Stop()
}
if contentBuffer.Len() > 0 {
bufferedContent := contentBuffer.String()
contentBuffer.Reset()
if strings.TrimSpace(bufferedContent) != "" && !strings.Contains(bufferedContent, `{"name":`) {
displayResponse(bufferedContent, opts.WordWrap, state)
}
}
bufferMutex.Unlock()
if len(opts.Messages) > 0 {
fmt.Println()
@ -1437,6 +1867,11 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
}
func generate(cmd *cobra.Command, opts runOptions) error {
// Tools/MCP servers require interactive mode (Chat API)
if len(opts.MCPServers) > 0 {
return errors.New("--tools flag requires interactive mode; use 'ollama run <model> --tools <file>' without piped input")
}
client, err := api.ClientFromEnvironment()
if err != nil {
return err
@ -1460,6 +1895,7 @@ func generate(cmd *cobra.Command, opts runOptions) error {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT)
defer signal.Stop(sigChan)
go func() {
<-sigChan
@ -1491,7 +1927,7 @@ func generate(cmd *cobra.Command, opts runOptions) error {
displayResponse(response.Thinking, opts.WordWrap, state)
}
if thinkTagOpened && !thinkTagClosed && (content != "" || len(response.ToolCalls) > 0) {
if thinkTagOpened && !thinkTagClosed && content != "" {
if !strings.HasSuffix(thinkingContent.String(), "\n") {
fmt.Println()
}
@ -1503,13 +1939,6 @@ func generate(cmd *cobra.Command, opts runOptions) error {
displayResponse(content, opts.WordWrap, state)
if response.ToolCalls != nil {
toolCalls := response.ToolCalls
if len(toolCalls) > 0 {
fmt.Print(renderToolCalls(toolCalls, plainText))
}
}
return nil
}
@ -1704,11 +2133,17 @@ func NewCLI() *cobra.Command {
return
}
if listTools, _ := cmd.Flags().GetBool("list-tools"); listTools {
ListToolsHandler(cmd, args)
return
}
cmd.Print(cmd.UsageString())
},
}
rootCmd.Flags().BoolP("version", "v", false, "Show version information")
rootCmd.Flags().Bool("list-tools", false, "List available MCP servers and their tools")
createCmd := &cobra.Command{
Use: "create MODEL",
@ -1754,6 +2189,7 @@ func NewCLI() *cobra.Command {
runCmd.Flags().Bool("hidethinking", false, "Hide thinking output (if provided)")
runCmd.Flags().Bool("truncate", false, "For embedding models: truncate inputs exceeding context length (default: true). Set --truncate=false to error instead")
runCmd.Flags().Int("dimensions", 0, "Truncate output embeddings to specified dimension (embedding models only)")
runCmd.Flags().String("tools", "", "Enable MCP tools (default: all registered servers with current dir, or specify path for filesystem)")
stopCmd := &cobra.Command{
Use: "stop MODEL",
@ -1964,15 +2400,101 @@ func renderToolCalls(toolCalls []api.ToolCall, plainText bool) string {
out += formatExplanation
}
for i, toolCall := range toolCalls {
argsAsJSON, err := json.Marshal(toolCall.Function.Arguments)
if err != nil {
return ""
}
if i > 0 {
out += "\n"
}
// all tool calls are unexpected since we don't currently support registering any in the CLI
out += fmt.Sprintf(" Model called a non-existent function '%s()' with arguments: %s", formatValues+toolCall.Function.Name+formatExplanation, formatValues+string(argsAsJSON)+formatExplanation)
// Format arguments in a more readable way
var argsDisplay string
// Arguments is already a map[string]any
// Sort keys for deterministic display order
keys := make([]string, 0, len(toolCall.Function.Arguments))
for k := range toolCall.Function.Arguments {
keys = append(keys, k)
}
sort.Strings(keys)
var pairs []string
for _, k := range keys {
pairs = append(pairs, fmt.Sprintf("%s: %v", k, toolCall.Function.Arguments[k]))
}
if len(pairs) > 0 {
argsDisplay = strings.Join(pairs, ", ")
} else {
argsDisplay = "(no arguments)"
}
// Show tool execution in progress with cleaner format
out += fmt.Sprintf("🔧 Executing tool '%s'%s\n",
formatValues+toolCall.Function.Name+formatExplanation, formatExplanation)
out += fmt.Sprintf(" Arguments: %s%s%s\n",
formatValues, argsDisplay, formatExplanation)
}
if !plainText {
out += readline.ColorDefault
}
return out
}
func renderToolResults(toolResults []api.ToolResult, plainText bool) string {
out := ""
formatExplanation := ""
formatValues := ""
formatError := ""
if !plainText {
formatExplanation = readline.ColorGrey + readline.ColorBold
formatValues = readline.ColorDefault
// Use bold for errors since ColorRed doesn't exist
formatError = readline.ColorBold
out += formatExplanation
}
for i, toolResult := range toolResults {
if i > 0 {
out += "\n"
}
// Tool name and arguments already shown in renderToolCalls
// Just show the result or error here
if toolResult.Error != "" {
// Parse error for better context
errorMsg := toolResult.Error
// Try to extract meaningful error from MCP errors
if strings.Contains(errorMsg, "MCP tool returned error") {
errorMsg = "Tool execution failed"
}
// Look for specific error patterns
if strings.Contains(toolResult.Error, "Parent directory does not exist") {
errorMsg = "Parent directory does not exist - check path"
} else if strings.Contains(toolResult.Error, "permission denied") {
errorMsg = "Permission denied - insufficient privileges"
} else if strings.Contains(toolResult.Error, "Invalid arguments") {
errorMsg = "Invalid tool arguments provided"
} else if strings.Contains(toolResult.Error, "file not found") {
errorMsg = "File or directory not found"
}
// Truncate long error messages (rune-safe for UTF-8)
errorRunes := []rune(errorMsg)
if len(errorRunes) > 200 {
errorMsg = string(errorRunes[:197]) + "..."
}
out += fmt.Sprintf("❌ Error: %s%s%s\n",
formatError, errorMsg, formatExplanation)
} else {
content := toolResult.Content
if strings.TrimSpace(content) == "" {
// Empty result - show a clear indicator
out += fmt.Sprintf("✅ Result: %s(empty)%s\n",
formatValues, formatExplanation)
} else {
// Truncate very long results for display (rune-safe for UTF-8)
runes := []rune(content)
if len(runes) > 200 {
content = string(runes[:197]) + "..."
}
out += fmt.Sprintf("✅ Result:\n%s%s%s\n",
formatValues, content, formatExplanation)
}
}
}
if !plainText {
out += readline.ColorDefault

View File

@ -425,6 +425,7 @@ func TestRunEmbeddingModel(t *testing.T) {
cmd.Flags().String("format", "", "")
cmd.Flags().String("think", "", "")
cmd.Flags().Bool("hidethinking", false, "")
cmd.Flags().String("tools", "", "")
oldStdout := os.Stdout
r, w, _ := os.Pipe()
@ -517,6 +518,7 @@ func TestRunEmbeddingModelWithFlags(t *testing.T) {
cmd.Flags().String("format", "", "")
cmd.Flags().String("think", "", "")
cmd.Flags().Bool("hidethinking", false, "")
cmd.Flags().String("tools", "", "")
if err := cmd.Flags().Set("truncate", "true"); err != nil {
t.Fatalf("failed to set truncate flag: %v", err)
@ -618,6 +620,7 @@ func TestRunEmbeddingModelPipedInput(t *testing.T) {
cmd.Flags().String("format", "", "")
cmd.Flags().String("think", "", "")
cmd.Flags().Bool("hidethinking", false, "")
cmd.Flags().String("tools", "", "")
// Capture stdin
oldStdin := os.Stdin
@ -693,6 +696,7 @@ func TestRunEmbeddingModelNoInput(t *testing.T) {
cmd.Flags().String("format", "", "")
cmd.Flags().String("think", "", "")
cmd.Flags().Bool("hidethinking", false, "")
cmd.Flags().String("tools", "", "")
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)

View File

@ -510,6 +510,7 @@ Advanced parameters (optional):
- `options`: additional model parameters listed in the documentation for the [Modelfile](./modelfile.mdx#valid-parameters-and-values) such as `temperature`
- `stream`: if `false` the response will be returned as a single response object, rather than a stream of objects
- `keep_alive`: controls how long the model will stay loaded into memory following the request (default: `5m`)
- `mcp_servers`: (experimental) list of MCP server configurations for autonomous tool execution. See [MCP documentation](./mcp.md)
### Tool calling

View File

@ -25,6 +25,14 @@ I'm a basic program that prints the famous "Hello, world!" message to the consol
ollama run gemma3 "What's in this image? /Users/jmorgan/Desktop/smile.png"
```
#### MCP tools (experimental)
```
ollama run qwen2.5:7b --tools /path/to/directory
```
Enables MCP (Model Context Protocol) servers for autonomous tool execution. See [MCP documentation](./mcp.md).
### Generate embeddings
```

360
docs/mcp.md Normal file
View File

@ -0,0 +1,360 @@
# MCP (Model Context Protocol) Integration
Ollama supports the Model Context Protocol (MCP), enabling language models to execute tools and interact with external systems autonomously.
> **Status**: Experimental
## Quick Start
### CLI Usage
```bash
# Run with filesystem tools restricted to a directory
ollama run qwen2.5:7b --tools /path/to/directory
# In a git repository, both filesystem AND git tools auto-enable
ollama run qwen2.5:7b --tools /path/to/git-repo
# Example interaction
>>> List all files in the directory
# Model will automatically execute filesystem:list_directory tool
>>> Show the git status
# Model will automatically execute git:status tool (if in a git repo)
```
### API Usage
```bash
curl -X POST http://localhost:11434/api/chat \
-d '{
"model": "qwen2.5:7b",
"messages": [{"role": "user", "content": "List the files"}],
"mcp_servers": [{
"name": "filesystem",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/safe/path"]
}]
}'
```
## How It Works
1. **Model generates tool call** in JSON format
2. **Parser detects** the tool call during streaming
3. **MCP server executes** the tool via JSON-RPC over stdio
4. **Results returned** to model context
5. **Model continues** generation with tool results
6. **Loop repeats** for multi-step tasks (up to 15 rounds)
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Public API (mcp.go) │
│ 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 │
└─────────────────────┘
```
## Auto-Enable Configuration
MCP servers can declare when they should automatically enable with the `--tools` flag.
### Auto-Enable Modes
| Mode | Description |
|------|-------------|
| `never` | Server must be explicitly configured via API (default) |
| `always` | Server enables whenever `--tools` is used |
| `with_path` | Server enables when `--tools` has a path argument |
| `if_match` | Server enables if conditions in `enable_if` match |
### Conditional Enabling (if_match)
The `enable_if` object supports these conditions:
| Condition | Description |
|-----------|-------------|
| `file_exists` | Check if a file/directory exists in the tools path |
| `env_set` | Check if an environment variable is set (non-empty) |
### Example Configuration
Create `~/.ollama/mcp-servers.json`:
```json
{
"servers": [
{
"name": "filesystem",
"description": "File system operations",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"requires_path": true,
"auto_enable": "with_path"
},
{
"name": "git",
"description": "Git repository operations",
"command": "npx",
"args": ["-y", "@cyanheads/git-mcp-server"],
"requires_path": true,
"auto_enable": "if_match",
"enable_if": {
"file_exists": ".git"
}
},
{
"name": "postgres",
"description": "PostgreSQL database operations",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres"],
"auto_enable": "if_match",
"enable_if": {
"env_set": "POSTGRES_CONNECTION"
}
},
{
"name": "python",
"description": "Python code execution",
"command": "python",
"args": ["-m", "mcp_server_python"],
"auto_enable": "never"
}
]
}
```
With this configuration:
- **filesystem** enables for any `--tools /path`
- **git** enables only if `/path/.git` exists
- **postgres** enables only if `POSTGRES_CONNECTION` env var is set
- **python** never auto-enables (must use API explicitly)
## API Reference
### Chat Endpoint with MCP
**Endpoint:** `POST /api/chat`
**Request:**
```json
{
"model": "qwen2.5:7b",
"messages": [{"role": "user", "content": "Your prompt"}],
"mcp_servers": [
{
"name": "server-name",
"command": "executable",
"args": ["arg1", "arg2"],
"env": {"KEY": "value"}
}
],
"max_tool_rounds": 10,
"tool_timeout": 30000
}
```
**MCP Server Configuration:**
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Unique identifier for the server |
| `command` | string | Executable to run |
| `args` | []string | Command-line arguments |
| `env` | map | Environment variables |
### Server Definition Fields
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Unique server identifier |
| `description` | string | Human-readable description |
| `command` | string | Executable to run (npx, python, etc.) |
| `args` | []string | Command-line arguments |
| `env` | map | Environment variables |
| `requires_path` | bool | Whether server needs a path argument |
| `path_arg_index` | int | Where to insert path in args (-1 = append) |
| `capabilities` | []string | List of capability tags |
| `auto_enable` | string | Auto-enable mode (never/always/with_path/if_match) |
| `enable_if` | object | Conditions for if_match mode |
## Security
### Implemented Safeguards
- **Process isolation**: MCP servers run in separate process groups
- **Path restrictions**: Filesystem access limited to specified directories
- **Environment filtering**: Allowlist-based, sensitive variables removed
- **Command validation**: Dangerous commands (shells, sudo, rm) blocked
- **Argument sanitization**: Shell injection prevention
- **Timeouts**: 30-second default with graceful shutdown
### Blocked Commands
Shells (`bash`, `sh`, `zsh`), privilege escalation (`sudo`, `su`), destructive commands (`rm`, `dd`), and network tools (`curl`, `wget`, `nc`) are blocked by default.
## Creating MCP Servers
MCP servers communicate via JSON-RPC 2.0 over stdin/stdout and must implement three methods:
### Required Methods
1. **`initialize`** - Returns server capabilities
2. **`tools/list`** - Returns available tools with schemas
3. **`tools/call`** - Executes a tool and returns results
### Minimal Python Example
```python
#!/usr/bin/env python3
import json
import sys
def handle_request(request):
method = request.get("method")
request_id = request.get("id")
if method == "initialize":
return {
"jsonrpc": "2.0", "id": request_id,
"result": {
"protocolVersion": "0.1.0",
"capabilities": {"tools": {}},
"serverInfo": {"name": "my-server", "version": "1.0.0"}
}
}
elif method == "tools/list":
return {
"jsonrpc": "2.0", "id": request_id,
"result": {
"tools": [{
"name": "hello",
"description": "Say hello",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Name to greet"}
},
"required": ["name"]
}
}]
}
}
elif method == "tools/call":
name = request["params"]["arguments"].get("name", "World")
return {
"jsonrpc": "2.0", "id": request_id,
"result": {
"content": [{"type": "text", "text": f"Hello, {name}!"}]
}
}
if __name__ == "__main__":
while True:
line = sys.stdin.readline()
if not line:
break
request = json.loads(line)
response = handle_request(request)
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
```
### Testing Your Server
```bash
# Test initialize
echo '{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}' | python3 my_server.py
# Test tools/list
echo '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":2}' | python3 my_server.py
# Test tools/call
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"hello","arguments":{"name":"Alice"}},"id":3}' | python3 my_server.py
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `OLLAMA_DEBUG=INFO` | Enable debug logging |
| `OLLAMA_MCP_TIMEOUT` | Tool execution timeout (ms) |
| `OLLAMA_MCP_SERVERS` | JSON config for MCP servers (overrides file) |
| `OLLAMA_MCP_DISABLE=1` | Disable MCP validation on startup |
## Supported Models
MCP works best with models that support tool calling:
- Qwen 2.5 / Qwen 3 series
- Llama 3.1+ with tool support
- Other models with JSON tool call output
## Limitations
- **Transport**: stdio only (no HTTP/WebSocket)
- **Protocol**: MCP 1.0
- **Concurrency**: Max 10 parallel MCP servers
- **Platform**: Linux/macOS (Windows untested)
## Troubleshooting
**"Tool not found"**
- Verify MCP server initialized correctly
- Check tool name includes namespace prefix
**"MCP server failed to initialize"**
- Check command path exists
- Verify Python/Node environment
- Test server manually with JSON input
**"No MCP servers matched for --tools context"**
- Check auto_enable settings in config
- Verify path exists and conditions match
**"Access denied"**
- Path outside allowed directories
- Security policy violation
**Debug mode:**
```bash
OLLAMA_DEBUG=INFO ollama serve
```
## Resources
- [MCP Specification](https://modelcontextprotocol.io/docs)
- [Official MCP Servers](https://github.com/modelcontextprotocol/servers)

59
examples/mcp-servers.json Normal file
View File

@ -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"
}
]
}

114
server/mcp.go Normal file
View File

@ -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
}

810
server/mcp_client.go Normal file
View File

@ -0,0 +1,810 @@
package server
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/ollama/ollama/api"
)
// MCPClient manages communication with a single MCP server via JSON-RPC over stdio
type MCPClient struct {
name string
command string
args []string
env map[string]string
// Process management
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
stderr *bufio.Reader
// State
mu sync.RWMutex
initialized bool
tools []api.Tool
requestID int64
responses map[int64]chan *jsonRPCResponse
// Lifecycle
ctx context.Context
cancel context.CancelFunc
done chan struct{}
// Pipe handles (for clean shutdown)
stdoutPipe io.ReadCloser
stderrPipe io.ReadCloser
// Dependencies (injectable for testing)
commandResolver CommandResolverInterface
}
// =============================================================================
// MCPClient Options (Functional Options Pattern)
// =============================================================================
// MCPClientOption configures an MCPClient during creation.
type MCPClientOption func(*MCPClient)
// WithCommandResolver sets a custom command resolver for the client.
// This is primarily useful for testing to avoid system dependencies.
//
// Example:
//
// mockResolver := &MockCommandResolver{...}
// client := NewMCPClient("test", "npx", args, env, WithCommandResolver(mockResolver))
func WithCommandResolver(resolver CommandResolverInterface) MCPClientOption {
return func(c *MCPClient) {
c.commandResolver = resolver
}
}
// JSON-RPC 2.0 message types
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID *int64 `json:"id,omitempty"`
Method string `json:"method"`
Params interface{} `json:"params,omitempty"`
}
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID *int64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
}
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// MCP protocol message types
type mcpInitializeRequest struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]interface{} `json:"capabilities"`
ClientInfo mcpClientInfo `json:"clientInfo"`
}
type mcpClientInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
type mcpInitializeResponse struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]interface{} `json:"capabilities"`
ServerInfo mcpServerInfo `json:"serverInfo"`
}
type mcpServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
type mcpListToolsRequest struct{}
type mcpListToolsResponse struct {
Tools []mcpTool `json:"tools"`
}
type mcpTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]interface{} `json:"inputSchema"`
}
type mcpCallToolRequest struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
type mcpCallToolResponse struct {
Content []mcpContent `json:"content"`
IsError bool `json:"isError,omitempty"`
}
type mcpContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
// NewMCPClient creates a new MCP client for the specified server configuration.
// Optional MCPClientOption arguments can be used to customize behavior (e.g., for testing).
func NewMCPClient(name, command string, args []string, env map[string]string, opts ...MCPClientOption) *MCPClient {
ctx, cancel := context.WithCancel(context.Background())
client := &MCPClient{
name: name,
command: command, // Will be resolved after options are applied
args: args,
env: env,
responses: make(map[int64]chan *jsonRPCResponse),
ctx: ctx,
cancel: cancel,
done: make(chan struct{}),
commandResolver: DefaultCommandResolver, // Default, can be overridden
}
// Apply options
for _, opt := range opts {
opt(client)
}
// Guard against nil resolver (e.g., if WithCommandResolver(nil) was called)
if client.commandResolver == nil {
client.commandResolver = DefaultCommandResolver
}
// Resolve the command using the configured resolver
client.command = client.commandResolver.ResolveForEnvironment(command)
return client
}
// Start spawns the MCP server process and initializes communication.
//
// SECURITY REVIEW: This function executes external processes. Security controls:
// - Command must pass validation in MCPManager.validateServerConfig()
// - Environment is filtered via buildSecureEnvironment()
// - Process runs in isolated process group (Setpgid)
// - Context-based cancellation for cleanup
func (c *MCPClient) Start() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.cmd != nil {
return errors.New("MCP client already started")
}
// Handle commands that might have spaces (like "pnpm dlx")
cmdParts := strings.Fields(c.command)
var cmdName string
var cmdArgs []string
if len(cmdParts) > 1 {
cmdName = cmdParts[0]
cmdArgs = append(cmdParts[1:], c.args...)
} else {
cmdName = c.command
cmdArgs = c.args
}
// SECURITY: Create command with context for cancellation control
c.cmd = exec.CommandContext(c.ctx, cmdName, cmdArgs...)
// SECURITY: Apply filtered environment (see buildSecureEnvironment)
c.cmd.Env = c.buildSecureEnvironment()
// SECURITY: Process isolation via process group
c.cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true, // Isolate in own process group
Pgid: 0, // New process group
// Future: Consider adding privilege dropping for root users
// Credential: &syscall.Credential{Uid: 65534, Gid: 65534}
}
// Set up pipes for communication.
// Each error path explicitly closes previously opened pipes to prevent leaks.
// On success, pipes remain open for the lifetime of the MCP client.
stdin, err := c.cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
c.stdin = stdin
stdout, err := c.cmd.StdoutPipe()
if err != nil {
c.stdin.Close()
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
c.stdoutPipe = stdout
c.stdout = bufio.NewReader(stdout)
stderr, err := c.cmd.StderrPipe()
if err != nil {
c.stdin.Close()
stdout.Close()
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
c.stderrPipe = stderr
c.stderr = bufio.NewReader(stderr)
// Start the process
if err := c.cmd.Start(); err != nil {
c.stdin.Close()
stdout.Close()
stderr.Close()
return fmt.Errorf("failed to start MCP server: %w", err)
}
slog.Info("MCP server started", "name", c.name, "pid", c.cmd.Process.Pid)
// Start message handling goroutines
go c.handleResponses()
go c.handleErrors()
// Check if the process is still running after a brief delay
// This catches immediate failures like command not found
processCheckDone := make(chan bool, 1)
go func() {
time.Sleep(100 * time.Millisecond)
// Non-blocking check if process has exited
if c.cmd.ProcessState != nil {
processCheckDone <- false
return
}
// Try to check process existence without waiting
if c.cmd.Process != nil {
// On Unix systems, signal 0 can be used to check process existence
if err := c.cmd.Process.Signal(syscall.Signal(0)); err != nil {
processCheckDone <- false
return
}
}
processCheckDone <- true
}()
select {
case alive := <-processCheckDone:
if !alive {
// Process died immediately - collect the error
waitErr := c.cmd.Wait()
c.stdin.Close()
stdout.Close()
stderr.Close()
return fmt.Errorf("MCP server exited immediately: %w", waitErr)
}
case <-time.After(200 * time.Millisecond):
// Process seems to be running, continue
}
return nil
}
// Initialize performs the MCP handshake sequence
func (c *MCPClient) Initialize() error {
if err := c.Start(); err != nil {
return err
}
// Add timeout to initialization to prevent hanging
initCtx, cancel := context.WithTimeout(c.ctx, 10*time.Second)
defer cancel()
// Send initialize request
req := mcpInitializeRequest{
ProtocolVersion: "2024-11-05",
Capabilities: map[string]interface{}{
"tools": map[string]interface{}{},
},
ClientInfo: mcpClientInfo{
Name: "ollama",
Version: "0.1.0",
},
}
var resp mcpInitializeResponse
if err := c.callWithContext(initCtx, "initialize", req, &resp); err != nil {
return fmt.Errorf("MCP initialize failed: %w", err)
}
// Send initialized notification
if err := c.notify("notifications/initialized", nil); err != nil {
return fmt.Errorf("MCP initialized notification failed: %w", err)
}
c.mu.Lock()
c.initialized = true
c.mu.Unlock()
slog.Info("MCP client initialized", "name", c.name, "server", resp.ServerInfo.Name)
return nil
}
// ListTools discovers available tools from the MCP server
func (c *MCPClient) ListTools() ([]api.Tool, error) {
c.mu.RLock()
if !c.initialized {
c.mu.RUnlock()
return nil, errors.New("MCP client not initialized")
}
// Return cached tools if available
if len(c.tools) > 0 {
tools := make([]api.Tool, len(c.tools))
copy(tools, c.tools)
c.mu.RUnlock()
return tools, nil
}
c.mu.RUnlock()
var resp mcpListToolsResponse
if err := c.call("tools/list", mcpListToolsRequest{}, &resp); err != nil {
return nil, fmt.Errorf("failed to list MCP tools: %w", err)
}
// Convert MCP tools to Ollama API format
tools := make([]api.Tool, 0, len(resp.Tools))
for _, mcpTool := range resp.Tools {
tool := api.Tool{
Type: "function",
Function: api.ToolFunction{
Name: fmt.Sprintf("%s:%s", c.name, mcpTool.Name), // Namespace with server name
Description: mcpTool.Description,
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: make(map[string]api.ToolProperty),
Required: []string{},
},
},
}
// Convert input schema to tool parameters
if props, ok := mcpTool.InputSchema["properties"].(map[string]interface{}); ok {
for propName, propDef := range props {
propDefMap, ok := propDef.(map[string]interface{})
if !ok {
slog.Debug("MCP schema: property definition not a map", "tool", mcpTool.Name, "property", propName)
continue
}
toolProp := api.ToolProperty{
Description: getStringFromMap(propDefMap, "description"),
}
if propType, ok := propDefMap["type"].(string); ok {
toolProp.Type = api.PropertyType{propType}
} else {
slog.Debug("MCP schema: property type not a string", "tool", mcpTool.Name, "property", propName)
}
// Preserve items schema for array types (needed for context injection)
if items, ok := propDefMap["items"]; ok {
toolProp.Items = items
}
tool.Function.Parameters.Properties[propName] = toolProp
}
} else if mcpTool.InputSchema["properties"] != nil {
slog.Debug("MCP schema: properties not a map", "tool", mcpTool.Name)
}
if required, ok := mcpTool.InputSchema["required"].([]interface{}); ok {
for _, req := range required {
if reqStr, ok := req.(string); ok {
tool.Function.Parameters.Required = append(tool.Function.Parameters.Required, reqStr)
} else {
slog.Debug("MCP schema: required item not a string", "tool", mcpTool.Name)
}
}
} else if mcpTool.InputSchema["required"] != nil {
slog.Debug("MCP schema: required not an array", "tool", mcpTool.Name)
}
tools = append(tools, tool)
}
// Cache the tools
c.mu.Lock()
c.tools = tools
c.mu.Unlock()
slog.Debug("MCP tools discovered", "name", c.name, "count", len(tools))
return tools, nil
}
// CallTool executes a tool call via the MCP server
func (c *MCPClient) CallTool(name string, args map[string]interface{}) (string, error) {
c.mu.RLock()
if !c.initialized {
c.mu.RUnlock()
return "", errors.New("MCP client not initialized")
}
c.mu.RUnlock()
// Remove namespace prefix if present
toolName := name
if prefix := c.name + ":"; len(name) > len(prefix) && name[:len(prefix)] == prefix {
toolName = name[len(prefix):]
}
// Ensure arguments is never nil (MCP protocol requires an object, not undefined)
if args == nil {
args = make(map[string]interface{})
}
req := mcpCallToolRequest{
Name: toolName,
Arguments: args,
}
// Debug logging removed
var resp mcpCallToolResponse
// Set timeout for tool execution
ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
defer cancel()
if err := c.callWithContext(ctx, "tools/call", req, &resp); err != nil {
return "", fmt.Errorf("MCP tool call failed: %w", err)
}
slog.Debug("MCP tool response", "name", name, "is_error", resp.IsError, "content_count", len(resp.Content))
if resp.IsError {
// Log error without full response to avoid exposing sensitive data
slog.Error("MCP tool execution error", "name", name, "content_count", len(resp.Content))
return "", fmt.Errorf("MCP tool returned error")
}
// Concatenate all text content
var result string
for _, content := range resp.Content {
if content.Type == "text" {
result += content.Text
}
}
// Debug logging removed
return result, nil
}
// GetTools returns the list of tools available from this MCP server
func (c *MCPClient) GetTools() []api.Tool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.tools
}
// Close shuts down the MCP client and terminates the server process
func (c *MCPClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.cmd == nil {
return nil
}
slog.Info("Shutting down MCP client", "name", c.name)
// Cancel context to stop goroutines
c.cancel()
// Close stdin to signal shutdown
if c.stdin != nil {
c.stdin.Close()
}
// Close stdout/stderr pipes to unblock handleResponses/handleErrors goroutines
if c.stdoutPipe != nil {
c.stdoutPipe.Close()
}
if c.stderrPipe != nil {
c.stderrPipe.Close()
}
// Wait for process to exit gracefully
done := make(chan error, 1)
go func() {
done <- c.cmd.Wait()
}()
select {
case err := <-done:
if err != nil {
slog.Warn("MCP server exited with error", "name", c.name, "error", err)
}
case <-time.After(5 * time.Second):
// Force kill if not responding
slog.Warn("Force killing unresponsive MCP server", "name", c.name)
c.cmd.Process.Kill()
<-done
}
c.cmd = nil
c.initialized = false
close(c.done)
return nil
}
// call sends a JSON-RPC request and waits for the response
func (c *MCPClient) call(method string, params interface{}, result interface{}) error {
return c.callWithContext(c.ctx, method, params, result)
}
// callWithContext sends a JSON-RPC request with a custom context
func (c *MCPClient) callWithContext(ctx context.Context, method string, params interface{}, result interface{}) error {
id := atomic.AddInt64(&c.requestID, 1)
req := jsonRPCRequest{
JSONRPC: "2.0",
ID: &id,
Method: method,
Params: params,
}
// Create response channel
respChan := make(chan *jsonRPCResponse, 1)
c.mu.Lock()
c.responses[id] = respChan
c.mu.Unlock()
defer func() {
c.mu.Lock()
delete(c.responses, id)
c.mu.Unlock()
close(respChan)
}()
// Send request
if err := c.sendRequest(req); err != nil {
return err
}
// Wait for response
select {
case resp := <-respChan:
if resp.Error != nil {
return fmt.Errorf("JSON-RPC error %d: %s", resp.Error.Code, resp.Error.Message)
}
if result != nil && resp.Result != nil {
if err := json.Unmarshal(resp.Result, result); err != nil {
return fmt.Errorf("failed to unmarshal response: %w", err)
}
}
return nil
case <-ctx.Done():
return ctx.Err()
case <-c.done:
return errors.New("MCP client closed")
}
}
// notify sends a JSON-RPC notification (no response expected)
func (c *MCPClient) notify(method string, params interface{}) error {
req := jsonRPCRequest{
JSONRPC: "2.0",
Method: method,
Params: params,
}
return c.sendRequest(req)
}
// sendRequest sends a JSON-RPC request over stdin
func (c *MCPClient) sendRequest(req jsonRPCRequest) error {
if c.stdin == nil {
return fmt.Errorf("client not started")
}
data, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
if _, err := c.stdin.Write(append(data, '\n')); err != nil {
return fmt.Errorf("failed to write request: %w", err)
}
return nil
}
// handleResponses processes incoming JSON-RPC responses from stdout
func (c *MCPClient) handleResponses() {
defer func() {
if r := recover(); r != nil {
slog.Error("MCP response handler panic", "name", c.name, "error", r)
}
}()
scanner := bufio.NewScanner(c.stdout)
// Set a larger buffer to handle long JSON responses
scanner.Buffer(make([]byte, 64*1024), 1024*1024) // 64KB initial, 1MB max
for {
select {
case <-c.done:
return
default:
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
slog.Error("Error reading MCP response", "name", c.name, "error", err)
}
return
}
line := scanner.Bytes()
var resp jsonRPCResponse
if err := json.Unmarshal(line, &resp); err != nil {
// Don't log raw line content - may contain sensitive data
slog.Warn("Invalid JSON-RPC response", "name", c.name, "error", err, "length", len(line))
continue
}
// Route response to waiting caller
if resp.ID != nil {
c.mu.RLock()
if respChan, exists := c.responses[*resp.ID]; exists {
select {
case respChan <- &resp:
default:
slog.Warn("Response channel full", "name", c.name, "id", *resp.ID)
}
}
c.mu.RUnlock()
}
}
}
}
// handleErrors processes stderr output from the MCP server
func (c *MCPClient) handleErrors() {
defer func() {
if r := recover(); r != nil {
slog.Error("MCP error handler panic", "name", c.name, "error", r)
}
}()
for {
select {
case <-c.done:
return
default:
line, isPrefix, err := c.stderr.ReadLine()
if err != nil {
if err != io.EOF {
slog.Error("Error reading MCP stderr", "name", c.name, "error", err)
}
return
}
if isPrefix {
slog.Warn("MCP stderr line too long, truncated", "name", c.name)
}
// Truncate stderr to avoid logging excessive/sensitive output
msg := string(line)
if len(msg) > 200 {
msg = msg[:200] + "...(truncated)"
}
slog.Debug("MCP server stderr", "name", c.name, "message", msg)
}
}
}
// buildSecureEnvironment creates a filtered environment for the MCP server.
//
// SECURITY REVIEW: This is a critical security function. It controls what
// environment variables are passed to MCP server processes.
//
// Defense strategy (defense in depth):
// 1. Start with empty environment (not inherited)
// 2. Allowlist only known-safe variables
// 3. Apply MCPSecurityConfig filtering (blocks credentials)
// 4. Sanitize PATH to remove dangerous directories
// 5. Add custom env vars only after security checks
func (c *MCPClient) buildSecureEnvironment() []string {
// SECURITY: Start with empty env, not os.Environ()
env := []string{}
// Get security configuration
securityConfig := GetSecurityConfig()
// SECURITY: Allowlist of safe environment variables.
// Only these variables can be passed through from the parent process.
allowedVars := map[string]bool{
"PATH": true,
"HOME": true,
"USER": true,
"LANG": true,
"LC_ALL": true,
"LC_CTYPE": true,
"TZ": true,
"TMPDIR": true,
"TEMP": true,
"TMP": true,
"TERM": true,
"PYTHONPATH": true,
"NODE_PATH": true,
"DISPLAY": true, // For GUI applications
"EDITOR": true, // For text editing
"SHELL": false, // Explicitly blocked - could enable shell escapes
}
// Filter existing environment variables
for _, e := range os.Environ() {
parts := strings.SplitN(e, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
value := parts[1]
// Check if variable should be filtered based on security config
if securityConfig.ShouldFilterEnvironmentVar(key) {
slog.Debug("MCP: filtered credential env var", "name", c.name, "key", key)
continue
}
// Only include explicitly allowed variables
if allowed, exists := allowedVars[key]; exists && allowed {
env = append(env, fmt.Sprintf("%s=%s", key, value))
}
}
// Add custom environment variables from server config.
// NOTE: Custom vars only check the blocklist, not the allowlist. This is intentional:
// inherited vars use strict allowlist (defense in depth), but custom vars are explicitly
// configured by the user/admin, so they're trusted if not in the blocklist.
for key, value := range c.env {
if securityConfig.ShouldFilterEnvironmentVar(key) {
slog.Debug("MCP: blocked custom env var", "name", c.name, "key", key)
continue
}
env = append(env, fmt.Sprintf("%s=%s", key, value))
}
// Set a restricted PATH if not already set
if !hasEnvVar(env, "PATH") {
env = append(env, "PATH=/usr/local/bin:/usr/bin:/bin")
}
return env
}
// getStringFromMap safely extracts a string value from a map
func getStringFromMap(m map[string]interface{}, key string) string {
if val, ok := m[key].(string); ok {
return val
}
return ""
}
// hasEnvVar checks if an environment variable exists in the env slice
func hasEnvVar(env []string, key string) bool {
prefix := key + "="
for _, e := range env {
if strings.HasPrefix(e, prefix) {
return true
}
}
return false
}

81
server/mcp_code_api.go Normal file
View File

@ -0,0 +1,81 @@
package server
import (
"fmt"
"log/slog"
"strings"
"github.com/ollama/ollama/api"
)
// MCPCodeAPI provides context injection for MCP tools
type MCPCodeAPI struct {
manager *MCPManager
}
// NewMCPCodeAPI creates a new MCP code API
func NewMCPCodeAPI(manager *MCPManager) *MCPCodeAPI {
return &MCPCodeAPI{
manager: manager,
}
}
// GenerateMinimalContext returns essential runtime context for tool usage.
// Tool schemas are already provided via the template's TypeScript rendering,
// so we only need to add runtime-specific info like working directories.
func (m *MCPCodeAPI) GenerateMinimalContext(configs []api.MCPServerConfig) string {
slog.Debug("GenerateMinimalContext called", "configs_count", len(configs))
var context strings.Builder
// Add filesystem working directory if applicable
for _, config := range configs {
if workingDir := m.extractFilesystemPath(config); workingDir != "" {
context.WriteString(fmt.Sprintf(`
Filesystem working directory: %s
All filesystem tool paths must be within this directory.
`, workingDir))
}
}
result := context.String()
if result != "" {
slog.Debug("Generated MCP context", "length", len(result))
}
return result
}
// extractFilesystemPath extracts the working directory from filesystem server config
func (m *MCPCodeAPI) extractFilesystemPath(config api.MCPServerConfig) string {
isFilesystem := strings.Contains(config.Command, "filesystem") ||
(len(config.Args) > 0 && strings.Contains(strings.Join(config.Args, " "), "filesystem"))
if isFilesystem && len(config.Args) > 0 {
// Path is typically the last argument
return config.Args[len(config.Args)-1]
}
return ""
}
// InjectContextIntoMessages adds runtime context to the message stream
func (m *MCPCodeAPI) InjectContextIntoMessages(messages []api.Message, configs []api.MCPServerConfig) []api.Message {
context := m.GenerateMinimalContext(configs)
if context == "" {
return messages
}
// Check if there's already a system message
if len(messages) > 0 && messages[0].Role == "system" {
// Append to existing system message
messages[0].Content += context
} else {
// Create new system message
systemMsg := api.Message{
Role: "system",
Content: context,
}
messages = append([]api.Message{systemMsg}, messages...)
}
return messages
}

View File

@ -0,0 +1,214 @@
package server
import (
"fmt"
"log/slog"
"os"
"os/exec"
"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.
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{
resolved: make(map[string]string),
}
}
// DefaultCommandResolver is the shared resolver instance for production use.
// Tests should use WithCommandResolver option instead of modifying this.
var DefaultCommandResolver = NewCommandResolver()
// ResolveCommand finds the actual executable for a given command
func (cr *CommandResolver) ResolveCommand(command string) (string, error) {
cr.mu.RLock()
if resolved, ok := cr.resolved[command]; ok {
cr.mu.RUnlock()
return resolved, nil
}
cr.mu.RUnlock()
// Try to resolve the command
var resolved string
var err error
switch command {
case "npx":
resolved, err = cr.resolveNodePackageManager()
case "python":
resolved, err = cr.resolvePython()
case "node":
resolved, err = cr.resolveNode()
default:
// For other commands, check if they exist as-is
resolved, err = cr.checkCommand(command)
}
if err != nil {
return "", err
}
// Cache the resolution
cr.mu.Lock()
cr.resolved[command] = resolved
cr.mu.Unlock()
return resolved, nil
}
// resolveNodePackageManager finds an available Node.js package manager
func (cr *CommandResolver) resolveNodePackageManager() (string, error) {
// Priority order for package managers
managers := []struct {
cmd string
args []string
}{
{"npx", []string{"--version"}},
{"pnpm", []string{"dlx", "--version"}}, // pnpm equivalent of npx
{"yarn", []string{"dlx", "--version"}}, // yarn 2+ equivalent
{"bunx", []string{"--version"}}, // bun equivalent
}
for _, mgr := range managers {
if path, err := exec.LookPath(mgr.cmd); err == nil {
// Verify it actually works
cmd := exec.Command(path, mgr.args...)
if err := cmd.Run(); err == nil {
// For pnpm/yarn, we need to return the dlx subcommand
if mgr.cmd == "pnpm" {
return "pnpm dlx", nil
} else if mgr.cmd == "yarn" {
return "yarn dlx", nil
}
return mgr.cmd, nil
}
}
}
// Check if npm is available and suggest installing npx
if _, err := exec.LookPath("npm"); err == nil {
return "", fmt.Errorf("npx not found but npm is available - install with: npm install -g npx")
}
return "", fmt.Errorf("no Node.js package manager found (tried npx, pnpm, yarn, bunx)")
}
// resolvePython finds an available Python interpreter
func (cr *CommandResolver) resolvePython() (string, error) {
// Priority order for Python interpreters
interpreters := []string{
"python3", // Most Unix systems
"python", // Windows or virtualenv
"python3.12", // Specific versions
"python3.11",
"python3.10",
"python3.9",
"python3.8",
}
for _, interp := range interpreters {
if path, err := exec.LookPath(interp); err == nil {
// Verify it's Python 3.8+ by checking version
cmd := exec.Command(path, "--version")
output, err := cmd.Output()
if err == nil && len(output) > 0 {
// Basic check that it's Python 3
if string(output[:7]) == "Python " && output[7] >= '3' {
return interp, nil
}
}
}
}
return "", fmt.Errorf("no Python 3 interpreter found (tried python3, python, and versioned variants)")
}
// resolveNode finds the Node.js executable
func (cr *CommandResolver) resolveNode() (string, error) {
// Try different Node.js executable names
nodes := []string{"node", "nodejs"}
for _, node := range nodes {
if path, err := exec.LookPath(node); err == nil {
// Verify it works
cmd := exec.Command(path, "--version")
if err := cmd.Run(); err == nil {
return node, nil
}
}
}
return "", fmt.Errorf("Node.js not found (tried node, nodejs)")
}
// checkCommand checks if a command exists as-is
func (cr *CommandResolver) checkCommand(command string) (string, error) {
if _, err := exec.LookPath(command); err == nil {
return command, nil
}
return "", fmt.Errorf("command not found: %s", command)
}
// ResolveForEnvironment checks environment variables for command overrides
func (cr *CommandResolver) ResolveForEnvironment(command string) string {
// Allow environment variable overrides
envMap := map[string]string{
"npx": "OLLAMA_NPX_COMMAND",
"python": "OLLAMA_PYTHON_COMMAND",
"node": "OLLAMA_NODE_COMMAND",
}
if envVar, ok := envMap[command]; ok {
if override := os.Getenv(envVar); override != "" {
// Validate override against security blocklist
if GetSecurityConfig().IsCommandAllowed(override) {
return override
}
slog.Warn("Environment override blocked by security policy", "var", envVar, "command", override)
}
}
// Try standard resolution
if resolved, err := cr.ResolveCommand(command); err == nil {
return resolved
}
// Return original command as fallback
return command
}
// NOTE: A GetSystemRequirements() method could be added here for diagnostics/status endpoints

280
server/mcp_definitions.go Normal file
View File

@ -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
}

484
server/mcp_manager.go Normal file
View File

@ -0,0 +1,484 @@
package server
import (
"fmt"
"log/slog"
"strings"
"sync"
"github.com/ollama/ollama/api"
)
// MCPManager manages multiple MCP server connections and provides tool execution services
type MCPManager struct {
mu sync.RWMutex
clients map[string]*MCPClient
toolRouting map[string]string // tool name -> client name mapping
maxClients int
}
// MCPServerConfig is imported from api package
// ToolResult represents the result of a tool execution
type ToolResult struct {
Content string
Error error
}
// ExecutionPlan represents the execution strategy for a set of tool calls
type ExecutionPlan struct {
RequiresSequential bool
Groups [][]int // Groups of tool indices that can run in parallel
Reason string // Explanation of why this plan was chosen
}
// NewMCPManager creates a new MCP manager
func NewMCPManager(maxClients int) *MCPManager {
return &MCPManager{
clients: make(map[string]*MCPClient),
toolRouting: make(map[string]string),
maxClients: maxClients,
}
}
// AddServer adds a new MCP server to the manager
func (m *MCPManager) AddServer(config api.MCPServerConfig) error {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.clients) >= m.maxClients {
return fmt.Errorf("maximum number of MCP servers reached (%d)", m.maxClients)
}
if _, exists := m.clients[config.Name]; exists {
return fmt.Errorf("MCP server '%s' already exists", config.Name)
}
// Validate server configuration for security
if err := m.validateServerConfig(config); err != nil {
return fmt.Errorf("invalid MCP server configuration: %w", err)
}
// Create and initialize the MCP client
client := NewMCPClient(config.Name, config.Command, config.Args, config.Env)
if err := client.Initialize(); err != nil {
client.Close()
return fmt.Errorf("failed to initialize MCP server '%s': %w", config.Name, err)
}
// Discover tools
tools, err := client.ListTools()
if err != nil {
client.Close()
return fmt.Errorf("failed to list tools from MCP server '%s': %w", config.Name, err)
}
// Update tool routing
for _, tool := range tools {
m.toolRouting[tool.Function.Name] = config.Name
}
m.clients[config.Name] = client
slog.Info("MCP server added", "name", config.Name, "tools", len(tools))
return nil
}
// RemoveServer removes an MCP server from the manager
func (m *MCPManager) RemoveServer(name string) error {
m.mu.Lock()
defer m.mu.Unlock()
client, exists := m.clients[name]
if !exists {
return fmt.Errorf("MCP server '%s' not found", name)
}
// Remove tool routing entries
for toolName, clientName := range m.toolRouting {
if clientName == name {
delete(m.toolRouting, toolName)
}
}
// Close the client
if err := client.Close(); err != nil {
slog.Warn("Error closing MCP client", "name", name, "error", err)
}
delete(m.clients, name)
slog.Info("MCP server removed", "name", name)
return nil
}
// GetAllTools returns all available tools from all MCP servers
func (m *MCPManager) GetAllTools() []api.Tool {
m.mu.RLock()
defer m.mu.RUnlock()
var allTools []api.Tool
for _, client := range m.clients {
tools, err := client.ListTools()
if err != nil {
slog.Warn("Failed to get tools from MCP client", "name", client.name, "error", err)
continue
}
allTools = append(allTools, tools...)
}
return allTools
}
// ExecuteTool executes a single tool call
func (m *MCPManager) ExecuteTool(toolCall api.ToolCall) ToolResult {
toolName := toolCall.Function.Name
m.mu.RLock()
clientName, exists := m.toolRouting[toolName]
if !exists {
m.mu.RUnlock()
return ToolResult{Error: fmt.Errorf("tool '%s' not found", toolName)}
}
client, exists := m.clients[clientName]
if !exists {
m.mu.RUnlock()
return ToolResult{Error: fmt.Errorf("MCP client '%s' not found", clientName)}
}
m.mu.RUnlock()
// Convert arguments to map[string]interface{}
args := make(map[string]interface{})
for k, v := range toolCall.Function.Arguments {
args[k] = v
}
// Execute the tool
content, err := client.CallTool(toolName, args)
if err != nil {
slog.Debug("MCP tool execution failed", "tool", toolName, "client", clientName)
} else {
slog.Debug("MCP tool executed", "tool", toolName, "client", clientName, "result_length", len(content))
}
return ToolResult{
Content: content,
Error: err,
}
}
// AnalyzeExecutionPlan analyzes tool calls to determine optimal execution strategy
func (m *MCPManager) AnalyzeExecutionPlan(toolCalls []api.ToolCall) ExecutionPlan {
if len(toolCalls) <= 1 {
return ExecutionPlan{
RequiresSequential: false,
Groups: [][]int{{0}},
Reason: "Single tool call",
}
}
// Analyze tool patterns for dependencies
hasWriteOperations := false
hasReadOperations := false
fileTargets := make(map[string][]int) // Track which tools operate on which files
for i, toolCall := range toolCalls {
toolName := toolCall.Function.Name
args := toolCall.Function.Arguments
// Check for file operations
if strings.Contains(toolName, "write") || strings.Contains(toolName, "create") ||
strings.Contains(toolName, "edit") || strings.Contains(toolName, "append") {
hasWriteOperations = true
// Try to extract file path from arguments
if pathArg, exists := args["path"]; exists {
if path, ok := pathArg.(string); ok {
fileTargets[path] = append(fileTargets[path], i)
}
} else if fileArg, exists := args["file"]; exists {
if file, ok := fileArg.(string); ok {
fileTargets[file] = append(fileTargets[file], i)
}
}
}
if strings.Contains(toolName, "read") || strings.Contains(toolName, "list") ||
strings.Contains(toolName, "get") {
hasReadOperations = true
// Try to extract file path from arguments
if pathArg, exists := args["path"]; exists {
if path, ok := pathArg.(string); ok {
fileTargets[path] = append(fileTargets[path], i)
}
} else if fileArg, exists := args["file"]; exists {
if file, ok := fileArg.(string); ok {
fileTargets[file] = append(fileTargets[file], i)
}
}
}
}
// Determine if sequential execution is needed
requiresSequential := false
reason := "Can execute in parallel"
// Check for file operation dependencies
if hasWriteOperations && hasReadOperations {
requiresSequential = true
reason = "Mixed read and write operations detected"
}
// Check for operations on the same file
for file, indices := range fileTargets {
if len(indices) > 1 {
requiresSequential = true
reason = fmt.Sprintf("Multiple operations on the same file: %s", file)
break
}
}
// Check for explicit ordering patterns in tool names
for i := 0; i < len(toolCalls)-1; i++ {
curr := toolCalls[i].Function.Name
next := toolCalls[i+1].Function.Name
// Common patterns that suggest ordering
if (strings.Contains(curr, "create") && strings.Contains(next, "read")) ||
(strings.Contains(curr, "write") && strings.Contains(next, "read")) ||
(strings.Contains(curr, "1") && strings.Contains(next, "2")) ||
(strings.Contains(curr, "first") && strings.Contains(next, "second")) ||
(strings.Contains(curr, "init") && strings.Contains(next, "use")) {
requiresSequential = true
reason = "Tool names suggest sequential dependency"
break
}
}
// Build execution groups
var groups [][]int
if requiresSequential {
// Each tool in its own group for sequential execution
for i := range toolCalls {
groups = append(groups, []int{i})
}
} else {
// All tools in one group for parallel execution
group := make([]int, len(toolCalls))
for i := range toolCalls {
group[i] = i
}
groups = [][]int{group}
}
plan := ExecutionPlan{
RequiresSequential: requiresSequential,
Groups: groups,
Reason: reason,
}
slog.Debug("Execution plan analyzed",
"sequential", requiresSequential,
"reason", reason,
"tool_count", len(toolCalls))
return plan
}
// ExecuteWithPlan executes tool calls according to the execution plan
func (m *MCPManager) ExecuteWithPlan(toolCalls []api.ToolCall, plan ExecutionPlan) []ToolResult {
results := make([]ToolResult, len(toolCalls))
for _, group := range plan.Groups {
if len(group) == 1 {
// Single tool, execute directly
idx := group[0]
results[idx] = m.ExecuteTool(toolCalls[idx])
} else {
// Multiple tools in group, execute in parallel
var wg sync.WaitGroup
for _, idx := range group {
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i] = m.ExecuteTool(toolCalls[i])
}(idx)
}
wg.Wait()
}
}
return results
}
// ExecuteToolsParallel executes multiple tool calls in parallel
func (m *MCPManager) ExecuteToolsParallel(toolCalls []api.ToolCall) []ToolResult {
if len(toolCalls) == 0 {
return nil
}
results := make([]ToolResult, len(toolCalls))
// For single tool call, execute directly
if len(toolCalls) == 1 {
results[0] = m.ExecuteTool(toolCalls[0])
return results
}
// Execute multiple tools in parallel
var wg sync.WaitGroup
for i, toolCall := range toolCalls {
wg.Add(1)
go func(index int, tc api.ToolCall) {
defer wg.Done()
results[index] = m.ExecuteTool(tc)
}(i, toolCall)
}
wg.Wait()
return results
}
// ExecuteToolsSequential executes multiple tool calls sequentially
func (m *MCPManager) ExecuteToolsSequential(toolCalls []api.ToolCall) []ToolResult {
results := make([]ToolResult, len(toolCalls))
for i, toolCall := range toolCalls {
results[i] = m.ExecuteTool(toolCall)
// Stop on first error if desired
if results[i].Error != nil {
slog.Warn("Tool execution failed", "tool", toolCall.Function.Name, "error", results[i].Error)
}
}
return results
}
// GetToolClient returns the client name for a given tool
func (m *MCPManager) GetToolClient(toolName string) (string, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
clientName, exists := m.toolRouting[toolName]
return clientName, exists
}
// GetServerNames returns a list of all registered MCP server names
func (m *MCPManager) GetServerNames() []string {
m.mu.RLock()
defer m.mu.RUnlock()
names := make([]string, 0, len(m.clients))
for name := range m.clients {
names = append(names, name)
}
return names
}
// GetToolDefinition returns the definition for a specific tool
func (m *MCPManager) GetToolDefinition(serverName, toolName string) *api.Tool {
m.mu.RLock()
defer m.mu.RUnlock()
client, exists := m.clients[serverName]
if !exists {
return nil
}
// Get tools from the client
tools := client.GetTools()
for _, tool := range tools {
if tool.Function.Name == toolName {
return &tool
}
}
return nil
}
// Close shuts down all MCP clients
func (m *MCPManager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
var errs []string
for name, client := range m.clients {
if err := client.Close(); err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", name, err))
}
}
// Clear all data
m.clients = make(map[string]*MCPClient)
m.toolRouting = make(map[string]string)
if len(errs) > 0 {
return fmt.Errorf("errors closing MCP clients: %s", strings.Join(errs, "; "))
}
return nil
}
// Shutdown is an alias for Close for consistency with registry
func (m *MCPManager) Shutdown() error {
slog.Info("Shutting down MCP manager", "clients", len(m.clients))
return m.Close()
}
// validateServerConfig validates MCP server configuration for security
func (m *MCPManager) validateServerConfig(config api.MCPServerConfig) error {
// Validate name
if config.Name == "" {
return fmt.Errorf("server name cannot be empty")
}
if len(config.Name) > 100 {
return fmt.Errorf("server name too long (max 100 characters)")
}
if strings.ContainsAny(config.Name, "/\\:*?\"<>|") {
return fmt.Errorf("server name contains invalid characters")
}
// Validate command
if config.Command == "" {
return fmt.Errorf("command cannot be empty")
}
// Get security configuration
securityConfig := GetSecurityConfig()
// Check if command is allowed by security policy
if !securityConfig.IsCommandAllowed(config.Command) {
return fmt.Errorf("command '%s' is not allowed for security reasons", config.Command)
}
// Validate command path (must be absolute or in PATH)
if strings.Contains(config.Command, "..") {
return fmt.Errorf("command path cannot contain '..'")
}
// Validate arguments
for _, arg := range config.Args {
if strings.Contains(arg, "..") || strings.HasPrefix(arg, "-") && len(arg) > 50 {
return fmt.Errorf("suspicious argument detected: %s", arg)
}
// Check for shell injection attempts using security config
if securityConfig.HasShellMetacharacters(arg) {
return fmt.Errorf("argument contains shell metacharacters: %s", arg)
}
}
// Validate environment variables
for key := range config.Env {
if securityConfig.HasShellMetacharacters(key) {
return fmt.Errorf("environment variable name contains invalid characters: %s", key)
}
}
return nil
}

View File

@ -0,0 +1,152 @@
package server
import (
"path/filepath"
"strings"
)
// =============================================================================
// MCP Security Configuration
// =============================================================================
//
// SECURITY REVIEW: This file defines the security policies that control which
// commands can be executed as MCP servers and what arguments/environment they
// can receive. Changes to this file should be reviewed by security-aware
// maintainers.
//
// Key security surfaces:
// - BlockedCommands: Prevents execution of dangerous system commands
// - BlockedMetacharacters: Prevents shell injection attacks
// - FilteredEnvironmentVars: Prevents credential leakage to MCP servers
//
// Threat model:
// - Malicious MCP server configs attempting to execute system commands
// - Shell injection through tool arguments
// - Credential theft through environment variable access
//
// =============================================================================
// MCPSecurityConfig defines security policies for MCP servers
type MCPSecurityConfig struct {
// Commands that are never allowed as MCP servers
BlockedCommands []string
// Shell metacharacters that are not allowed in arguments
BlockedMetacharacters []string
// Environment variables that should be filtered
FilteredEnvironmentVars []string
}
// DefaultSecurityConfig returns the default security configuration.
//
// SECURITY REVIEW: This function defines the default blocklists. Adding or
// removing entries has direct security implications. Consider:
// - Why is a command being added/removed?
// - What attack vectors does it enable/prevent?
// - Are there bypass possibilities (symlinks, PATH manipulation)?
func DefaultSecurityConfig() *MCPSecurityConfig {
return &MCPSecurityConfig{
// SECURITY: Blocked commands - these can never be used as MCP server commands.
// Rationale: These commands could be used for privilege escalation,
// arbitrary file manipulation, or establishing network connections.
BlockedCommands: []string{
// Shells - prevent arbitrary command execution
"sh", "bash", "zsh", "fish", "csh", "ksh", "dash", "tcsh",
"cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe",
// System commands - prevent privilege escalation and system damage
"sudo", "su", "doas", "runas", "pkexec",
"rm", "del", "rmdir", "format", "dd", "shred",
"kill", "killall", "pkill", "shutdown", "reboot",
"systemctl", "service", "init",
// Network tools - prevent data exfiltration and network attacks
"curl", "wget", "nc", "netcat", "telnet", "ssh", "scp", "sftp",
"nmap", "ping", "traceroute", "dig", "nslookup",
// Script interpreters - prevent arbitrary code execution
"eval", "exec", "source", ".",
"perl", "ruby", "php", "lua", "tcl",
// File manipulation - prevent permission/ownership changes
"chmod", "chown", "chgrp", "mount", "umount",
"ln", "mkfifo", "mknod",
// Package managers - prevent system modification
"apt", "apt-get", "yum", "dnf", "pacman", "zypper",
"brew", "port", "snap", "flatpak",
},
// SECURITY: Shell metacharacters - block these in arguments to prevent injection.
// These characters could be used to chain commands or redirect I/O.
BlockedMetacharacters: []string{
";", "|", "&", "$(", "`", ">", "<", ">>", "<<",
"||", "&&", "\n", "\r", "$", "!", "*", "?", "=",
},
// SECURITY: Environment variables to filter - prevent credential leakage.
// MCP servers should not have access to credentials in the parent environment.
FilteredEnvironmentVars: []string{
// AWS
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN",
// Cloud providers
"GOOGLE_APPLICATION_CREDENTIALS", "AZURE_CLIENT_SECRET",
// API Keys
"GITHUB_TOKEN", "GITLAB_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
// Database
"DATABASE_URL", "DB_PASSWORD", "MYSQL_ROOT_PASSWORD", "POSTGRES_PASSWORD",
// Authentication
"JWT_SECRET", "SESSION_SECRET", "AUTH_TOKEN", "API_KEY", "API_SECRET",
// SSH
"SSH_AUTH_SOCK", "SSH_AGENT_PID",
},
}
}
// IsCommandAllowed checks if a command is allowed by security policy
func (c *MCPSecurityConfig) IsCommandAllowed(command string) bool {
baseName := filepath.Base(command)
for _, blocked := range c.BlockedCommands {
if baseName == blocked || strings.HasSuffix(command, "/"+blocked) {
return false
}
}
return true
}
// HasShellMetacharacters checks if a string contains shell metacharacters
func (c *MCPSecurityConfig) HasShellMetacharacters(s string) bool {
for _, meta := range c.BlockedMetacharacters {
if strings.Contains(s, meta) {
return true
}
}
return false
}
// ShouldFilterEnvironmentVar checks if an environment variable should be filtered
func (c *MCPSecurityConfig) ShouldFilterEnvironmentVar(key string) bool {
for _, filtered := range c.FilteredEnvironmentVars {
if key == filtered {
return true
}
}
return false
}
// Global security config instance
// NOTE: To add user customization, load from ~/.ollama/mcp-security.json and append to these defaults
var globalSecurityConfig = DefaultSecurityConfig()
// GetSecurityConfig returns the global security configuration
func GetSecurityConfig() *MCPSecurityConfig {
return globalSecurityConfig
}

178
server/mcp_sessions.go Normal file
View File

@ -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]
}

731
server/mcp_test.go Normal file
View File

@ -0,0 +1,731 @@
package server
// =============================================================================
// MCP Integration Tests
// =============================================================================
//
// This file contains tests for the MCP (Model Context Protocol) implementation.
//
// Test Categories:
//
// 1. Client Tests (TestMCPClient*)
// - Client initialization and lifecycle
// - Environment variable filtering
// - Timeout handling
//
// 2. Security Tests (TestDangerous*, TestShellInjection*, TestSecure*)
// - Command blocklist validation
// - Shell metacharacter detection
// - Credential filtering
//
// 3. Manager Tests (TestMCPManager*, TestToolResult*, TestParallel*)
// - Server registration
// - Tool caching
// - Parallel execution
//
// 4. Auto-Enable Tests (TestAutoEnable*)
// - Mode: never, always, with_path, if_match
// - Conditions: file_exists, env_set
//
// Run all MCP tests:
// go test -v ./server/... -run "TestMCP|TestSecure|TestShell|TestTool|TestDanger|TestParallel|TestAutoEnable"
//
// =============================================================================
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/ollama/ollama/api"
)
// TestMCPClientInitialization tests the MCP client initialization
func TestMCPClientInitialization(t *testing.T) {
client := NewMCPClient("test", "echo", []string{"test"}, nil)
require.Equal(t, "test", client.name)
require.Equal(t, "echo", client.command)
require.False(t, client.initialized, "Client should not be initialized on creation")
}
// TestSecureEnvironmentFiltering tests environment variable filtering
func TestSecureEnvironmentFiltering(t *testing.T) {
// Set some test environment variables
os.Setenv("TEST_SAFE_VAR", "safe_value")
os.Setenv("AWS_SECRET_ACCESS_KEY", "secret_key")
os.Setenv("PATH", "/usr/local/bin:/usr/bin:/bin:/root/bin")
defer os.Unsetenv("TEST_SAFE_VAR")
defer os.Unsetenv("AWS_SECRET_ACCESS_KEY")
client := NewMCPClient("test", "echo", []string{}, nil)
env := client.buildSecureEnvironment()
// Check that sensitive variables are filtered out
for _, e := range env {
require.False(t, strings.HasPrefix(e, "AWS_SECRET_ACCESS_KEY="),
"Sensitive AWS_SECRET_ACCESS_KEY should be filtered out")
require.False(t, strings.Contains(e, "/root/bin"),
"Dangerous PATH component /root/bin should be filtered out")
}
// Check that PATH is present but sanitized
hasPath := false
for _, e := range env {
if strings.HasPrefix(e, "PATH=") {
hasPath = true
require.NotContains(t, e, "/root",
"PATH should not contain /root directories")
}
}
require.True(t, hasPath, "PATH should be present in environment")
}
// TestMCPManagerAddServer tests adding MCP servers to the manager
func TestMCPManagerAddServer(t *testing.T) {
manager := NewMCPManager(5)
// Test adding a valid server config
config := api.MCPServerConfig{
Name: "test_server",
Command: "python",
Args: []string{"-m", "test_module"},
Env: map[string]string{"TEST": "value"},
}
// This will fail in test environment but validates the validation logic
err := manager.AddServer(config)
if err != nil {
require.Contains(t, err.Error(), "failed to initialize",
"Expected initialization failure in test environment")
}
// Test invalid server names
invalidConfigs := []api.MCPServerConfig{
{Name: "", Command: "python"}, // Empty name
{Name: strings.Repeat("a", 101), Command: "python"}, // Too long
{Name: "test/server", Command: "python"}, // Invalid characters
}
for _, cfg := range invalidConfigs {
err := manager.validateServerConfig(cfg)
require.Error(t, err, "Should reject invalid config: %+v", cfg)
}
}
// TestDangerousCommandValidation tests rejection of dangerous commands
func TestDangerousCommandValidation(t *testing.T) {
manager := NewMCPManager(5)
dangerousConfigs := []api.MCPServerConfig{
{Name: "test1", Command: "bash"},
{Name: "test2", Command: "/bin/sh"},
{Name: "test3", Command: "sudo"},
{Name: "test4", Command: "rm"},
{Name: "test5", Command: "curl"},
{Name: "test6", Command: "eval"},
}
for _, cfg := range dangerousConfigs {
err := manager.validateServerConfig(cfg)
require.Error(t, err, "Should reject dangerous command: %s", cfg.Command)
require.Contains(t, err.Error(), "not allowed for security",
"Expected security error for command %s", cfg.Command)
}
// Test that safe commands are allowed
safeConfigs := []api.MCPServerConfig{
{Name: "test1", Command: "python"},
{Name: "test2", Command: "node"},
{Name: "test3", Command: "/usr/bin/python3"},
}
for _, cfg := range safeConfigs {
err := manager.validateServerConfig(cfg)
require.NoError(t, err, "Should allow safe command %s", cfg.Command)
}
}
// TestShellInjectionPrevention tests prevention of shell injection
func TestShellInjectionPrevention(t *testing.T) {
manager := NewMCPManager(5)
// Test arguments with shell metacharacters
injectionConfigs := []api.MCPServerConfig{
{
Name: "test1",
Command: "python",
Args: []string{"; rm -rf /"},
},
{
Name: "test2",
Command: "python",
Args: []string{"test", "| cat /etc/passwd"},
},
{
Name: "test3",
Command: "python",
Args: []string{"$(whoami)"},
},
{
Name: "test4",
Command: "python",
Args: []string{"`id`"},
},
}
for _, cfg := range injectionConfigs {
err := manager.validateServerConfig(cfg)
require.Error(t, err, "Should reject shell injection attempt in args: %v", cfg.Args)
require.Contains(t, err.Error(), "shell metacharacters",
"Expected shell metacharacter error")
}
}
// TestParallelToolExecution tests parallel execution of tools
func TestParallelToolExecution(t *testing.T) {
manager := NewMCPManager(5)
// Create test tool calls
toolCalls := []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "tool1",
Arguments: map[string]interface{}{"test": "1"},
},
},
{
Function: api.ToolCallFunction{
Name: "tool2",
Arguments: map[string]interface{}{"test": "2"},
},
},
{
Function: api.ToolCallFunction{
Name: "tool3",
Arguments: map[string]interface{}{"test": "3"},
},
},
}
// Execute in parallel (will fail but tests the mechanism)
results := manager.ExecuteToolsParallel(toolCalls)
require.Len(t, results, len(toolCalls))
// All should have errors since no MCP servers are connected
for i, result := range results {
require.Error(t, result.Error, "Expected error for tool call %d", i)
}
}
// TestMCPClientTimeout tests timeout handling for tool execution
func TestMCPClientTimeout(t *testing.T) {
client := NewMCPClient("test", "sleep", []string{"60"}, nil)
// Create a context with very short timeout
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
// Try to call with timeout (will fail but tests the mechanism)
req := mcpCallToolRequest{
Name: "test_tool",
Arguments: map[string]interface{}{},
}
var resp mcpCallToolResponse
err := client.callWithContext(ctx, "tools/call", req, &resp)
// Should timeout or fail
require.Error(t, err, "Expected timeout or error")
}
// TestEnvironmentVariableValidation tests validation of environment variables
func TestEnvironmentVariableValidation(t *testing.T) {
manager := NewMCPManager(5)
// Test invalid environment variable names
invalidEnvConfigs := []api.MCPServerConfig{
{
Name: "test1",
Command: "python",
Env: map[string]string{"VAR=BAD": "value"},
},
{
Name: "test2",
Command: "python",
Env: map[string]string{"VAR;CMD": "value"},
},
{
Name: "test3",
Command: "python",
Env: map[string]string{"VAR|PIPE": "value"},
},
}
for _, cfg := range invalidEnvConfigs {
err := manager.validateServerConfig(cfg)
require.Error(t, err, "Should reject invalid environment variable names: %v", cfg.Env)
}
// Test valid environment variables
validConfig := api.MCPServerConfig{
Name: "test",
Command: "python",
Env: map[string]string{
"PYTHONPATH": "/usr/lib/python3",
"MY_VAR": "value",
"TEST_123": "test",
},
}
err := manager.validateServerConfig(validConfig)
require.NoError(t, err, "Should allow valid environment variables")
}
// BenchmarkToolExecution benchmarks tool execution performance
func BenchmarkToolExecution(b *testing.B) {
manager := NewMCPManager(10)
toolCall := api.ToolCall{
Function: api.ToolCallFunction{
Name: "test_tool",
Arguments: map[string]interface{}{"param": "value"},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = manager.ExecuteTool(toolCall)
}
}
// BenchmarkParallelToolExecution benchmarks parallel tool execution
func BenchmarkParallelToolExecution(b *testing.B) {
manager := NewMCPManager(10)
toolCalls := make([]api.ToolCall, 10)
for i := range toolCalls {
toolCalls[i] = api.ToolCall{
Function: api.ToolCallFunction{
Name: fmt.Sprintf("tool_%d", i),
Arguments: map[string]interface{}{"param": i},
},
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = manager.ExecuteToolsParallel(toolCalls)
}
}
// =============================================================================
// Auto-Enable Unit Tests
// =============================================================================
// TestAutoEnableMode_Never verifies servers with auto_enable:"never" don't auto-enable
func TestAutoEnableMode_Never(t *testing.T) {
defs := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"never_server": {
Name: "never_server",
Command: "python",
AutoEnable: AutoEnableNever,
},
"empty_mode": {
Name: "empty_mode",
Command: "python",
// AutoEnable not set - defaults to never
},
},
}
ctx := AutoEnableContext{ToolsPath: "/some/path"}
servers := defs.GetAutoEnableServers(ctx)
require.Empty(t, servers, "Expected 0 auto-enabled servers for 'never' mode")
}
// TestAutoEnableMode_Always verifies servers with auto_enable:"always" always enable
func TestAutoEnableMode_Always(t *testing.T) {
defs := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"always_server": {
Name: "always_server",
Command: "python",
Args: []string{"-m", "server"},
AutoEnable: AutoEnableAlways,
},
},
}
// Should enable even with empty path
ctx := AutoEnableContext{ToolsPath: ""}
servers := defs.GetAutoEnableServers(ctx)
require.Len(t, servers, 1)
require.Equal(t, "always_server", servers[0].Name)
// Should also enable with path
ctx = AutoEnableContext{ToolsPath: "/tmp"}
servers = defs.GetAutoEnableServers(ctx)
require.Len(t, servers, 1)
}
// TestAutoEnableMode_WithPath verifies servers with auto_enable:"with_path" enable only when path is provided
func TestAutoEnableMode_WithPath(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
defs := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"path_server": {
Name: "path_server",
Command: "python",
Args: []string{"-m", "server"},
RequiresPath: true,
PathArgIndex: -1,
AutoEnable: AutoEnableWithPath,
},
},
}
// Should NOT enable without path
ctx := AutoEnableContext{ToolsPath: ""}
servers := defs.GetAutoEnableServers(ctx)
require.Empty(t, servers, "Expected 0 servers without path")
// Should enable with valid path
ctx = AutoEnableContext{ToolsPath: tmpDir}
servers = defs.GetAutoEnableServers(ctx)
require.Len(t, servers, 1)
// Verify path was appended to args
expectedArgs := []string{"-m", "server", tmpDir}
require.Equal(t, expectedArgs, servers[0].Args)
}
// TestAutoEnableMode_IfMatch_FileExists verifies file_exists condition
func TestAutoEnableMode_IfMatch_FileExists(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
// Create .git directory to simulate git repo
gitDir := filepath.Join(tmpDir, ".git")
require.NoError(t, os.Mkdir(gitDir, 0755))
defs := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"git_server": {
Name: "git_server",
Command: "python",
Args: []string{"-m", "git_server"},
RequiresPath: true,
PathArgIndex: -1,
AutoEnable: AutoEnableIfMatch,
EnableIf: EnableCondition{FileExists: ".git"},
},
},
}
// Should enable when .git exists
ctx := AutoEnableContext{ToolsPath: tmpDir}
servers := defs.GetAutoEnableServers(ctx)
require.Len(t, servers, 1, "Expected 1 server when .git exists")
// Should NOT enable in directory without .git
noGitDir, err := os.MkdirTemp("", "mcp-test-nogit-*")
require.NoError(t, err)
defer os.RemoveAll(noGitDir)
ctx = AutoEnableContext{ToolsPath: noGitDir}
servers = defs.GetAutoEnableServers(ctx)
require.Empty(t, servers, "Expected 0 servers without .git")
}
// TestAutoEnableMode_IfMatch_EnvSet verifies env_set condition
func TestAutoEnableMode_IfMatch_EnvSet(t *testing.T) {
defs := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"env_server": {
Name: "env_server",
Command: "python",
AutoEnable: AutoEnableIfMatch,
EnableIf: EnableCondition{EnvSet: "MCP_TEST_VAR"},
},
},
}
// Test with env in context
ctx := AutoEnableContext{
ToolsPath: "",
Env: map[string]string{"MCP_TEST_VAR": "some_value"},
}
servers := defs.GetAutoEnableServers(ctx)
require.Len(t, servers, 1, "Expected 1 server when env is set in context")
// Test with env NOT set
ctx = AutoEnableContext{
ToolsPath: "",
Env: map[string]string{},
}
servers = defs.GetAutoEnableServers(ctx)
require.Empty(t, servers, "Expected 0 servers when env not set")
// Test with os.Getenv fallback
os.Setenv("MCP_TEST_VAR_FALLBACK", "fallback_value")
defer os.Unsetenv("MCP_TEST_VAR_FALLBACK")
defsFallback := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"env_server": {
Name: "env_server",
Command: "python",
AutoEnable: AutoEnableIfMatch,
EnableIf: EnableCondition{EnvSet: "MCP_TEST_VAR_FALLBACK"},
},
},
}
ctx = AutoEnableContext{ToolsPath: "", Env: nil}
servers = defsFallback.GetAutoEnableServers(ctx)
require.Len(t, servers, 1, "Expected 1 server with os.Getenv fallback")
}
// TestAutoEnableMode_IfMatch_CombinedConditions verifies AND logic for conditions
func TestAutoEnableMode_IfMatch_CombinedConditions(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
markerFile := filepath.Join(tmpDir, ".marker")
require.NoError(t, os.WriteFile(markerFile, []byte("test"), 0644))
defs := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"combined_server": {
Name: "combined_server",
Command: "python",
RequiresPath: true,
PathArgIndex: -1,
AutoEnable: AutoEnableIfMatch,
EnableIf: EnableCondition{
FileExists: ".marker",
EnvSet: "MCP_COMBINED_TEST",
},
},
},
}
// Should NOT enable when only file exists
ctx := AutoEnableContext{
ToolsPath: tmpDir,
Env: map[string]string{},
}
servers := defs.GetAutoEnableServers(ctx)
require.Empty(t, servers, "Expected 0 servers when only file condition matches")
// Should NOT enable when only env is set
ctx = AutoEnableContext{
ToolsPath: "/nonexistent",
Env: map[string]string{"MCP_COMBINED_TEST": "value"},
}
servers = defs.GetAutoEnableServers(ctx)
require.Empty(t, servers, "Expected 0 servers when only env condition matches")
// Should enable when BOTH conditions match
ctx = AutoEnableContext{
ToolsPath: tmpDir,
Env: map[string]string{"MCP_COMBINED_TEST": "value"},
}
servers = defs.GetAutoEnableServers(ctx)
require.Len(t, servers, 1, "Expected 1 server when both conditions match")
}
// TestGetAutoEnableServers_MultipleServers verifies multiple servers can auto-enable
func TestGetAutoEnableServers_MultipleServers(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
// Create .git directory
require.NoError(t, os.Mkdir(filepath.Join(tmpDir, ".git"), 0755))
defs := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"filesystem": {
Name: "filesystem",
Command: "npx",
Args: []string{"-y", "@mcp/server-filesystem"},
RequiresPath: true,
PathArgIndex: -1,
AutoEnable: AutoEnableWithPath,
},
"git": {
Name: "git",
Command: "python",
Args: []string{"-m", "mcp_git"},
RequiresPath: true,
PathArgIndex: -1,
AutoEnable: AutoEnableIfMatch,
EnableIf: EnableCondition{FileExists: ".git"},
},
"never_server": {
Name: "never_server",
Command: "python",
AutoEnable: AutoEnableNever,
},
},
}
ctx := AutoEnableContext{ToolsPath: tmpDir}
servers := defs.GetAutoEnableServers(ctx)
require.Len(t, servers, 2, "Expected 2 auto-enabled servers")
// Verify both filesystem and git are enabled
names := make(map[string]bool)
for _, s := range servers {
names[s.Name] = true
}
require.True(t, names["filesystem"], "Expected 'filesystem' server to be auto-enabled")
require.True(t, names["git"], "Expected 'git' server to be auto-enabled")
require.False(t, names["never_server"], "'never_server' should NOT be auto-enabled")
}
// TestBuildConfigForAutoEnable_PathArgIndex verifies path insertion at different positions
func TestBuildConfigForAutoEnable_PathArgIndex(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "mcp-test-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
testCases := []struct {
name string
args []string
pathArgIndex int
expected []string
}{
{
name: "append at end (index -1)",
args: []string{"arg1", "arg2"},
pathArgIndex: -1,
expected: []string{"arg1", "arg2", tmpDir},
},
{
name: "insert at beginning (index 0)",
args: []string{"arg1", "arg2"},
pathArgIndex: 0,
expected: []string{tmpDir, "arg1", "arg2"},
},
{
name: "insert in middle (index 1)",
args: []string{"arg1", "arg2"},
pathArgIndex: 1,
expected: []string{"arg1", tmpDir, "arg2"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
defs := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"test": {
Name: "test",
Command: "python",
Args: tc.args,
RequiresPath: true,
PathArgIndex: tc.pathArgIndex,
AutoEnable: AutoEnableWithPath,
},
},
}
ctx := AutoEnableContext{ToolsPath: tmpDir}
servers := defs.GetAutoEnableServers(ctx)
require.Len(t, servers, 1)
require.Equal(t, tc.expected, servers[0].Args)
})
}
}
// TestBuildConfigForAutoEnable_InvalidPath verifies error handling for invalid paths
func TestBuildConfigForAutoEnable_InvalidPath(t *testing.T) {
defs := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"path_server": {
Name: "path_server",
Command: "python",
RequiresPath: true,
AutoEnable: AutoEnableWithPath,
},
},
}
// Should fail with non-existent path
ctx := AutoEnableContext{ToolsPath: "/definitely/not/a/real/path/12345"}
servers := defs.GetAutoEnableServers(ctx)
// Server should be skipped due to invalid path
require.Empty(t, servers, "Expected 0 servers with invalid path")
}
// TestBuildConfigForAutoEnable_EnvCopy verifies environment variables are copied
func TestBuildConfigForAutoEnable_EnvCopy(t *testing.T) {
defs := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"env_server": {
Name: "env_server",
Command: "python",
AutoEnable: AutoEnableAlways,
Env: map[string]string{
"VAR1": "value1",
"VAR2": "value2",
},
},
},
}
ctx := AutoEnableContext{}
servers := defs.GetAutoEnableServers(ctx)
require.Len(t, servers, 1)
require.Equal(t, "value1", servers[0].Env["VAR1"])
require.Equal(t, "value2", servers[0].Env["VAR2"])
// Verify original wasn't mutated by modifying copy
servers[0].Env["VAR1"] = "modified"
original := defs.Servers["env_server"]
require.Equal(t, "value1", original.Env["VAR1"], "Original server definition was mutated")
}
// TestEnableCondition_EmptyConditions verifies empty conditions always match
func TestEnableCondition_EmptyConditions(t *testing.T) {
defs := &MCPDefinitions{
Servers: map[string]MCPServerDefinition{
"empty_cond": {
Name: "empty_cond",
Command: "python",
AutoEnable: AutoEnableIfMatch,
EnableIf: EnableCondition{}, // Empty - should always match
},
},
}
ctx := AutoEnableContext{ToolsPath: ""}
servers := defs.GetAutoEnableServers(ctx)
require.Len(t, servers, 1, "Expected 1 server with empty conditions")
}

View File

@ -52,6 +52,17 @@ import (
"github.com/ollama/ollama/version"
)
// CompletionResult holds the result of a completion request
type CompletionResult struct {
Content string
Thinking string
ToolCalls []api.ToolCall
Done bool
DoneReason string
Metrics api.Metrics
Error error
}
const signinURLStr = "https://ollama.com/connect?name=%s&key=%s"
func shouldUseHarmony(model *Model) bool {
@ -337,10 +348,11 @@ func (s *Server) GenerateHandler(c *gin.Context) {
m.Config.Parser = "harmony"
}
if !req.Raw && m.Config.Parser != "" {
builtinParser = parsers.ParserForName(m.Config.Parser)
if builtinParser != nil {
// no tools or last message for generate endpoint
// Initialize parser for thinking extraction only (tools not supported in Generate API)
builtinParser.Init(nil, nil, req.Think)
}
}
@ -459,7 +471,7 @@ func (s *Server) GenerateHandler(c *gin.Context) {
// the real chat handler, but doing this as a stopgap to get renderer
// support for generate
if values.Messages != nil && values.Suffix == "" && req.Template == "" {
prompt, images, err = chatPrompt(c.Request.Context(), m, r.Tokenize, opts, values.Messages, []api.Tool{}, req.Think, req.Truncate == nil || *req.Truncate)
prompt, images, err = chatPrompt(c.Request.Context(), m, r.Tokenize, opts, values.Messages, nil, req.Think, req.Truncate == nil || *req.Truncate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@ -510,8 +522,8 @@ func (s *Server) GenerateHandler(c *gin.Context) {
ch := make(chan any)
go func() {
// TODO (jmorganca): avoid building the response twice both here and below
var sb strings.Builder
defer close(ch)
var sb strings.Builder
if err := r.Completion(c.Request.Context(), llm.CompletionRequest{
Prompt: prompt,
Images: images,
@ -537,16 +549,13 @@ func (s *Server) GenerateHandler(c *gin.Context) {
}
if builtinParser != nil {
content, thinking, toolCalls, err := builtinParser.Add(cr.Content, cr.Done)
content, thinking, _, err := builtinParser.Add(cr.Content, cr.Done)
if err != nil {
ch <- gin.H{"error": err.Error()}
return
}
res.Response = content
res.Thinking = thinking
if cr.Done && len(toolCalls) > 0 {
res.ToolCalls = toolCalls
}
} else if thinkingState != nil {
thinking, content := thinkingState.AddContent(cr.Content)
res.Thinking = thinking
@ -574,7 +583,7 @@ func (s *Server) GenerateHandler(c *gin.Context) {
if builtinParser != nil {
// only send messages with meaningful content (empty messages confuse clients)
if res.Response != "" || res.Thinking != "" || res.Done || len(res.ToolCalls) > 0 {
if res.Response != "" || res.Thinking != "" || res.Done {
ch <- res
}
@ -1517,6 +1526,10 @@ func (s *Server) GenerateRoutes(rc *ollama.Registry) (http.Handler, error) {
r.POST("/api/show", s.ShowHandler)
r.DELETE("/api/delete", s.DeleteHandler)
// MCP Tools discovery
r.GET("/api/tools", s.ToolsHandler)
r.POST("/api/tools", s.ToolsHandler)
r.POST("/api/me", s.WhoamiHandler)
r.POST("/api/signout", s.SignoutHandler)
@ -1861,6 +1874,211 @@ func toolCallId() string {
return "call_" + strings.ToLower(string(b))
}
// executeCompletionWithTools executes a completion and collects the full response
// This is a synchronous wrapper around the async completion callback
// When suppressDone is true, the Done flag is not sent to the client channel
// (used for intermediate rounds in multi-round tool execution)
func (s *Server) executeCompletionWithTools(
ctx context.Context,
r llm.LlamaServer,
prompt string,
images []llm.ImageData,
opts *api.Options,
req api.ChatRequest,
m *Model,
builtinParser parsers.Parser,
thinkingState *thinking.Parser,
ch chan any,
checkpointStart time.Time,
checkpointLoaded time.Time,
truncate bool,
suppressDone bool,
) (*CompletionResult, error) {
result := &CompletionResult{}
done := make(chan error, 1)
// For tracking tool calls when using tools
var toolParser *tools.Parser
if len(req.Tools) > 0 && builtinParser == nil {
toolParser = tools.NewParser(m.Template.Template, req.Tools)
}
// Track thinking content for structured outputs
var thinkingBuilder strings.Builder
// Accumulate tool calls across streaming chunks
var accumulatedToolCalls []api.ToolCall
// Create a new context for this completion
completionCtx, cancel := context.WithCancel(ctx)
defer cancel()
err := r.Completion(completionCtx, llm.CompletionRequest{
Prompt: prompt,
Images: images,
Format: req.Format,
Options: opts,
Shift: req.Shift == nil || *req.Shift,
Truncate: truncate,
Logprobs: req.Logprobs,
TopLogprobs: req.TopLogprobs,
}, func(resp llm.CompletionResponse) {
// When suppressDone is true, don't signal Done to client
// (used for intermediate rounds in multi-round tool execution)
clientDone := resp.Done && !suppressDone
res := api.ChatResponse{
Model: req.Model,
CreatedAt: time.Now().UTC(),
Message: api.Message{Role: "assistant", Content: resp.Content},
Done: clientDone,
Metrics: api.Metrics{
PromptEvalCount: resp.PromptEvalCount,
PromptEvalDuration: resp.PromptEvalDuration,
EvalCount: resp.EvalCount,
EvalDuration: resp.EvalDuration,
},
Logprobs: toAPILogprobs(resp.Logprobs),
}
if resp.Done {
res.DoneReason = resp.DoneReason.String()
res.TotalDuration = time.Since(checkpointStart)
res.LoadDuration = checkpointLoaded.Sub(checkpointStart)
result.DoneReason = res.DoneReason
result.Metrics = res.Metrics
}
// Handle builtin parser (for models with native tool support)
if builtinParser != nil {
content, thinking, toolCalls, err := builtinParser.Add(resp.Content, resp.Done)
if err != nil {
result.Error = err
done <- err
return
}
res.Message.Content = content
res.Message.Thinking = thinking
res.Message.ToolCalls = toolCalls
thinkingBuilder.WriteString(thinking)
// Accumulate results
result.Content += content
result.Thinking += thinking
// Accumulate tool calls for multi-round MCP execution
if len(toolCalls) > 0 {
accumulatedToolCalls = append(accumulatedToolCalls, toolCalls...)
}
// On completion, set all accumulated tool calls
if resp.Done {
result.ToolCalls = accumulatedToolCalls
}
// Stream to client if there's content to stream
if res.Message.Content != "" || res.Message.Thinking != "" || len(res.Message.ToolCalls) > 0 || resp.Done || len(res.Logprobs) > 0 {
ch <- res
}
if resp.Done {
result.Done = true
done <- nil
}
return
}
// Handle thinking state parser
if thinkingState != nil {
thinkingContent, remainingContent := thinkingState.AddContent(res.Message.Content)
if thinkingContent == "" && remainingContent == "" && !resp.Done {
// Need more content to decide
return
}
res.Message.Thinking = thinkingContent
thinkingBuilder.WriteString(thinkingContent)
res.Message.Content = remainingContent
result.Thinking += thinkingContent
}
// Handle tool parsing (for models without native tool support)
if len(req.Tools) > 0 && builtinParser == nil {
toolCalls, content := toolParser.Add(res.Message.Content)
if len(content) > 0 {
res.Message.Content = content
result.Content += content
} else if len(toolCalls) > 0 {
res.Message.ToolCalls = toolCalls
res.Message.Content = ""
// Keep accumulating tool calls
accumulatedToolCalls = toolCalls
} else if res.Message.Thinking != "" {
// don't return, fall through to send
} else {
// Send logprobs while content is being buffered by the parser for tool calls
if len(res.Logprobs) > 0 && !resp.Done {
logprobRes := res
logprobRes.Message.Content = ""
logprobRes.Message.ToolCalls = nil
ch <- logprobRes
}
if resp.Done {
res.Message.Content = toolParser.Content()
// Set accumulated tool calls in result before signaling done
if len(accumulatedToolCalls) > 0 {
result.ToolCalls = accumulatedToolCalls
}
// If no tool calls, get final content from parser
if len(result.ToolCalls) == 0 && toolParser != nil {
result.Content = toolParser.Content()
}
result.Done = true
ch <- res
done <- nil
}
return
}
} else {
result.Content += res.Message.Content
}
// Stream to client
ch <- res
if resp.Done {
// If we accumulated tool calls, set them in result
if len(accumulatedToolCalls) > 0 {
result.ToolCalls = accumulatedToolCalls
}
// If no tool calls, get final content from parser
if len(result.ToolCalls) == 0 && toolParser != nil {
result.Content = toolParser.Content()
}
result.Done = true
done <- nil
}
})
if err != nil {
return nil, err
}
// Wait for completion or context cancellation
select {
case err := <-done:
if err != nil {
return nil, err
}
return result, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (s *Server) ChatHandler(c *gin.Context) {
checkpointStart := time.Now()
@ -2027,6 +2245,80 @@ func (s *Server) ChatHandler(c *gin.Context) {
}
}
// =========================================================================
// MCP (Model Context Protocol) Integration
// =========================================================================
//
// MCP allows the model to execute external tools via JSON-RPC servers.
// This section handles:
// 1. Manager initialization (from session cache or new)
// 2. Tool discovery (list available tools from MCP servers)
// 3. Context injection (inform model about available tools)
// 4. Parser configuration (for tool call detection)
//
// Entry points:
// - req.MCPServers: Explicit server configs from API
// - req.ToolsPath: Path-based auto-enable from --tools flag
//
// See: mcp.go, mcp_manager.go for implementation details
// =========================================================================
var mcpManager *MCPManager
if len(req.MCPServers) > 0 || req.ToolsPath != "" {
if req.ToolsPath != "" {
// Path-based mode: auto-enable servers matching the tools path
// Used by CLI: `ollama run model --tools /path`
slog.Debug("Using tools path for MCP manager", "tools_path", req.ToolsPath, "model", req.Model)
mcpManager, err = GetMCPManagerForPath(req.Model, req.ToolsPath)
if err != nil {
slog.Error("Failed to get MCP manager for tools path", "error", err)
// Continue without MCP - graceful degradation
}
} else if len(req.MCPServers) > 0 {
// Explicit mode: use server configs from API request
// Used by API: POST /api/chat with mcp_servers field
sessionID := GenerateSessionID(req)
slog.Debug("Getting MCP manager", "session", sessionID, "servers", len(req.MCPServers))
mcpManager, err = GetMCPManager(sessionID, req.MCPServers)
if err != nil {
slog.Error("Failed to get MCP manager", "error", err)
// Continue without MCP - graceful degradation
}
}
if mcpManager != nil {
// Step 1: Discover tools from MCP servers and add to request
mcpTools := mcpManager.GetAllTools()
req.Tools = append(req.Tools, mcpTools...)
// Step 2: Inject context to help model use tools effectively
// Use programmatic context injection from tool schemas
codeAPI := NewMCPCodeAPI(mcpManager)
req.Messages = codeAPI.InjectContextIntoMessages(req.Messages, req.MCPServers)
// Step 3: Auto-configure parser for tool call detection
if len(req.Tools) > 0 && m.Config.Parser == "" {
if m.Config.ModelFamily == "qwen2" || m.Config.ModelFamily == "qwen3" {
m.Config.Parser = "qwen3-vl-instruct"
}
}
// Step 4: Update capabilities now that we have tools
if len(req.Tools) > 0 && !slices.Contains(caps, model.CapabilityTools) {
caps = append(caps, model.CapabilityTools)
}
}
// Cleanup: Close MCP manager when request completes
// Note: Session manager may cache for reuse within TTL
defer func() {
if err := mcpManager.Close(); err != nil {
slog.Warn("Error closing MCP manager", "error", err)
}
}()
}
r, m, opts, err := s.scheduleRunner(c.Request.Context(), name.String(), caps, req.Options, req.KeepAlive)
if errors.Is(err, errCapabilityCompletion) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("%q does not support chat", req.Model)})
@ -2115,11 +2407,6 @@ func (s *Server) ChatHandler(c *gin.Context) {
}
}
var toolParser *tools.Parser
if len(req.Tools) > 0 && (builtinParser == nil || !builtinParser.HasToolSupport()) {
toolParser = tools.NewParser(m.Template.Template, req.Tools)
}
type structuredOutputsState int
const (
structuredOutputsState_None structuredOutputsState = iota
@ -2131,181 +2418,223 @@ func (s *Server) ChatHandler(c *gin.Context) {
go func() {
defer close(ch)
structuredOutputsState := structuredOutputsState_None
// Initialize for multi-round execution
// NOTE: Upstream's structuredOutputsState for thinking models is not yet integrated
// TODO: Add structuredOutputsState support for thinking models with format constraints
currentMsgs := msgs
maxRounds := req.MaxToolRounds
if maxRounds == 0 {
maxRounds = 15 // Default maximum rounds
}
for {
var tb strings.Builder
slog.Debug("Starting multi-round execution",
"mcpManager", mcpManager != nil,
"tools_count", len(req.Tools),
"max_rounds", maxRounds)
currentFormat := req.Format
// structured outputs via double request is enabled when:
// 1. the model supports the thinking capability and
// 2. it uses a built-in parser or our generic thinking parser
// MAIN LOOP - Multi-round execution for tool calling
var round int
for round = 0; round < maxRounds; round++ {
slog.Debug("Starting round", "round", round, "messages", len(currentMsgs))
// Note that the current approach does not work for (potential future)
// non-thinking models that emit anything before actual content. This
// current approach uses the transition from parsed thinking content to
// parsed non-thinking content as the signal to turn constraining on
if req.Format != nil && structuredOutputsState == structuredOutputsState_None && ((builtinParser != nil || thinkingState != nil) && slices.Contains(m.Capabilities(), model.CapabilityThinking)) {
currentFormat = nil
}
// sets up new context given parent context per request
ctx, cancel := context.WithCancel(c.Request.Context())
err := r.Completion(ctx, llm.CompletionRequest{
Prompt: prompt,
Images: images,
Format: currentFormat,
Options: opts,
Shift: req.Shift == nil || *req.Shift,
Truncate: truncate,
Logprobs: req.Logprobs,
TopLogprobs: req.TopLogprobs,
}, func(r llm.CompletionResponse) {
res := api.ChatResponse{
Model: req.Model,
CreatedAt: time.Now().UTC(),
Message: api.Message{Role: "assistant", Content: r.Content},
Done: r.Done,
Metrics: api.Metrics{
PromptEvalCount: r.PromptEvalCount,
PromptEvalDuration: r.PromptEvalDuration,
EvalCount: r.EvalCount,
EvalDuration: r.EvalDuration,
},
Logprobs: toAPILogprobs(r.Logprobs),
}
if r.Done {
res.DoneReason = r.DoneReason.String()
res.TotalDuration = time.Since(checkpointStart)
res.LoadDuration = checkpointLoaded.Sub(checkpointStart)
}
if builtinParser != nil {
slog.Log(context.TODO(), logutil.LevelTrace, "builtin parser input", "parser", m.Config.Parser, "content", r.Content)
content, thinking, toolCalls, err := builtinParser.Add(r.Content, r.Done)
if err != nil {
ch <- gin.H{"error": err.Error()}
return
}
res.Message.Content = content
res.Message.Thinking = thinking
for i := range toolCalls {
toolCalls[i].ID = toolCallId()
}
res.Message.ToolCalls = toolCalls
tb.WriteString(thinking)
// we are now receiving content from the model - we should start applying structured outputs
if structuredOutputsState == structuredOutputsState_None && req.Format != nil && tb.String() != "" && res.Message.Content != "" {
structuredOutputsState = structuredOutputsState_ReadyToApply
cancel()
return
}
if res.Message.Content != "" || res.Message.Thinking != "" || len(res.Message.ToolCalls) > 0 || r.Done || len(res.Logprobs) > 0 {
slog.Log(context.TODO(), logutil.LevelTrace, "builtin parser output", "parser", m.Config.Parser, "content", content, "thinking", thinking, "toolCalls", toolCalls, "done", r.Done)
ch <- res
} else {
slog.Log(context.TODO(), logutil.LevelTrace, "builtin parser empty output", "parser", m.Config.Parser)
}
return
}
if thinkingState != nil {
thinkingContent, remainingContent := thinkingState.AddContent(res.Message.Content)
if thinkingContent == "" && remainingContent == "" && !r.Done {
// need to accumulate more to decide what to send
return
}
res.Message.Thinking = thinkingContent
tb.WriteString(thinkingContent)
// emit the collected thinking text before restarting with structured outputs and clear unstructured content
// to avoid leaking mixed tokens like "</think>Hello"
if structuredOutputsState == structuredOutputsState_None && req.Format != nil && tb.String() != "" && remainingContent != "" {
structuredOutputsState = structuredOutputsState_ReadyToApply
res.Message.Content = ""
ch <- res
cancel()
return
}
res.Message.Content = remainingContent
}
if len(req.Tools) > 0 {
toolCalls, content := toolParser.Add(res.Message.Content)
if len(content) > 0 {
res.Message.Content = content
} else if len(toolCalls) > 0 {
for i := range toolCalls {
toolCalls[i].ID = toolCallId()
}
res.Message.ToolCalls = toolCalls
res.Message.Content = ""
} else if res.Message.Thinking != "" {
// don't return, fall through to send
} else {
// Send logprobs while content is being buffered by the parser for tool calls
if len(res.Logprobs) > 0 && !r.Done {
logprobRes := res
logprobRes.Message.Content = ""
logprobRes.Message.ToolCalls = nil
ch <- logprobRes
}
if r.Done {
res.Message.Content = toolParser.Content()
ch <- res
}
return
}
}
ch <- res
})
if err != nil {
if structuredOutputsState == structuredOutputsState_ReadyToApply && strings.Contains(err.Error(), "context canceled") && c.Request.Context().Err() == nil {
// only ignores error if it's a context cancellation due to setting structured outputs
} else {
var serr api.StatusError
if errors.As(err, &serr) {
ch <- gin.H{"error": serr.ErrorMessage, "status": serr.StatusCode}
} else {
ch <- gin.H{"error": err.Error()}
}
return
}
}
// ignored structured outputs cancellation falls through to here, start a new request with the structured outputs and updated prompt. use the
if structuredOutputsState == structuredOutputsState_ReadyToApply {
structuredOutputsState = structuredOutputsState_Applying
msg := api.Message{
Role: "assistant",
Thinking: tb.String(),
}
msgs = append(msgs, msg)
prompt, _, err = chatPrompt(c.Request.Context(), m, r.Tokenize, opts, msgs, processedTools, req.Think, truncate)
// Re-render prompt and reset parser if not first round (tool results were added)
if round > 0 {
var err error
prompt, images, err = chatPrompt(c.Request.Context(), m, r.Tokenize, opts, currentMsgs, processedTools, req.Think, truncate)
if err != nil {
slog.Error("chat prompt error applying structured outputs", "error", err)
slog.Error("Failed to render prompt in round", "round", round, "error", err)
ch <- gin.H{"error": err.Error()}
return
}
// force constraining by terminating thinking header, the parser is already at this state
// when the last message is thinking, the rendered for gpt-oss cannot disambiguate between having the
// model continue thinking or ending thinking and outputting the final message.
// TODO(parthsareen): consider adding prefill disambiguation logic to the renderer for structured outputs.
if shouldUseHarmony(m) || (builtinParser != nil && m.Config.Parser == "harmony") {
prompt += "<|end|><|start|>assistant<|channel|>final<|message|>"
// Create fresh parser instance for new round (parser has internal buffer state)
if builtinParser != nil && m.Config.Parser != "" {
builtinParser = parsers.ParserForName(m.Config.Parser)
if builtinParser != nil {
lastMsg := &currentMsgs[len(currentMsgs)-1]
builtinParser.Init(req.Tools, lastMsg, req.Think)
}
}
continue
}
break
// Execute completion and collect full response
// When MCP is enabled, suppress Done flag during intermediate rounds
// to prevent client from closing connection prematurely
suppressDone := mcpManager != nil
completionResult, err := s.executeCompletionWithTools(
c.Request.Context(),
r,
prompt,
images,
opts,
req,
m,
builtinParser,
thinkingState,
ch,
checkpointStart,
checkpointLoaded,
truncate,
suppressDone,
)
if err != nil {
slog.Error("Completion failed", "round", round, "error", err)
var serr api.StatusError
if errors.As(err, &serr) {
ch <- gin.H{"error": serr.ErrorMessage, "status": serr.StatusCode}
} else {
ch <- gin.H{"error": err.Error()}
}
return
}
// Check if model called tools
if len(completionResult.ToolCalls) == 0 {
// No tools called - conversation is complete
slog.Debug("No tools called, conversation complete", "round", round)
break // Exit the loop - we're done
}
// Validate tool calls are not empty or malformed
validToolCalls := 0
for _, tc := range completionResult.ToolCalls {
if tc.Function.Name != "" {
validToolCalls++
} else {
slog.Warn("Invalid tool call detected", "round", round, "tool", tc)
}
}
if validToolCalls == 0 {
slog.Warn("No valid tool calls found, exiting", "round", round)
break
}
// Model called tools - execute them if we have an MCP manager
if mcpManager != nil {
slog.Debug("MCP tool execution starting",
"tools_in_response", len(completionResult.ToolCalls),
"valid_tools", validToolCalls,
"round", round)
// Send tool calls to client for display BEFORE executing
// This ensures the client can show "Executing tool..." for all rounds
// Note: Don't include Content here - it was already streamed during completion
ch <- api.ChatResponse{
Model: req.Model,
Message: api.Message{
Role: "assistant",
ToolCalls: completionResult.ToolCalls,
},
}
// Analyze execution plan
executionPlan := mcpManager.AnalyzeExecutionPlan(completionResult.ToolCalls)
slog.Debug("Execution plan determined",
"sequential", executionPlan.RequiresSequential,
"reason", executionPlan.Reason)
// Execute tools according to plan
results := mcpManager.ExecuteWithPlan(completionResult.ToolCalls, executionPlan)
// Log tool calls for debugging
for i, tc := range completionResult.ToolCalls {
slog.Info("Tool call details",
"round", round,
"index", i,
"name", tc.Function.Name,
"arguments", tc.Function.Arguments)
}
// Add assistant message with tool calls
assistantMsg := api.Message{
Role: "assistant",
Content: completionResult.Content, // Preserve any content
ToolCalls: completionResult.ToolCalls,
}
currentMsgs = append(currentMsgs, assistantMsg)
// Add tool result messages and send them to client for display
toolResultsForDisplay := make([]api.ToolResult, 0, len(results))
for i, result := range results {
toolMsg := api.Message{
Role: "tool",
ToolName: completionResult.ToolCalls[i].Function.Name,
}
// Create display result with arguments for context
displayResult := api.ToolResult{
ToolName: completionResult.ToolCalls[i].Function.Name,
Arguments: completionResult.ToolCalls[i].Function.Arguments,
Content: result.Content,
}
if result.Error != nil {
// JSON-encode the error for proper template rendering
if encoded, err := json.Marshal(fmt.Sprintf("Error: %v", result.Error)); err == nil {
toolMsg.Content = string(encoded)
} else {
toolMsg.Content = fmt.Sprintf("\"Error: %v\"", result.Error)
}
displayResult.Error = result.Error.Error()
slog.Warn("Tool execution failed",
"tool", completionResult.ToolCalls[i].Function.Name,
"error", result.Error)
} else {
// JSON-encode the content for proper template rendering
// The template expects {"content": {{ .Content }}} where Content should be a JSON string
if encoded, err := json.Marshal(result.Content); err == nil {
toolMsg.Content = string(encoded)
} else {
toolMsg.Content = result.Content
}
}
currentMsgs = append(currentMsgs, toolMsg)
toolResultsForDisplay = append(toolResultsForDisplay, displayResult)
}
// Send tool results to client for display
if len(toolResultsForDisplay) > 0 {
ch <- api.ChatResponse{
Model: req.Model,
Message: api.Message{
Role: "assistant",
ToolResults: toolResultsForDisplay,
},
}
}
// Continue to next round - model will process tool results
slog.Info("Tools executed, continuing to next round",
"round", round,
"messages", len(currentMsgs),
"last_tool", completionResult.ToolCalls[len(completionResult.ToolCalls)-1].Function.Name)
} else {
// No MCP manager - send tool calls to client for external execution
slog.Debug("No MCP manager, sending tool calls to client", "round", round)
break // Exit - client will handle tool execution
}
} // End of maxRounds loop
// Check if we exhausted rounds
if round >= maxRounds {
slog.Warn("Maximum tool execution rounds reached", "rounds", maxRounds)
ch <- gin.H{"error": fmt.Sprintf("Maximum tool execution rounds (%d) exceeded", maxRounds)}
}
// When MCP was enabled, we suppressed Done flags during the loop
// Send a final Done: true to signal the conversation is complete
if mcpManager != nil {
ch <- api.ChatResponse{
Model: req.Model,
CreatedAt: time.Now().UTC(),
Message: api.Message{Role: "assistant"},
Done: true,
DoneReason: "stop",
}
}
}()
@ -2331,22 +2660,15 @@ func (s *Server) ChatHandler(c *gin.Context) {
case gin.H:
msg, ok := t["error"].(string)
if !ok {
msg = "unexpected error format in response"
msg = "unexpected error"
}
status, ok := t["status"].(int)
if !ok {
status = http.StatusInternalServerError
}
c.JSON(status, gin.H{"error": msg})
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "unexpected response"})
return
}
}
resp.Message.Content = sbContent.String()
resp.Message.Thinking = sbThinking.String()
resp.Logprobs = allLogprobs
@ -2354,12 +2676,10 @@ func (s *Server) ChatHandler(c *gin.Context) {
if len(toolCalls) > 0 {
resp.Message.ToolCalls = toolCalls
}
c.JSON(http.StatusOK, resp)
return
} else {
streamResponse(c, ch)
}
streamResponse(c, ch)
}
func handleScheduleError(c *gin.Context, name string, err error) {

91
server/routes_tools.go Normal file
View File

@ -0,0 +1,91 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ollama/ollama/api"
)
// ToolsHandler handles requests to list available MCP tools.
// GET: Returns available MCP server definitions from configuration.
// POST with mcp_servers: Returns tools from the specified MCP servers.
func (s *Server) ToolsHandler(c *gin.Context) {
var req struct {
MCPServers []api.MCPServerConfig `json:"mcp_servers,omitempty"`
}
if c.Request.Method == "POST" {
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
// If MCP servers provided, list their tools
if len(req.MCPServers) > 0 {
manager := NewMCPManager(10)
defer manager.Close()
var allTools []ToolInfo
for _, config := range req.MCPServers {
if err := manager.AddServer(config); err != nil {
// Include error in response but continue
allTools = append(allTools, ToolInfo{
Name: config.Name,
Description: "Failed to initialize: " + err.Error(),
Error: err.Error(),
})
continue
}
// Get tools from this server
tools := manager.GetAllTools()
for _, tool := range tools {
allTools = append(allTools, ToolInfo{
Name: tool.Function.Name,
Description: tool.Function.Description,
Parameters: &tool.Function.Parameters,
ServerName: config.Name,
})
}
}
c.JSON(http.StatusOK, ToolsResponse{
Tools: allTools,
})
return
}
// Otherwise, list available MCP server definitions
defs, err := LoadMCPDefinitions()
if err != nil {
// Config parsing errors are client errors (bad config), not server errors
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid MCP configuration: " + err.Error()})
return
}
servers := defs.ListServers()
c.JSON(http.StatusOK, MCPServersResponse{
Servers: servers,
})
}
// ToolInfo provides information about a single tool
type ToolInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters *api.ToolFunctionParameters `json:"parameters,omitempty"`
ServerName string `json:"server,omitempty"`
Error string `json:"error,omitempty"`
}
// ToolsResponse contains the list of available tools
type ToolsResponse struct {
Tools []ToolInfo `json:"tools"`
}
// MCPServersResponse contains the list of available MCP server types
type MCPServersResponse struct {
Servers []MCPServerInfo `json:"servers"`
}