diff --git a/.gitignore b/.gitignore index 8ccaa57..b140d63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # general things to ignore +agents/ build/ dist/ docs/_sources/ @@ -37,6 +38,7 @@ tmp.txt logs/ *.db AGENTS.md +mcpServers.json .pytype/ .idea/ diff --git a/README.md b/README.md index 45a9340..1a545bb 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/ai/llm_caller.py b/ai/llm_caller.py index 61516ef..cf81c23 100644 --- a/ai/llm_caller.py +++ b/ai/llm_caller.py @@ -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__) @@ -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( @@ -94,6 +97,7 @@ async def call_llm( get_current_time, AgentTool(agent=search_agent), AgentTool(agent=coding_agent), + *mcp_toolsets, ], ) diff --git a/ai/mcp_config_loader.py b/ai/mcp_config_loader.py new file mode 100644 index 0000000..6f6e543 --- /dev/null +++ b/ai/mcp_config_loader.py @@ -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) diff --git a/app.py b/app.py index ad7c803..faaf137 100644 --- a/app.py +++ b/app.py @@ -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) @@ -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 diff --git a/app_oauth.py b/app_oauth.py index 5442792..1bca9a2 100644 --- a/app_oauth.py +++ b/app_oauth.py @@ -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 @@ -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()) @@ -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)) diff --git a/listeners/shared.py b/listeners/shared.py index 9c50e9f..e69d169 100644 --- a/listeners/shared.py +++ b/listeners/shared.py @@ -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…", ], ) diff --git a/mcpServers.json.sample b/mcpServers.json.sample new file mode 100644 index 0000000..e495030 --- /dev/null +++ b/mcpServers.json.sample @@ -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}" + } + } + } +} diff --git a/pyproject.toml b/pyproject.toml index f978283..4001f60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/requirements.txt b/requirements.txt index bc3f707..dfed6fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_mcp_config_loader.py b/tests/test_mcp_config_loader.py new file mode 100644 index 0000000..d97ce5f --- /dev/null +++ b/tests/test_mcp_config_loader.py @@ -0,0 +1,216 @@ +import json + +import ai.mcp_config_loader as mcp_loader + + +class DummyStdioServerParameters: + def __init__(self, command, args=None, env=None): + self.command = command + self.args = args or [] + self.env = env or {} + + +class DummyStdioConnectionParams: + def __init__(self, server_params, timeout=5): + self.server_params = server_params + self.timeout = timeout + + +class DummySseConnectionParams: + def __init__(self, url, headers=None): + self.url = url + self.headers = headers or {} + + +class DummyStreamableHTTPConnectionParams: + def __init__(self, url, headers=None): + self.url = url + self.headers = headers or {} + + +class DummyMcpToolset: + def __init__(self, connection_params, tool_filter=None): + self.connection_params = connection_params + self.tool_filter = tool_filter + + +def _toolset_params(toolset): + if hasattr(toolset, "connection_params"): + return toolset.connection_params + return getattr(toolset, "_connection_params") + + +class TestExpandConfigValues: + def test_expands_env_and_workspace_vars_recursively(self, monkeypatch, tmp_path): + monkeypatch.setenv("MCP_TEST_TOKEN", "secret-token") + monkeypatch.chdir(tmp_path) + + payload = { + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer ${env:MCP_TEST_TOKEN}", + }, + "args": ["${workspaceFolder}", "${env:MISSING_VAR}"], + } + + expanded = mcp_loader.expand_config_values(payload) + + assert expanded["headers"]["Authorization"] == "Bearer secret-token" + assert expanded["args"][0] == str(tmp_path) + assert expanded["args"][1] == "" + + +class TestCreateToolsetFromConfig: + def _patch_classes(self, monkeypatch): + monkeypatch.setattr( + mcp_loader, "StdioServerParameters", DummyStdioServerParameters + ) + monkeypatch.setattr( + mcp_loader, "StdioConnectionParams", DummyStdioConnectionParams + ) + monkeypatch.setattr(mcp_loader, "SseConnectionParams", DummySseConnectionParams) + monkeypatch.setattr( + mcp_loader, + "StreamableHTTPConnectionParams", + DummyStreamableHTTPConnectionParams, + ) + monkeypatch.setattr(mcp_loader, "McpToolset", DummyMcpToolset) + + def test_creates_stdio_toolset_with_timeout_and_merged_env(self, monkeypatch): + self._patch_classes(monkeypatch) + monkeypatch.setenv("HOST_VALUE", "host") + + toolset = mcp_loader.create_toolset_from_config( + "local", + { + "command": "npx", + "args": ["-y", "server"], + "env": {"LOCAL_ONLY": "1"}, + "tool_filter": ["read_file"], + }, + ) + + params = _toolset_params(toolset) + assert isinstance(params, DummyStdioConnectionParams) + assert params.timeout == 30 + assert params.server_params.command == "npx" + assert params.server_params.args == ["-y", "server"] + assert params.server_params.env["LOCAL_ONLY"] == "1" + assert params.server_params.env["HOST_VALUE"] == "host" + assert toolset.tool_filter == ["read_file"] + + def test_routes_sse_url_to_sse_connection_params(self, monkeypatch): + self._patch_classes(monkeypatch) + + toolset = mcp_loader.create_toolset_from_config( + "remote", + { + "url": "https://example.com/sse", + "headers": {"Authorization": "Bearer x"}, + }, + ) + + params = _toolset_params(toolset) + assert isinstance(params, DummySseConnectionParams) + assert params.url == "https://example.com/sse" + assert params.headers == {"Authorization": "Bearer x"} + + def test_routes_other_urls_to_streamable_http_connection_params(self, monkeypatch): + self._patch_classes(monkeypatch) + + toolset = mcp_loader.create_toolset_from_config( + "remote", + { + "url": "https://example.com/mcp", + "headers": {"Authorization": "Bearer y"}, + }, + ) + + params = _toolset_params(toolset) + assert isinstance(params, DummyStreamableHTTPConnectionParams) + assert params.url == "https://example.com/mcp" + assert params.headers == {"Authorization": "Bearer y"} + + def test_invalid_config_without_command_or_url_raises(self): + try: + mcp_loader.create_toolset_from_config("invalid", {"headers": {}}) + assert False, "expected ValueError" + except ValueError as exc: + assert "command" in str(exc) or "url" in str(exc) + + +class TestLoadMcpToolsetsFromFile: + def test_missing_config_file_returns_empty_list(self, caplog, tmp_path): + missing = tmp_path / "does-not-exist.json" + + result = mcp_loader.load_mcp_toolsets_from_file(str(missing)) + + assert result == [] + assert any("not found" in record.message for record in caplog.records) + + def test_invalid_json_returns_empty_list(self, tmp_path): + path = tmp_path / "mcpServers.json" + path.write_text("{ not-json", encoding="utf-8") + + result = mcp_loader.load_mcp_toolsets_from_file(str(path)) + + assert result == [] + + def test_loads_multiple_servers_and_skips_failures(self, monkeypatch, tmp_path): + path = tmp_path / "mcpServers.json" + path.write_text( + json.dumps( + { + "mcpServers": { + "good": {"url": "https://example.com/mcp"}, + "bad": {"url": ""}, + "good2": {"command": "npx", "args": ["-y", "x"]}, + } + } + ), + encoding="utf-8", + ) + + sentinel = object() + sentinel2 = object() + + def fake_create(name, config): + _ = config + if name == "bad": + raise ValueError("broken") + if name == "good": + return sentinel + if name == "good2": + return sentinel2 + raise AssertionError("unexpected server") + + monkeypatch.setattr(mcp_loader, "create_toolset_from_config", fake_create) + + result = mcp_loader.load_mcp_toolsets_from_file(str(path)) + + assert result == [sentinel, sentinel2] + + def test_ignores_non_object_server_entries(self, tmp_path): + path = tmp_path / "mcpServers.json" + path.write_text( + json.dumps( + { + "mcpServers": { + "bad": "not-an-object", + } + } + ), + encoding="utf-8", + ) + + result = mcp_loader.load_mcp_toolsets_from_file(str(path)) + + assert result == [] + + def test_invalid_mcp_servers_shape_returns_empty_list(self, tmp_path): + path = tmp_path / "mcpServers.json" + path.write_text(json.dumps({"mcpServers": []}), encoding="utf-8") + + result = mcp_loader.load_mcp_toolsets_from_file(str(path)) + + assert result == [] diff --git a/uv.lock b/uv.lock index 74d36dd..c02c99b 100644 --- a/uv.lock +++ b/uv.lock @@ -719,7 +719,7 @@ wheels = [ [[package]] name = "google-adk" -version = "1.25.1" +version = "1.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -733,6 +733,7 @@ dependencies = [ { name = "google-cloud-bigquery" }, { name = "google-cloud-bigquery-storage" }, { name = "google-cloud-bigtable" }, + { name = "google-cloud-dataplex" }, { name = "google-cloud-discoveryengine" }, { name = "google-cloud-pubsub" }, { name = "google-cloud-secret-manager" }, @@ -767,9 +768,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/e2/9c755a7088128cc7e2dbae99d0c512d71fc6504ed128eb489b516b7e47c4/google_adk-1.25.1.tar.gz", hash = "sha256:5f3771d9f704f04c4a6996a3d0c33fc6890641047d3f5a6128cc9b2a83b3326b", size = 2218119 } +sdist = { url = "https://files.pythonhosted.org/packages/5e/8c/5f3d3fcfdabbbe509e2a488cbfbf799c6a889b25ace877d40956498d3da1/google_adk-1.27.2.tar.gz", hash = "sha256:2971793c9872cd496cc322e6dd7cf404e99512689ed2fffd43f333683d204c2a", size = 2297941 } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/09/e7ed67abe7e928309799b4c4789b2a3b5eba4ac0eb6d4c7912f9e3e9823d/google_adk-1.25.1-py3-none-any.whl", hash = "sha256:62907f54b918a56450fc81669471f5819f41a48548ada3a521ac85728ca29001", size = 2579485 }, + { url = "https://files.pythonhosted.org/packages/44/0d/bde4735b5ec36a312774526bb75fecaf45af6ae029d9bd665c42c5fe8d15/google_adk-1.27.2-py3-none-any.whl", hash = "sha256:04b4f23e9d26d75ee2f70c8d933629b095b8f6b23ed8ffc072f4d13fd106848d", size = 2689037 }, ] [[package]] @@ -977,6 +978,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469 }, ] +[[package]] +name = "google-cloud-dataplex" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/64/38445469e85e20b6fbb0ad58d0466daa3bd779789729562c12b35cfc24c3/google_cloud_dataplex-2.16.0.tar.gz", hash = "sha256:f9086abb94ae1f35151b2df5b729cc6bbf9361354d5afd22e76515ec0a8e7fdc", size = 766385 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/1a/9d0fc0188abcfe3c4e58db72972b100badb9899e34d94471223ac2037816/google_cloud_dataplex-2.16.0-py3-none-any.whl", hash = "sha256:173ce519395cd424c1ae22de4efb194767524fb5a2424194f091e63b34f4dfc1", size = 584533 }, +] + [[package]] name = "google-cloud-discoveryengine" version = "0.13.12" @@ -2937,6 +2955,7 @@ source = { virtual = "." } dependencies = [ { name = "aiohttp" }, { name = "google-adk" }, + { name = "mcp" }, { name = "pypdf" }, { name = "python-dotenv" }, { name = "slack-bolt" }, @@ -2954,7 +2973,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.9.5" }, - { name = "google-adk", specifier = ">=1.19.0" }, + { name = "google-adk", specifier = ">=1.27.2" }, + { name = "mcp", specifier = ">=1.26.0" }, { name = "pypdf", specifier = ">=6.7.1" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "slack-bolt", specifier = ">=1.27.0" },