Skip to content

Commit 33e4fea

Browse files
committed
feat: add configurable max tool rounds and limit handling (#31)
- Add --max-tool-rounds CLI option and MAX_TOOL_ROUNDS env var support - Pass max_tool_rounds into app state and MCPManager - Enforce validation in validate_cli_inputs (must be >= 1) - Implement round limiting in streaming and non-streaming proxy flows (including final LLM call/stream when limit reached) - Add _make_final_llm_call and _stream_final_llm_call helpers - Update README with usage and docs for max tool rounds - Add unit test for max_tool_rounds validation"
1 parent 13f0827 commit 33e4fea

File tree

6 files changed

+102
-12
lines changed

6 files changed

+102
-12
lines changed

README.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@
4545
- 🚀 **Pre-loaded Servers**: All MCP servers are connected at startup from JSON configuration
4646
- 📝 **JSON Configuration**: Configure multiple servers with complex commands and environments
4747
- 🔗 **Tool Integration**: Automatic tool call processing and response integration
48-
-**Multi-Round Tool Execution**: Automatically loops through multiple rounds of tool calls until completion
49-
- �🛠️ **All Tools Available**: Ollama can use any tool from any connected server simultaneously
48+
- 🔄 **Multi-Round Tool Execution**: Automatically loops through multiple rounds of tool calls until completion
49+
- 🛡️ **Configurable Tool Limits**: Set maximum tool execution rounds to prevent excessive tool calls
50+
- 🛠️ **All Tools Available**: Ollama can use any tool from any connected server simultaneously
5051
- 🔌 **Complete API Compatibility**: `/api/chat` adds tools while all other Ollama API endpoints are transparently proxied
5152
- 🔧 **Configurable Ollama**: Specify custom Ollama server URL via CLI (supports local and cloud models)
5253
- ☁️ **Cloud Model Support**: Works with Ollama cloud models
@@ -193,12 +194,16 @@ CORS_ORIGINS="http://localhost:3000,http://localhost:8080,https://app.example.co
193194
```
194195

195196
**Environment Variables:**
197+
- `CORS_ORIGINS`: Comma-separated list of allowed origins (default: `*`)
198+
- `*` allows all origins (shows warning in logs)
199+
- Example: `CORS_ORIGINS="http://localhost:3000,https://myapp.com" ollama-mcp-bridge`
200+
- `MAX_TOOL_ROUNDS`: Maximum number of tool execution rounds (default: unlimited)
201+
- Can be overridden with `--max-tool-rounds` CLI parameter (CLI takes precedence)
202+
- Example: `MAX_TOOL_ROUNDS=5 ollama-mcp-bridge`
196203
- `OLLAMA_URL`: URL of the Ollama server (default: `http://localhost:11434`)
197204
- Can be overridden with `--ollama-url` CLI parameter
198205
- Useful for Docker deployments and configuration management
199-
- `CORS_ORIGINS`: Comma-separated list of allowed origins (default: `*`)
200-
- `*` allows all origins (shows warning in logs)
201-
- Specific origins like `http://localhost:3000,https://myapp.com` for production
206+
- Example: `OLLAMA_URL=http://192.168.1.100:11434 ollama-mcp-bridge`
202207

203208
**CORS Logging:**
204209
- The bridge logs CORS configuration at startup
@@ -227,8 +232,11 @@ ollama-mcp-bridge --host 0.0.0.0 --port 8080
227232
# Custom Ollama server URL (local or cloud)
228233
ollama-mcp-bridge --ollama-url http://192.168.1.100:11434
229234

235+
# Limit tool execution rounds (prevents excessive tool calls)
236+
ollama-mcp-bridge --max-tool-rounds 5
237+
230238
# Combine options
231-
ollama-mcp-bridge --config custom.json --host 0.0.0.0 --port 8080 --ollama-url http://remote-ollama:11434
239+
ollama-mcp-bridge --config custom.json --host 0.0.0.0 --port 8080 --ollama-url http://remote-ollama:11434 --max-tool-rounds 10
232240

233241
# Check version and available updates
234242
ollama-mcp-bridge --version
@@ -245,6 +253,8 @@ ollama-mcp-bridge --version
245253
- `--host`: Host to bind the server (default: `0.0.0.0`)
246254
- `--port`: Port to bind the server (default: `8000`)
247255
- `--ollama-url`: Ollama server URL (default: `http://localhost:11434`)
256+
- `--max-tool-rounds`: Maximum tool execution rounds (default: unlimited, can also be set via `MAX_TOOL_ROUNDS` environment variable)
257+
- `--reload`: Enable auto-reload during development
248258
- `--version`: Show version information, check for updates and exit
249259

250260
### API Usage

src/ollama_mcp_bridge/lifecycle.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ async def lifespan(fastapi_app: FastAPI):
2323
# Get config from app state with explicit defaults
2424
config_file = getattr(fastapi_app.state, 'config_file', 'mcp-config.json')
2525
ollama_url = getattr(fastapi_app.state, 'ollama_url', 'http://localhost:11434')
26+
max_tool_rounds = getattr(fastapi_app.state, 'max_tool_rounds', None)
2627

27-
logger.info(f"Starting with config file: {config_file}, Ollama URL: {ollama_url}")
28+
logger.info(f"Starting with config file: {config_file}, Ollama URL: {ollama_url}, Max tool rounds: {max_tool_rounds if max_tool_rounds else 'unlimited'}")
2829

2930
# Initialize manager and load servers
3031
mcp_manager = MCPManager(ollama_url=ollama_url)
32+
mcp_manager.max_tool_rounds = max_tool_rounds
3133
await mcp_manager.load_servers(config_file)
3234

3335
# Initialize services

src/ollama_mcp_bridge/main.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Simple CLI entry point for MCP Proxy"""
2-
import asyncio
32
import os
3+
import asyncio
44
import typer
55
import uvicorn
66
from loguru import logger
7+
from typing import Optional
78

89
from .api import app
910
from .utils import check_ollama_health, check_for_updates, validate_cli_inputs
@@ -14,6 +15,7 @@ def cli_app(
1415
host: str = typer.Option("0.0.0.0", "--host", help="Host to bind to"),
1516
port: int = typer.Option(8000, "--port", help="Port to bind to"),
1617
ollama_url: str = typer.Option(os.getenv("OLLAMA_URL", "http://localhost:11434"), "--ollama-url", help="Ollama server URL"),
18+
max_tool_rounds: Optional[int] = typer.Option(os.getenv("MAX_TOOL_ROUNDS", None), "--max-tool-rounds", help="Maximum tool execution rounds (default: unlimited)"),
1719
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
1820
version: bool = typer.Option(False, "--version", help="Show version information, check for updates and exit"),
1921
):
@@ -23,10 +25,12 @@ def cli_app(
2325
# Check for updates and print if available
2426
asyncio.run(check_for_updates(__version__, print_message=True))
2527
raise typer.Exit(0)
26-
validate_cli_inputs(config, host, port, ollama_url)
28+
validate_cli_inputs(config, host, port, ollama_url, max_tool_rounds)
29+
2730
# Store config in app state so lifespan can access it
2831
app.state.config_file = config
2932
app.state.ollama_url = ollama_url
33+
app.state.max_tool_rounds = max_tool_rounds
3034

3135
logger.info(f"Starting MCP proxy server on {host}:{port}")
3236
logger.info(f"Using Ollama server: {ollama_url}")

src/ollama_mcp_bridge/proxy_service.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,36 @@ async def proxy_chat_with_tools(self, payload: Dict[str, Any], stream: bool = Fa
5858
logger.error(f"Chat proxy failed: {e}")
5959
raise
6060

61+
async def _make_final_llm_call(self, endpoint: str, payload: Dict[str, Any], messages: list) -> Dict[str, Any]:
62+
"""Make a final LLM call without tools to get final answer after tool execution"""
63+
final_payload = dict(payload)
64+
final_payload["messages"] = messages
65+
final_payload["tools"] = None # Don't allow more tool calls
66+
resp = await self.http_client.post(f"{self.mcp_manager.ollama_url}{endpoint}", json=final_payload)
67+
resp.raise_for_status()
68+
return resp.json()
69+
70+
async def _stream_final_llm_call(self, stream_ollama, payload: Dict[str, Any], messages: list) -> AsyncGenerator[bytes, None]:
71+
"""Stream a final LLM call without tools to get final answer after tool execution"""
72+
final_payload = dict(payload)
73+
final_payload["messages"] = messages
74+
final_payload["tools"] = None # Don't allow more tool calls
75+
76+
ndjson_iter = iter_ndjson_chunks(stream_ollama(final_payload))
77+
async for json_obj in ndjson_iter:
78+
buffer_chunk = json.dumps(json_obj).encode() + b"\n"
79+
yield buffer_chunk
80+
6181
async def _proxy_with_tools_non_streaming(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
6282
"""Handle non-streaming chat requests with tools"""
6383
payload = dict(payload)
6484
payload["tools"] = self.mcp_manager.all_tools if self.mcp_manager.all_tools else None
6585
messages = payload.get("messages") or []
6686

87+
# Get max tool rounds from app state (None means unlimited)
88+
max_rounds = getattr(self.mcp_manager, 'max_tool_rounds', None)
89+
current_round = 0
90+
6791
# Loop to handle potentially multiple rounds of tool calls
6892
while True:
6993
# Call Ollama
@@ -85,6 +109,13 @@ async def _proxy_with_tools_non_streaming(self, endpoint: str, payload: Dict[str
85109

86110
# Execute tool calls and add results to messages
87111
messages = await self._handle_tool_calls(messages, tool_calls)
112+
113+
# Check if we've reached the maximum number of rounds
114+
current_round += 1
115+
if max_rounds is not None and current_round >= max_rounds:
116+
logger.warning(f"Reached maximum tool execution rounds ({max_rounds}), making final LLM call with tool results")
117+
return await self._make_final_llm_call(endpoint, payload, messages)
118+
88119
# Continue loop to get next response
89120

90121
async def _proxy_with_tools_streaming(self, endpoint: str, payload: Dict[str, Any]) -> AsyncGenerator[bytes, None]:
@@ -100,6 +131,10 @@ async def stream_ollama(payload_to_send):
100131
async for chunk in resp.aiter_bytes():
101132
yield chunk
102133

134+
# Get max tool rounds from app state (None means unlimited)
135+
max_rounds = getattr(self.mcp_manager, 'max_tool_rounds', None)
136+
current_round = 0
137+
103138
# Loop to handle potentially multiple rounds of tool calls
104139
while True:
105140
current_payload = dict(payload)
@@ -128,14 +163,23 @@ async def stream_ollama(payload_to_send):
128163
# No tool calls required, streaming complete
129164
break
130165

131-
# Tool calls detected; execute them and loop for the follow-up response
166+
# Tool calls detected; execute them
132167
messages.append({
133168
"role": "assistant",
134169
"content": response_text,
135170
"tool_calls": tool_calls
136171
})
137172
messages = await self._handle_tool_calls(messages, tool_calls)
138173

174+
# Check if we've reached the maximum number of rounds
175+
current_round += 1
176+
if max_rounds is not None and current_round >= max_rounds:
177+
logger.warning(f"Reached maximum tool execution rounds ({max_rounds}), making final LLM call with tool results")
178+
# Stream the final LLM response with tool results (no more tools allowed)
179+
async for chunk in self._stream_final_llm_call(stream_ollama, payload, messages):
180+
yield chunk
181+
break
182+
139183
def _extract_tool_calls(self, result: Dict[str, Any]) -> list:
140184
"""Extract tool calls from response"""
141185
tool_calls = result.get("message", {}).get("tool_calls", [])

src/ollama_mcp_bridge/utils.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ async def iter_ndjson_chunks(chunk_iterator):
8080
except json.JSONDecodeError as e:
8181
logger.debug(f"Error parsing trailing NDJSON: {e}")
8282

83-
def validate_cli_inputs(config: str, host: str, port: int, ollama_url: str):
84-
"""Validate CLI inputs for config file, host, port, and ollama_url."""
83+
def validate_cli_inputs(config: str, host: str, port: int, ollama_url: str, max_tool_rounds: int = None):
84+
"""Validate CLI inputs for config file, host, port, ollama_url, and max_tool_rounds."""
8585
# Validate config file exists
8686
if not os.path.isfile(config):
8787
raise BadParameter(f"Config file not found: {config}")
@@ -99,6 +99,10 @@ def validate_cli_inputs(config: str, host: str, port: int, ollama_url: str):
9999
if not url_pattern.match(ollama_url):
100100
raise BadParameter(f"Invalid Ollama URL: {ollama_url}")
101101

102+
# Validate max_tool_rounds
103+
if max_tool_rounds is not None and max_tool_rounds < 1:
104+
raise BadParameter(f"max_tool_rounds must be at least 1, got {max_tool_rounds}")
105+
102106
async def check_for_updates(current_version: str, print_message: bool = False) -> str:
103107
"""
104108
Check if a newer version of ollama-mcp-bridge is available on PyPI.

tests/test_unit.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,32 @@ def test_example_config_structure():
131131
assert "args" in server_config
132132
assert isinstance(server_config["args"], list)
133133

134+
def test_validate_cli_max_tool_rounds():
135+
"""Test that validate_cli_inputs enforces max_tool_rounds validation."""
136+
try:
137+
from ollama_mcp_bridge.utils import validate_cli_inputs
138+
except ImportError:
139+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
140+
from ollama_mcp_bridge.utils import validate_cli_inputs
141+
142+
# Valid case: None
143+
validate_cli_inputs("mcp-config.json", "0.0.0.0", 8000, "http://localhost:11434", None)
144+
145+
# Invalid max_tool_rounds (zero)
146+
from typer import BadParameter
147+
try:
148+
validate_cli_inputs("mcp-config.json", "0.0.0.0", 8000, "http://localhost:11434", 0)
149+
assert False, "Expected BadParameter for max_tool_rounds=0"
150+
except BadParameter:
151+
pass
152+
153+
# Invalid max_tool_rounds (negative)
154+
try:
155+
validate_cli_inputs("mcp-config.json", "0.0.0.0", 8000, "http://localhost:11434", -1)
156+
assert False, "Expected BadParameter for max_tool_rounds=-1"
157+
except BadParameter:
158+
pass
159+
134160
def test_script_installed():
135161
try:
136162
result = subprocess.run(["ollama-mcp-bridge", "--help"], check=False)

0 commit comments

Comments
 (0)