Skip to content

Commit 1f57375

Browse files
merge(next): MCP integration, async refactor, user experience improvements, and v2.0 release\n\n- Major: Model Context Protocol (MCP) integration for dynamic external tools/services (compatible with Cursor AI IDE)\n- Async refactor: all tool handlers and agent loop are async\n- User experience: emoji/logging improvements, spinner for MCP loading, graceful shutdown\n- Context: user queries now include current date/time\n- Docs: README and pyproject.toml updated for v2.0 and MCP\n- Many style, bugfix, and maintainability improvements
2 parents 2eecf1e + b973366 commit 1f57375

24 files changed

+450
-87
lines changed

CREATING_TOOLS.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ tool_definition = {
3939
}
4040

4141

42-
def handle_tool_call(input_data):
42+
def handle_call(input_data):
4343
"""
4444
Process the tool call with the given input data.
4545
@@ -76,9 +76,9 @@ TOOLS = [
7676
]
7777

7878
TOOL_HANDLERS = {
79-
"bash": bash.handle_tool_call,
79+
"bash": bash.handle_call,
8080
# ... other handler mappings
81-
"my_tool": my_tool.handle_tool_call, # Add your tool handler here
81+
"my_tool": my_tool.handle_call, # Add your tool handler here
8282
}
8383
```
8484

@@ -143,7 +143,7 @@ Follow these functional programming principles:
143143
Example of a handler with proper error handling:
144144

145145
```python
146-
def handle_tool_call(input_data):
146+
def handle_call(input_data):
147147
try:
148148
# Validate required inputs
149149
if "required_param" not in input_data:
@@ -176,7 +176,7 @@ API_KEY = os.getenv("MY_TOOL_API_KEY")
176176
BASE_URL = os.getenv("MY_TOOL_BASE_URL")
177177

178178
# Then check these in your handler:
179-
def handle_tool_call(input_data):
179+
def handle_call(input_data):
180180
if not API_KEY or not BASE_URL:
181181
return "❌ Missing required environment variables in .env"
182182

@@ -234,7 +234,7 @@ tool_definition = {
234234
}
235235
}
236236

237-
def handle_tool_call(input_data):
237+
def handle_call(input_data):
238238
if not WEATHER_API_KEY:
239239
return "❌ Missing WEATHER_API_KEY environment variable"
240240

README.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
# Agent Loop
22

3-
> **An AI Agent with optional Human-in-the-Loop Safety**
3+
> **An AI Agent with optional Human-in-the-Loop Safety and Model Context Protocol (MCP) integration**
44
55
---
66

77
![Python](https://img.shields.io/badge/Python-3.8%2B-blue?logo=python)
88
![uv](https://img.shields.io/badge/uv-Package_Manager-7c4dff)
99
![Anthropic](https://img.shields.io/badge/Anthropic-API-orange)
1010
![OpenAI](https://img.shields.io/badge/OpenAI-API-green?logo=openai)
11+
![MCP](https://img.shields.io/badge/Tool-MCP-4B8BBE?logo=protocols)
12+
**![Version](https://img.shields.io/badge/version-2.0.0-blue)**
1113

1214
## Tools
1315

@@ -32,6 +34,9 @@
3234
- [Tools](#tools)
3335
- [Overview](#overview)
3436
- [Features](#features)
37+
- [MCP (Model Context Protocol) Integration](#mcp-model-context-protocol-integration)
38+
- [How it works](#how-it-works)
39+
- [Example MCP config](#example-mcp-config)
3540
- [Available Tools](#available-tools)
3641
- [Installation](#installation)
3742
- [Option 1: Using the installation script (Recommended)](#option-1-using-the-installation-script-recommended)
@@ -41,6 +46,7 @@
4146
- [Configuration](#configuration)
4247
- [API Keys and Environment Variables](#api-keys-and-environment-variables)
4348
- [Custom System Prompt](#custom-system-prompt)
49+
- [MCP Server Configuration](#mcp-server-configuration)
4450
- [Usage](#usage)
4551
- [Basic](#basic)
4652
- [Model Selection](#model-selection)
@@ -58,6 +64,7 @@
5864
- **Functional Programming:** Clean, composable, and testable code.
5965
- **DevOps Ready:** Integrates with Bash, Python, Docker, Git, Kubernetes, AWS, and more.
6066
- **Multi-Provider:** Supports both Anthropic Claude and OpenAI GPT models.
67+
- **MCP Integration:** Dynamically loads and uses tools/services from any MCP-compatible server (see below).
6168

6269
---
6370

@@ -68,6 +75,52 @@
6875
- Debug mode for transparency (`--debug`)
6976
- Modular, extensible tool system
7077
- Functional programming style throughout
78+
- **MCP (Model Context Protocol) integration for external tool/service discovery and use**
79+
80+
---
81+
82+
## MCP (Model Context Protocol) Integration
83+
84+
**New in v2.0!**
85+
86+
Agent Loop can now connect to any number of MCP-compatible servers, dynamically discovering and using their services as tools. This means you can:
87+
88+
- Add new capabilities (search, knowledge, automation, etc.) by simply running or configuring an MCP server.
89+
- Use tools from remote or local MCP servers as if they were built-in.
90+
- Aggregate services from multiple sources (e.g., Brave Search, Obsidian, custom servers) in one agent.
91+
92+
> **ℹ️ The MCP server configuration format is identical to that used by [Cursor AI IDE](https://docs.cursor.com/context/model-context-protocol#configuring-mcp-servers).**
93+
> See the [Cursor MCP documentation](https://docs.cursor.com/context/model-context-protocol#configuring-mcp-servers) for more details and advanced options.
94+
95+
### How it works
96+
97+
- On startup, Agent Loop reads your MCP server configuration from `~/.config/agent-loop/mcp.json`.
98+
- For each server, it starts a session and lists available services.
99+
- Each service is registered as a tool (named `<server>-<service>`) and can be called by the agent or user.
100+
- All MCP tools are available alongside built-in tools.
101+
102+
### Example MCP config
103+
104+
```json
105+
{
106+
"mcpServers": {
107+
"brave-search": {
108+
"command": "npx",
109+
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
110+
"env": { "BRAVE_API_KEY": "..." }
111+
},
112+
"mcp-obsidian": {
113+
"command": "npx",
114+
"args": ["-y", "mcp-obsidian", "/path/to/obsidian-vault/"]
115+
}
116+
}
117+
}
118+
```
119+
120+
- Place this file at `~/.config/agent-loop/mcp.json`.
121+
- Each server can be a local or remote MCP-compatible service.
122+
- All services/tools from these servers will be available in your agent session.
123+
- For more details, see the [Cursor MCP documentation](https://docs.cursor.com/context/model-context-protocol#configuring-mcp-servers).
71124

72125
---
73126

@@ -89,6 +142,7 @@
89142
| **aws_cli** | Run AWS CLI v2 read-only commands to interact with AWS services |
90143
| **jira** | Query JIRA via REST API using safe, read-only endpoints |
91144
| **confluence** | Query Atlassian Confluence Cloud via REST API (read-only) |
145+
| **MCP** | All services from configured MCP servers (see above) |
92146

93147
See [Creating Tools Guide](CREATING_TOOLS.md) for instructions on how to create your own tools.
94148

@@ -258,6 +312,10 @@ nano ~/.config/agent-loop/SYSTEM_PROMPT.txt
258312

259313
This allows you to give specific instructions or personality to the assistant. If this file doesn't exist, the default system prompt will be used.
260314

315+
### MCP Server Configuration
316+
317+
To enable MCP integration, create a file at `~/.config/agent-loop/mcp.json` as shown above. Each server entry should specify the command, arguments, and any required environment variables. All services from these servers will be available as tools in your agent session.
318+
261319
## Usage
262320

263321
### Basic

agent_loop/SYSTEM_PROMPT.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ You have access to the following tools: bash, python, node, sympy, filesystem, h
77
- ALWAYS ask for explicit user confirmation BEFORE any destructive or potentially destructive operation, even when not in safe mode.
88
- Use functional programming principles in your suggestions and code.
99
- Be concise, clear, and safety-conscious in all actions.
10+
- In general if you need to use some api, try with free ones except when told differently.

agent_loop/main.py

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,24 @@
1010
import argparse
1111
from halo import Halo
1212
from dotenv import load_dotenv
13+
import asyncio
14+
from contextlib import AsyncExitStack, suppress
15+
from agent_loop.mcp_client import MCPManager
16+
import inspect
17+
import datetime
1318

1419
load_dotenv(dotenv_path=os.path.expanduser("~/.config/agent-loop/.env"))
1520

21+
mcp_manager = MCPManager()
22+
1623

1724
def user_input() -> List[Dict]:
1825
x = input("\ndev@agent-loop:~$ ")
1926
if x.lower() in {"exit", "quit"}:
20-
print("Goodbye!")
27+
print("👋 Goodbye!")
2128
raise SystemExit
29+
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
30+
x = f"{x}\n(Current date and time: {now})"
2231
return [{"type": "text", "text": x}]
2332

2433

@@ -27,71 +36,57 @@ def create_llm():
2736
anthropic_model = os.getenv("ANTHROPIC_MODEL", "claude-3-7-sonnet-latest")
2837
openai_key = os.getenv("OPENAI_API_KEY")
2938
openai_model = os.getenv("OPENAI_MODEL", "gpt-4o")
30-
3139
if not anthropic_key and not openai_key:
3240
raise EnvironmentError(
33-
"No API keys found. Please set either ANTHROPIC_API_KEY or OPENAI_API_KEY "
34-
"environment variables. If both are set, ANTHROPIC_API_KEY will be used."
41+
"No API keys found. Please set either ANTHROPIC_API_KEY or OPENAI_API_KEY environment variables."
3542
)
36-
37-
# Check available API keys and select provider
3843
if anthropic_key:
39-
# Use Anthropic if its key is available (priority)
4044
return create_anthropic_llm(anthropic_model, anthropic_key)
41-
elif openai_key:
42-
# Fallback to OpenAI if only its key is available
43-
return create_openai_llm(openai_model, openai_key)
44-
else:
45-
# No API keys available
46-
raise EnvironmentError(
47-
"No API keys found. Please set either ANTHROPIC_API_KEY or OPENAI_API_KEY "
48-
"environment variables. If both are set, ANTHROPIC_API_KEY will be used."
49-
)
45+
return create_openai_llm(openai_model, openai_key)
5046

5147

5248
def get_tool_description(tool_name: str) -> str:
53-
"""Return the description for a tool given its name."""
5449
for tool in TOOLS:
5550
if tool.get("name") == tool_name:
5651
return tool.get("description", "No description available.")
5752
return "No description available."
5853

5954

6055
def confirm_tool_execution(tool_name: str, input_data: Dict) -> bool:
61-
"""Prompt the user to confirm execution of a tool call."""
6256
description = get_tool_description(tool_name)
63-
print(f"\n[CONFIRMATION REQUIRED]")
64-
print(f"Tool: {tool_name}")
65-
print(f"Description: {description}")
66-
print(f"Input: {input_data}")
67-
print("Do you want to execute this command? [y/N]: ", end="")
68-
answer = input().strip().lower()
57+
print(
58+
f"\n⚠️ [CONFIRMATION REQUIRED]\nTool: {tool_name}\nDescription: {description}\nInput: {input_data}"
59+
)
60+
answer = input("Do you want to execute this command? [y/N]: ").strip().lower()
6961
return answer in {"y", "yes"}
7062

7163

72-
def handle_tool_call(tool_call: Dict, debug: bool = False, safe: bool = False) -> Dict:
64+
async def handle_tool_call(
65+
tool_call: Dict, debug: bool = False, safe: bool = False
66+
) -> Dict:
7367
name = tool_call["name"]
7468
input_data = tool_call["input"]
69+
print(f"🛠️ [Agent] Calling tool: {name} | Input: {input_data}")
7570
if debug:
7671
print(f"\n[Tool: {name}] Input: {input_data}\n")
77-
7872
if safe and not confirm_tool_execution(name, input_data):
7973
return {
8074
"type": "tool_result",
8175
"tool_use_id": tool_call["id"],
8276
"content": [
8377
{
8478
"type": "text",
85-
"text": f"[SKIPPED] {name} command was not executed by user request.",
86-
}
79+
"text": f"⚠️ [SKIPPED] {name} command was not executed by user request.",
80+
},
8781
],
8882
}
89-
9083
handler = TOOL_HANDLERS.get(name)
9184
if not handler:
9285
raise ValueError(f"No handler for tool: {name}")
93-
94-
output = handler(input_data)
86+
if inspect.iscoroutinefunction(handler):
87+
output = await handler(input_data)
88+
else:
89+
output = handler(input_data)
9590
if debug:
9691
print(output)
9792
return {
@@ -101,7 +96,7 @@ def handle_tool_call(tool_call: Dict, debug: bool = False, safe: bool = False) -
10196
}
10297

10398

104-
def loop(llm_fn, debug: bool = False, safe: bool = False):
99+
async def loop(llm_fn, debug: bool = False, safe: bool = False):
105100
msg = user_input()
106101
while True:
107102
spinner = Halo(text="Thinking...", spinner="dots")
@@ -110,31 +105,48 @@ def loop(llm_fn, debug: bool = False, safe: bool = False):
110105
response, tool_calls = llm_fn(msg)
111106
finally:
112107
spinner.stop()
113-
print("Agent:", response)
114-
108+
print(f"💬 Agent: {response}")
115109
if tool_calls:
116110
tool_results = [
117-
handle_tool_call(tc, debug=debug, safe=safe) for tc in tool_calls
111+
await handle_tool_call(tc, debug=debug, safe=safe) for tc in tool_calls
118112
]
119113
msg = tool_results
120114
else:
121115
msg = user_input()
122116

123117

124-
def main():
125-
parser = argparse.ArgumentParser(description="Agent Loop")
126-
parser.add_argument("--debug", action="store_true", help="Show tool input/output")
127-
parser.add_argument(
128-
"--safe",
129-
action="store_true",
130-
help="Require confirmation before executing tools",
131-
)
132-
args = parser.parse_args()
118+
async def agent_main():
119+
try:
120+
async with AsyncExitStack() as exit_stack:
121+
parser = argparse.ArgumentParser(description="Agent Loop")
122+
parser.add_argument(
123+
"--debug", action="store_true", help="Show tool input/output"
124+
)
125+
parser.add_argument(
126+
"--safe",
127+
action="store_true",
128+
help="Require confirmation before executing tools",
129+
)
130+
args = parser.parse_args()
131+
spinner = Halo(text="Loading MCP servers...", spinner="dots")
132+
spinner.start()
133+
try:
134+
await mcp_manager.register_tools(exit_stack, debug=args.debug)
135+
finally:
136+
spinner.stop()
137+
try:
138+
await loop(create_llm(), debug=args.debug, safe=args.safe)
139+
except KeyboardInterrupt:
140+
print("\n👋 Interrupted. Goodbye!")
141+
except asyncio.CancelledError:
142+
pass
133143

144+
145+
def main():
134146
try:
135-
loop(create_llm(), debug=args.debug, safe=args.safe)
147+
asyncio.run(agent_main())
136148
except KeyboardInterrupt:
137-
print("\nInterrupted. Goodbye!")
149+
print("\n👋 Interrupted. Goodbye!")
138150

139151

140152
if __name__ == "__main__":

0 commit comments

Comments
 (0)