Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# general things to ignore
agents/
build/
dist/
docs/_sources/
Expand Down Expand Up @@ -37,6 +38,7 @@ tmp.txt
logs/
*.db
AGENTS.md
mcpServers.json
.pytype/
.idea/

Expand Down
57 changes: 55 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,62 @@ from ai.tools.custom_tools import get_weather, my_tool
tools=[get_weather, my_tool, AgentTool(agent=search_agent), ...]
```

### MCP servers via JSON config

You can add MCP (Model Context Protocol) tools without changing Python code by creating `mcpServers.json` in the project root.

```bash
cp mcpServers.json.sample mcpServers.json
# Edit mcpServers.json with your MCP servers
```

Supported server config shape (`mcpServers` object):

- `command` + optional `args`/`env` -> local stdio MCP server
- `url` ending in `/sse` -> SSE MCP server
- any other `url` (including `/mcp`) -> Streamable HTTP MCP server

Supported placeholders in all string values:

- `${env:VAR_NAME}` -> environment variable value (empty string if unset)
- `${workspaceFolder}` -> current working directory

Example:

```json
{
"mcpServers": {
"local-filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"],
"tool_filter": ["read_file", "list_directory"]
},
"remote-mcp": {
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer ${env:MCP_AUTH_TOKEN}"
}
},
"remote-sse": {
"url": "https://example.com/sse",
"headers": {
"Authorization": "Bearer ${env:MCP_AUTH_TOKEN}"
}
}
}
}
```

Notes:

- MCP toolsets are loaded once at startup from `MCP_CONFIG_PATH` (default: `mcpServers.json`).
- If the config file is missing, the app starts normally without MCP tools.
- If one MCP server fails to load, the others still load.
- MCP toolsets are closed during app shutdown.

## Architecture (high level)

Slack (UI) → **Bolt app** (Socket Mode, listeners) → **Google ADK** (LlmAgent + sub-agents + tools, session store) → **Gemini API**. Each Slack thread maps to one ADK session; responses are streamed back.
Slack (UI) → **Bolt app** (Socket Mode, listeners) → **Google ADK** (LlmAgent + sub-agents + local tools + MCP tools, session store) → **Gemini API**. Each Slack thread maps to one ADK session; responses are streamed back.

## Demos

Expand All @@ -126,8 +179,8 @@ Slack (UI) → **Bolt app** (Socket Mode, listeners) → **Google ADK** (LlmAgen

## Roadmap

- [x] MCP (Model Context Protocol) tools
- [ ] Memory Bank across sessions
- [ ] MCP (Model Context Protocol) tools
- [ ] Agent Engine / Cloud Run deployment
- [ ] Observability (e.g. OpenTelemetry)
- [ ] A2A protocol for multi-agent workflows
Expand Down
4 changes: 4 additions & 0 deletions ai/llm_caller.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Part,
)
from ai.tools.custom_tools import get_weather, get_current_time
from ai.mcp_config_loader import load_mcp_toolsets_from_file
from ai.utils import load_system_instruction

logger = logging.getLogger(__name__)
Expand All @@ -27,12 +28,14 @@
AGENT_MODEL = os.getenv("AGENT_MODEL", "gemini-2.5-flash")
AGENT_MAX_OUTPUT_TOKENS = int(os.getenv("AGENT_MAX_OUTPUT_TOKENS", "0"))
AGENT_TARGET_OUTPUT_CHARS = int(os.getenv("AGENT_TARGET_OUTPUT_CHARS", "9000"))
MCP_CONFIG_PATH = os.getenv("MCP_CONFIG_PATH", "mcpServers.json")

DEFAULT_SYSTEM_INSTRUCTION = load_system_instruction()


# Global services
session_service = InMemorySessionService()
mcp_toolsets = load_mcp_toolsets_from_file(MCP_CONFIG_PATH)


async def call_llm(
Expand Down Expand Up @@ -94,6 +97,7 @@ async def call_llm(
get_current_time,
AgentTool(agent=search_agent),
AgentTool(agent=coding_agent),
*mcp_toolsets,
],
)

Expand Down
147 changes: 147 additions & 0 deletions ai/mcp_config_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import json
import logging
import os
import re
from pathlib import Path
from typing import Any
from typing import Dict
from typing import List

from mcp import StdioServerParameters

from google.adk.tools.mcp_tool.mcp_session_manager import SseConnectionParams
from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams
from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams
from google.adk.tools.mcp_tool.mcp_toolset import McpToolset

logger = logging.getLogger(__name__)
_ENV_PATTERN = re.compile(r"\$\{env:([^}]+)\}")


def _expand_string(value: str) -> str:
expanded = _ENV_PATTERN.sub(lambda m: os.getenv(m.group(1), ""), value)
return expanded.replace("${workspaceFolder}", os.getcwd())


def expand_config_values(value: Any) -> Any:
"""Recursively expand supported placeholders in config values."""
if isinstance(value, str):
return _expand_string(value)
if isinstance(value, list):
return [expand_config_values(item) for item in value]
if isinstance(value, dict):
return {k: expand_config_values(v) for k, v in value.items()}
return value


def create_toolset_from_config(name: str, config: Dict[str, Any]) -> McpToolset:
"""Create one MCP toolset from a single server config object."""
expanded = expand_config_values(config)

if "command" in expanded:
command = expanded["command"]
if not isinstance(command, str) or not command.strip():
raise ValueError("'command' must be a non-empty string")

args = expanded.get("args", [])
if not isinstance(args, list):
raise ValueError("'args' must be a list when provided")

env = expanded.get("env", {})
if env is None:
env = {}
if not isinstance(env, dict):
raise ValueError("'env' must be an object when provided")

connection_params = StdioConnectionParams(
server_params=StdioServerParameters(
command=command,
args=args,
env={**os.environ, **env},
),
timeout=30,
)
elif "url" in expanded:
url = expanded["url"]
if not isinstance(url, str) or not url.strip():
raise ValueError("'url' must be a non-empty string")

headers = expanded.get("headers", {})
if headers is None:
headers = {}
if not isinstance(headers, dict):
raise ValueError("'headers' must be an object when provided")

if url.rstrip("/").lower().endswith("/sse"):
connection_params = SseConnectionParams(
url=url,
headers=headers,
)
else:
connection_params = StreamableHTTPConnectionParams(
url=url,
headers=headers,
)
else:
raise ValueError("Config must include either 'command' or 'url'")

tool_filter = expanded.get("tool_filter")
if tool_filter is not None and not isinstance(tool_filter, list):
raise ValueError("'tool_filter' must be a list when provided")

toolset = McpToolset(
connection_params=connection_params,
tool_filter=tool_filter,
)
logger.debug("Created MCP toolset for server '%s'", name)
return toolset


def load_mcp_toolsets_from_file(config_path: str) -> List[McpToolset]:
"""Load MCP toolsets from a JSON configuration file.

Returns only successfully-created toolsets; per-server failures are logged
and skipped so one bad entry does not stop the app.
"""
path = Path(config_path)
if not path.exists():
logger.info("MCP config file not found at %s; skipping MCP toolsets", path)
return []

try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
logger.error("Failed to parse MCP config '%s': %s", path, exc)
return []
except OSError as exc:
logger.error("Failed to read MCP config '%s': %s", path, exc)
return []

servers = payload.get("mcpServers", {})
if not isinstance(servers, dict):
logger.error("Invalid MCP config '%s': 'mcpServers' must be an object", path)
return []

toolsets: List[McpToolset] = []
for name, config in servers.items():
if not isinstance(config, dict):
logger.error(
"[FAIL] Failed to load MCP server '%s': config must be an object", name
)
continue
try:
toolsets.append(create_toolset_from_config(name=name, config=config))
logger.info("[OK] Loaded MCP server: %s", name)
except Exception as exc:
logger.error("[FAIL] Failed to load MCP server '%s': %s", name, exc)

return toolsets


async def close_mcp_toolsets(toolsets: List[McpToolset]) -> None:
"""Close all MCP toolsets, logging and continuing on individual failures."""
for toolset in toolsets:
try:
await toolset.close()
except Exception as exc:
logger.warning("Failed closing MCP toolset: %s", exc)
7 changes: 6 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from slack_sdk.web.async_client import AsyncWebClient

from listeners import register_listeners
from ai.llm_caller import mcp_toolsets
from ai.mcp_config_loader import close_mcp_toolsets

# Load environment variables
load_dotenv(dotenv_path=".env", override=False)
Expand All @@ -31,7 +33,10 @@

async def main():
handler = AsyncSocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN"))
await handler.start_async()
try:
await handler.start_async()
finally:
await close_mcp_toolsets(mcp_toolsets)


# Start Bolt app
Expand Down
8 changes: 7 additions & 1 deletion app_oauth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
import asyncio

from slack_bolt import App, BoltResponse
from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs, SuccessArgs
Expand All @@ -8,6 +9,8 @@
from slack_sdk.oauth.state_store import FileOAuthStateStore

from listeners import register_listeners
from ai.llm_caller import mcp_toolsets
from ai.mcp_config_loader import close_mcp_toolsets

# Set up logging
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO").upper())
Expand Down Expand Up @@ -56,4 +59,7 @@ def failure(args: FailureArgs) -> BoltResponse:

# Start Bolt app
if __name__ == "__main__":
app.start(3000)
try:
app.start(3000)
finally:
asyncio.run(close_mcp_toolsets(mcp_toolsets))
11 changes: 6 additions & 5 deletions listeners/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,12 @@ async def process_and_stream_message(
thread_ts=thread_ts,
status="thinking...",
loading_messages=[
"Teaching the hamsters to type faster…",
"Untangling the internet cables…",
"Consulting the office goldfish…",
"Polishing up the response just for you…",
"Convincing the AI to stop overthinking…",
"Rolling back the rollback of the rollback…",
"It works on my machine, shipping my laptop…",
"Blaming DNS… it's always DNS…",
"Restarting the pod that restarts the pods…",
"Updating the YAML that updates the YAML…",
"Deploying on Friday… what could go wrong…",
],
)

Expand Down
31 changes: 31 additions & 0 deletions mcpServers.json.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"mcpServers": {
"local-filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"${workspaceFolder}"
],
"env": {
"NODE_ENV": "production"
},
"tool_filter": [
"read_file",
"list_directory"
]
},
"remote-streamable": {
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer ${env:MCP_AUTH_TOKEN}"
}
},
"remote-sse": {
"url": "https://example.com/sse",
"headers": {
"Authorization": "Bearer ${env:MCP_AUTH_TOKEN}"
}
}
}
}
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ dependencies = [
"slack-sdk>=3.40.0",
"slack-bolt>=1.27.0",
"slack-cli-hooks>=0.2.0",
"google-adk>=1.19.0",
"google-adk>=1.27.2",
"mcp>=1.26.0",
"python-dotenv>=1.2.1",
"aiohttp>=3.9.5",
"pypdf>=6.7.1",
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
slack-sdk==3.40.0
slack-bolt==1.27.0
slack-cli-hooks==0.2.0
google-adk==1.19.0
google-adk==1.27.2
mcp==1.26.0
pytest==9.0.1
ruff==0.14.3
python-dotenv==1.2.1
Expand Down
Loading