diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..94e1135 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Repository Guidelines + +## Project Structure & Modules +- `src/agcluster/container/` – FastAPI backend: `api/` endpoints, `core/` orchestration (session, container, translation), `models/` Pydantic schemas, and `ui/` Next.js dashboard. +- `tests/` – Pytest suites split into `unit/` and `integration/`; markers also cover `e2e` (Docker-backed). +- `e2e/agcluster.spec.ts` – Root Playwright checks against a running stack. +- `docker/`, `docker-compose.yml`, `configs/`, `.env.example` – Deployment assets; copy `.env.example` to `.env` before local runs. +- `docs/`, `examples/` – Reference material and sample agent configurations. + +## Build, Test, and Development Commands +- Backend setup: `pip install -e ".[dev]"` (Python 3.11+). Start stack: `docker compose up -d`; rebuild images with `docker compose build`. +- API-only run (dev): `uvicorn agcluster.container.api.main:app --reload` after setting `ANTHROPIC_API_KEY` and other env vars. +- Backend tests: `pytest tests/` (all), `pytest tests/unit`, `pytest tests/integration`, or `pytest --cov=agcluster.container tests/`. +- UI workspace (`src/agcluster/container/ui`): `npm install`, then `npm run dev` for Next.js, `npm run build`/`npm start` for production, `npm test` for Vitest, `npm run test:e2e` for Playwright UI flows. +- Root Playwright smoke (if stack is up): `npm test` from repo root runs `playwright test`. + +## Coding Style & Naming Conventions +- Python: Black and Ruff with 100-char lines; optional mypy (`mypy src/agcluster`). Prefer typed function signatures in new code. +- Tests: filenames `test_*.py`; classes `Test*`; functions `test_*`. Use pytest markers `@pytest.mark.unit|integration|e2e` and keep fixtures in `tests/conftest.py`. +- TypeScript/React: ESLint + TypeScript; follow existing component patterns in `src/agcluster/container/ui`. Use PascalCase for components, camelCase for hooks/utilities. +- Tailwind is locked to v3; avoid `@apply` in new styles—prefer utility classes or scoped CSS modules. + +## Testing Guidelines +- Add unit tests for new services/models; integration tests for API surface changes; Playwright for end-to-end UI/agent flows. +- Aim to keep coverage gaps small (HTML report in `htmlcov/` from `pytest --cov`). Include edge cases around Docker/session lifecycle and file handling. +- For UI changes, pair Vitest component coverage with Playwright scenarios that validate auth, upload/download, and agent launch flows. +- Record any required Docker/ENV prerequisites in the test description to keep CI reproducible. + +## Commit & Pull Request Guidelines +- Use conventional commits when possible (`feat:`, `fix:`, `chore:`, `docs:`); keep subjects imperative and under ~72 chars. +- PRs should include: concise summary, linked issue/Linear ticket, test plan with commands run, and screenshots/GIFs for UI changes. +- Keep commits scoped; avoid mixing backend and UI refactors unless tightly coupled. Update docs/config examples when behavior changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ccf71e..123876d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to the AgCluster Container project will be documented in thi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.2] - 2025-12-03 + +### Added +- GitHub Code Review preset now exposes MCP permissions in the UI builder (including permission modes, tool selection, and MCP server envs). + +### Changed +- Respect `permission_mode` end-to-end (including container env) so MCP-enabled agents honor preset settings such as `bypassPermissions`. +- Clarified MCP credential expectations for GitHub by standardizing on `GITHUB_PERSONAL_ACCESS_TOKEN` in configs and docs. +- Docker/Fly providers and API client updated to carry full permission modes and MCP settings. + +### Fixed +- Prevented stale configs in containers by mounting repository presets in Docker Compose. +- Resolved MCP auth/approval loops for GitHub by propagating permissions correctly and passing PATs unchanged. + +--- + ## [0.3.0] - 2025-01-28 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 838d94a..b43ed1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ docker compose down ### Testing -**Test Suite**: 133 tests, 100% passing, 83% coverage +**Test Suite**: Run `pytest tests/` (unit, integration, e2e markers); target 80%+ coverage locally ```bash # Run all tests @@ -351,11 +351,12 @@ SESSION_IDLE_TIMEOUT=1800 # 30 minutes idle timeout ### Agent Configuration Files Agent presets are stored in `configs/presets/` as YAML files. See "Agent Configuration System" section above for structure and details. The system includes: -- ✅ 4 preset configurations (code-assistant, research-agent, data-analysis, fullstack-team) +- ✅ 5 preset configurations (code-assistant, research-agent, data-analysis, github-code-review, fullstack-team) - ✅ Custom inline config support via `/api/agents/launch` - ✅ Full validation via Pydantic models - ✅ Multi-agent orchestration (sub-agents) -- ✅ Per-agent tool specialization and resource limits +- ✅ Per-agent tool specialization and resource limits (with MCP servers where configured) +- ✅ Launch-time MCP credentials (`mcp_env`) must match keys declared under each server's `env`; reserved container env vars are blocked from override Documentation: `configs/README.md` @@ -484,18 +485,18 @@ curl -X POST "http://localhost:8000/api/files/conv-abc123.../upload?overwrite=fa - ✅ FastAPI endpoints (`/`, `/health`, `/api/agents/*`, `/api/configs`, `/api/files`) - ✅ Claude-native chat API (`/api/agents/{session_id}/chat`) - ✅ Agent configuration endpoints (`/api/configs`, `/api/agents/launch`, `/api/agents/sessions`) -- ✅ 4 preset agent configurations (code-assistant, research-agent, data-analysis, fullstack-team) -- ✅ Custom inline configuration support +- ✅ 5 preset agent configurations (adds `github-code-review` with MCP) +- ✅ Custom inline config support - ✅ Multi-agent orchestration (fullstack-team with 3 sub-agents) - ✅ Config-based session management with persistent containers - ✅ Background cleanup task (30-minute idle timeout) - ✅ Claude SDK integration with configurable tools (Bash, Read, Write, Grep, Task, WebFetch, NotebookEdit, TodoWrite) - ✅ TodoWrite tool for all presets (task tracking) - ✅ NotebookEdit tool for data-analysis (Jupyter support) +- ✅ MCP server support with launch-time credentials and auto-allowed MCP tools - ✅ Docker container isolation per conversation/session - ✅ Per-agent resource limits (CPU, memory, storage) - ✅ Namespace package structure for modularity -- ✅ Comprehensive test suite (218 tests, 212 passing, 66% coverage) - ✅ Web UI with Next.js 15 + React + TypeScript - ✅ File operations API with security (browse, preview, download, **upload**) - ✅ File upload with multi-provider support (Docker + Fly.io) @@ -514,7 +515,7 @@ curl -X POST "http://localhost:8000/api/files/conv-abc123.../upload?overwrite=fa **Tested and Verified**: - ✅ Multi-turn conversations with context preservation - ✅ Config-based agent launching and session management -- ✅ All 4 preset configurations load and validate successfully +- ✅ All presets load and validate successfully (including MCP-enabled configs) - ✅ Inline custom configuration support - ✅ Tool specialization per agent type - ✅ Resource limits enforcement @@ -541,4 +542,5 @@ curl -X POST "http://localhost:8000/api/files/conv-abc123.../upload?overwrite=fa - Agent-to-agent communication enhancements - Conversation export and history persistence - Never add this to the commit message: 🤖 Generated with Claude Code Co-Authored-By: Claude -- Always run ruff check src/ tests/ \ No newline at end of file +- Always run ruff check src/ tests/ +- Always run black --check src/ tests/ diff --git a/README.md b/README.md index 1f31812..9a095de 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,14 @@ ### Agent Configuration System -- **Preset Configurations** - 4 ready-to-use templates: +- **Preset Configurations** - 5 ready-to-use templates: - `code-assistant` - Full-stack development - `research-agent` - Web research and analysis - `data-analysis` - Statistical analysis with Jupyter + - `github-code-review` - GitHub PR reviews with MCP integration - `fullstack-team` - Multi-agent orchestration with sub-agents - **Custom Configurations** - Define agents with specific tools and limits +- **MCP Server Support** - Integrate external tools via Model Context Protocol (GitHub, filesystem, Postgres, etc.) - **Tool Specialization** - Configure which tools each agent can access - **Resource Management** - Per-agent CPU, memory, storage limits @@ -207,7 +209,32 @@ Statistical analysis and data visualization - **Resources**: 2 CPUs, 6GB RAM, 15GB storage - **Use Cases**: Exploratory data analysis, statistical testing, Jupyter workflows -#### 4. Full-Stack Team (`fullstack-team`) +#### 4. GitHub Code Review (`github-code-review`) + +GitHub PR review agent with MCP integration + +- **Tools**: Read, Write, Grep, TodoWrite (+ auto-added MCP tools) +- **MCP Servers**: GitHub MCP server for PR/issue operations +- **Permissions**: `permission_mode: bypassPermissions` to avoid repeated approval prompts for MCP calls +- **Resources**: 1 CPU, 2GB RAM, 5GB storage +- **Use Cases**: Automated PR reviews, security scanning, code quality checks + +**Launch with GitHub token:** +```bash +curl -X POST http://localhost:8000/api/agents/launch \ + -H "Content-Type: application/json" \ + -d '{ + "api_key": "sk-ant-...", + "config_id": "github-code-review", + "mcp_env": { + "github": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..." + } + } + }' +``` + +#### 5. Full-Stack Team (`fullstack-team`) Multi-agent orchestrator with specialized sub-agents @@ -390,6 +417,21 @@ Launch a new agent from configuration. } ``` +**With MCP credentials:** +```json +{ + "api_key": "sk-ant-...", + "config_id": "github-code-review", + "mcp_env": { + "github": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..." + } + } +} +``` + +**MCP credential rules and permissions:** keys in `mcp_env` must match those declared under each server’s `env` in the config (e.g., `GITHUB_PERSONAL_ACCESS_TOKEN`). Core container env vars (such as `ANTHROPIC_API_KEY`, `AGENT_CONFIG_JSON`, `AGENT_ID`) cannot be overridden. When `mcp_servers` are present, agents auto-enable `ListMcpResources`, `ReadMcpResource`, and `mcp__{server}__*` tool permissions, and use Claude SDK permission mode `acceptEdits` by default. If you require stricter gating, set `permission_mode: plan` or `default` in the config. + **Response:** ```json { diff --git a/configs/README.md b/configs/README.md index 45fac69..ea933ca 100644 --- a/configs/README.md +++ b/configs/README.md @@ -47,7 +47,69 @@ This directory contains pre-configured agent templates that can be used to launc --- -### 3. Full-Stack Team (`fullstack-team.yaml`) +### 3. Data Analysis Agent (`data-analysis.yaml`) + +**Purpose:** Statistical analysis and data visualization + +**Features:** +- Pandas, numpy, scipy, matplotlib support +- NotebookEdit for Jupyter-style workflows +- Bash for data processing scripts +- Optimized for exploratory data analysis + +**Best For:** +- Statistical testing +- Data cleaning and transformation +- Visualization and plotting +- ML model evaluation +- Interactive data debugging + +**Resource Allocation:** 2 CPUs, 6GB RAM + +--- + +### 4. GitHub Code Review (`github-code-review.yaml`) + +**Purpose:** Automated GitHub pull request reviews + +**Features:** +- **MCP Integration**: Uses GitHub MCP server for API access +- Systematic code review with focus on: + - Code quality and maintainability + - Security vulnerabilities + - Performance optimizations + - Testing coverage + - Architecture patterns +- Auto-enabled MCP tools (ListMcpResources, ReadMcpResource) +- Runtime credential injection via `mcp_env` + +**Best For:** +- Automated PR reviews +- Security audits +- Code quality checks +- Architecture analysis +- Best practices enforcement + +**Resource Allocation:** 1 CPU, 2GB RAM + +**Usage with Credentials:** +```bash +curl -X POST http://localhost:8000/api/agents/launch \ + -H "Content-Type: application/json" \ + -d '{ + "api_key": "sk-ant-...", + "config_id": "github-code-review", + "mcp_env": { + "github": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..." + } + } + }' +``` + +--- + +### 5. Full-Stack Team (`fullstack-team.yaml`) **Purpose:** Multi-agent orchestration for complex projects @@ -152,15 +214,52 @@ agents: ### MCP Server Integration +AgCluster supports the Model Context Protocol (MCP) for integrating external tools and services. MCP servers are defined in configuration files and credentials are provided at launch time. + +**Configuration Structure:** ```yaml mcp_servers: - github: - command: npx - args: ["-y", "@modelcontextprotocol/server-github"] + github: # Server name + command: npx # Executable + args: ["-y", "@modelcontextprotocol/server-github"] # Arguments env: - GITHUB_PERSONAL_ACCESS_TOKEN: ${GITHUB_TOKEN} + GITHUB_PERSONAL_ACCESS_TOKEN: ${GITHUB_PERSONAL_ACCESS_TOKEN} # Placeholder ``` +**Runtime Credentials:** + +Provide actual credentials via the `mcp_env` parameter when launching agents: + +```bash +curl -X POST http://localhost:8000/api/agents/launch \ + -H "Content-Type: application/json" \ + -d '{ + "api_key": "sk-ant-...", + "config_id": "github-code-review", + "mcp_env": { + "github": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token_here" + } + } + }' +``` + +**Key Features:** +- **Auto-Allow MCP Tools**: When `mcp_servers` are configured, `ListMcpResources` and `ReadMcpResource` are automatically added to `allowed_tools` +- **Environment Variable Merging**: Runtime `mcp_env` overrides config-defined placeholders +- **Multi-Provider Support**: MCP works with Docker, Fly.io, and other providers +- **Tool Discovery**: Agents can list and read available MCP resources +- **Runtime Credential Validation**: `mcp_env` keys must match the `env` keys defined per server; core container env vars cannot be overridden + +**Preset MCP Example:** +- `github-code-review.yaml` — GitHub PR reviews using the GitHub MCP server with launch-time personal access token + +**Available MCP Servers:** +- `@modelcontextprotocol/server-github` - GitHub API integration +- `@modelcontextprotocol/server-filesystem` - File system access +- `@modelcontextprotocol/server-postgres` - PostgreSQL integration +- Custom MCP servers (see [MCP docs](https://modelcontextprotocol.io)) + --- ## Resource Limits @@ -202,13 +301,25 @@ permission_mode: acceptEdits ```yaml id: github-bot name: GitHub Bot -allowed_tools: ["mcp__github__create_issue", "mcp__github__list_prs"] +# Note: ListMcpResources and ReadMcpResource are auto-added +# No need to explicitly list mcp__* tools +allowed_tools: ["Read", "Write"] mcp_servers: github: command: npx args: ["-y", "@modelcontextprotocol/server-github"] env: - GITHUB_PERSONAL_ACCESS_TOKEN: ${GITHUB_TOKEN} + GITHUB_PERSONAL_ACCESS_TOKEN: ${GITHUB_PERSONAL_ACCESS_TOKEN} # Placeholder +``` + +Launch with actual credentials: +```bash +curl -X POST http://localhost:8000/api/agents/launch \ + -d '{ + "api_key": "sk-ant-...", + "config_id": "github-bot", + "mcp_env": {"github": {"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..."}} + }' ``` ### Multi-Agent Team diff --git a/configs/presets/github-code-review.yaml b/configs/presets/github-code-review.yaml new file mode 100644 index 0000000..f6746eb --- /dev/null +++ b/configs/presets/github-code-review.yaml @@ -0,0 +1,53 @@ +id: github-code-review +name: GitHub Code Review Agent +description: Specialized agent for reviewing GitHub pull requests with security and performance analysis +version: 1.0.0 + +system_prompt: | + You are a senior software engineer specializing in code reviews. Your role is to review GitHub pull requests and provide constructive, actionable feedback. + + **Review Focus Areas:** + 1. **Code Quality** - Readability, maintainability, adherence to best practices + 2. **Security** - Potential vulnerabilities, input validation, authentication issues + 3. **Performance** - Inefficient algorithms, unnecessary operations, optimization opportunities + 4. **Testing** - Test coverage, edge cases, test quality + 5. **Architecture** - Design patterns, separation of concerns, scalability + + **Review Style:** + - Be constructive and respectful + - Provide specific examples and suggestions + - Explain the "why" behind recommendations + - Acknowledge good code when you see it + - Use GitHub MCP tools to fetch PR details, diffs, and file contents + + **Workflow:** + 1. Use GitHub MCP tools to fetch PR information + 2. Review the changes systematically + 3. Provide organized feedback by category + 4. Suggest concrete improvements with code examples when helpful + + Always start by using GitHub MCP tools to understand the PR context before providing feedback. + +allowed_tools: + - Read + - Write + - Grep + - TodoWrite + # MCP tools are auto-allowed when mcp_servers is configured + +# Claude SDK permissions: bypass prompts for tool use to avoid repeated approval +permission_mode: bypassPermissions + +resource_limits: + cpu_quota: 100000 # 1 CPU + memory_limit: 2g + storage_limit: 5g + +max_turns: 100 + +mcp_servers: + github: + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_PERSONAL_ACCESS_TOKEN: ${GITHUB_PERSONAL_ACCESS_TOKEN} # Overridden by mcp_env at launch time diff --git a/container/agent_server.py b/container/agent_server.py index bfeb4a6..8bfa2ee 100644 --- a/container/agent_server.py +++ b/container/agent_server.py @@ -85,7 +85,9 @@ def _load_config(self): self.config = { "id": "legacy", "name": "Legacy Agent", - "allowed_tools": os.environ.get("ALLOWED_TOOLS", "Bash,Read,Write").split(","), + "allowed_tools": os.environ.get( + "ALLOWED_TOOLS", "Bash,Read,Write,ListMcpResources,ReadMcpResource" + ).split(","), "system_prompt": os.environ.get("SYSTEM_PROMPT", "You are a helpful AI assistant."), "permission_mode": "acceptEdits", } diff --git a/docker-compose.yml b/docker-compose.yml index 04b55e2..e1108cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - /var/run/docker.sock:/var/run/docker.sock # Mount source code for development - ./src:/app/src + # Mount configs so presets stay in sync with local changes + - ./configs:/app/configs:ro environment: - API_HOST=0.0.0.0 - API_PORT=8000 diff --git a/src/agcluster/container/api/agents.py b/src/agcluster/container/api/agents.py index b91f9a9..039fc50 100644 --- a/src/agcluster/container/api/agents.py +++ b/src/agcluster/container/api/agents.py @@ -74,13 +74,14 @@ async def launch_agent(request: LaunchRequest): # Generate conversation ID (used as session key) conversation_id = str(uuid.uuid4()) - # Create session from config with optional provider + # Create session from config with optional provider and MCP credentials session_id, agent_container = await session_manager.create_session_from_config( conversation_id=conversation_id, api_key=request.api_key, config_id=request.config_id, config=request.config, provider=request.provider, + mcp_env=request.mcp_env, ) return LaunchResponse( diff --git a/src/agcluster/container/core/container_manager.py b/src/agcluster/container/core/container_manager.py index 576dd5f..6400650 100644 --- a/src/agcluster/container/core/container_manager.py +++ b/src/agcluster/container/core/container_manager.py @@ -12,6 +12,15 @@ logger = logging.getLogger(__name__) +RESERVED_ENV_VARS = { + "ANTHROPIC_API_KEY", + "AGENT_CONFIG_JSON", + "AGENT_ID", + "SESSION_ID", + "CONVERSATION_ID", +} + + class AgentContainer: """ Wrapper around ContainerInfo for backward compatibility. @@ -125,8 +134,53 @@ def __init__(self, provider_name: Optional[str] = None): logger.info(f"ContainerManager initialized with {self.provider.__class__.__name__}") + def _sanitize_mcp_env( + self, config: AgentConfig, mcp_env: Optional[Dict[str, Dict[str, str]]] + ) -> Optional[Dict[str, Dict[str, str]]]: + """ + Validate and sanitize MCP environment variables supplied at launch. + + Only allow variables explicitly declared in each server's config.env and + block overrides of core container variables. Returns a filtered copy. + """ + if not mcp_env: + return None + + if not config.mcp_servers: + raise ValueError("mcp_env provided but no mcp_servers configured") + + sanitized: Dict[str, Dict[str, str]] = {} + + for server_name, server_env in mcp_env.items(): + if server_name not in config.mcp_servers: + raise ValueError(f"Unknown MCP server '{server_name}' in mcp_env") + + declared_env = getattr(config.mcp_servers[server_name], "env", {}) or {} + allowed_keys = set(declared_env.keys()) + + if not allowed_keys and server_env: + raise ValueError( + f"MCP server '{server_name}' does not declare env keys; cannot accept runtime credentials" + ) + + sanitized[server_name] = {} + for key, value in server_env.items(): + if key in RESERVED_ENV_VARS: + raise ValueError(f"MCP env key '{key}' is reserved and cannot be overridden") + if key not in allowed_keys: + raise ValueError( + f"MCP env key '{key}' not declared in config for server '{server_name}'" + ) + sanitized[server_name][key] = value + + return sanitized + async def create_agent_container_from_config( - self, api_key: str, config: AgentConfig, config_id: str + self, + api_key: str, + config: AgentConfig, + config_id: str, + mcp_env: Optional[Dict[str, Dict[str, str]]] = None, ) -> AgentContainer: """ Create agent container from configuration using provider. @@ -135,6 +189,7 @@ async def create_agent_container_from_config( api_key: Anthropic API key config: Agent configuration config_id: Configuration ID (for tracking) + mcp_env: Optional runtime environment variables for MCP servers Returns: AgentContainer instance @@ -143,6 +198,9 @@ async def create_agent_container_from_config( logger.info(f"Creating container for session {session_id} with config {config_id}") + # Validate and sanitize runtime MCP environment variables + sanitized_mcp_env = self._sanitize_mcp_env(config, mcp_env) + # Build provider config provider_config = ProviderConfig( platform=self.provider_name, @@ -166,6 +224,9 @@ async def create_agent_container_from_config( max_turns=config.max_turns, api_key=api_key, platform_credentials={}, # TODO: Add platform-specific creds when needed + mcp_servers=config.mcp_servers, # Pass MCP servers from config + mcp_env=sanitized_mcp_env, # Pass runtime MCP environment variables + permission_mode=config.permission_mode or "acceptEdits", ) # Create container via provider @@ -216,6 +277,7 @@ async def create_agent_container( max_turns=100, # Default api_key=api_key, platform_credentials={}, + permission_mode="acceptEdits", ) # Create container via provider diff --git a/src/agcluster/container/core/providers/base.py b/src/agcluster/container/core/providers/base.py index 0bce0e1..0d961fb 100644 --- a/src/agcluster/container/core/providers/base.py +++ b/src/agcluster/container/core/providers/base.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Dict, Any, AsyncIterator +from typing import Dict, Any, AsyncIterator, Optional @dataclass @@ -45,6 +45,9 @@ class ProviderConfig: max_turns: Maximum conversation turns api_key: Anthropic API key platform_credentials: Platform-specific authentication credentials + mcp_servers: MCP server configurations (optional) + mcp_env: Runtime environment variables for MCP servers (optional) + permission_mode: Claude SDK permission mode (default, acceptEdits, plan, bypassPermissions) """ platform: str @@ -56,6 +59,9 @@ class ProviderConfig: max_turns: int api_key: str platform_credentials: Dict[str, Any] = field(default_factory=dict) + mcp_servers: Optional[Dict[str, Any]] = None + mcp_env: Optional[Dict[str, Dict[str, str]]] = None + permission_mode: str = "acceptEdits" class ContainerProvider(ABC): diff --git a/src/agcluster/container/core/providers/docker_provider.py b/src/agcluster/container/core/providers/docker_provider.py index 49b5194..6178193 100644 --- a/src/agcluster/container/core/providers/docker_provider.py +++ b/src/agcluster/container/core/providers/docker_provider.py @@ -71,21 +71,69 @@ async def create_container(self, session_id: str, config: ProviderConfig) -> Con # It's a Pydantic model (SystemPromptPreset), convert to dict system_prompt_value = system_prompt_value.model_dump() + # Build agent config JSON with MCP servers if configured + agent_config_dict = { + "id": config.platform, + "name": f"Agent {agent_id}", + "allowed_tools": config.allowed_tools, + "system_prompt": system_prompt_value, + "permission_mode": getattr(config, "permission_mode", None) or "acceptEdits", + "max_turns": config.max_turns, + } + + # Add MCP servers if configured + if config.mcp_servers: + # Convert Pydantic models to dicts for JSON serialization + mcp_servers_dict = {} + for server_name, server_config in config.mcp_servers.items(): + if hasattr(server_config, "model_dump"): + # Pydantic v2 + mcp_servers_dict[server_name] = server_config.model_dump(exclude_none=True) + elif hasattr(server_config, "dict"): + # Pydantic v1 + mcp_servers_dict[server_name] = server_config.dict(exclude_none=True) + else: + # Already a dict + mcp_servers_dict[server_name] = server_config + + agent_config_dict["mcp_servers"] = mcp_servers_dict + logger.info(f"Added {len(config.mcp_servers)} MCP server(s) to agent config") + env = { "AGENT_ID": agent_id, "ANTHROPIC_API_KEY": config.api_key, - "AGENT_CONFIG_JSON": json.dumps( - { - "id": config.platform, - "name": f"Agent {agent_id}", - "allowed_tools": config.allowed_tools, - "system_prompt": system_prompt_value, - "permission_mode": "acceptEdits", - "max_turns": config.max_turns, - } - ), + "AGENT_CONFIG_JSON": json.dumps(agent_config_dict), } + # Merge MCP environment variables if provided + if config.mcp_env: + for server_name, server_env in config.mcp_env.items(): + for env_key, env_value in server_env.items(): + env[env_key] = env_value + logger.info(f"Added MCP env var {env_key} for server {server_name}") + + # Also check for environment variable substitution in MCP server configs + if config.mcp_servers: + for server_name, server_config in config.mcp_servers.items(): + # Convert to dict if it's a Pydantic model + server_dict = server_config + if hasattr(server_config, "model_dump"): + server_dict = server_config.model_dump(exclude_none=True) + elif hasattr(server_config, "dict"): + server_dict = server_config.dict(exclude_none=True) + + if "env" in server_dict: + for env_key, env_value in server_dict["env"].items(): + # If value starts with ${, check if it's already in env + # Otherwise use the literal value + if isinstance(env_value, str) and env_value.startswith("${"): + # Skip - will be resolved at runtime + pass + else: + # Use literal value from config + if env_key not in env: + env[env_key] = env_value + # Create container container = self.docker_client.containers.run( image="agcluster/agent:latest", diff --git a/src/agcluster/container/core/providers/fly_provider.py b/src/agcluster/container/core/providers/fly_provider.py index 3c638d0..835c780 100644 --- a/src/agcluster/container/core/providers/fly_provider.py +++ b/src/agcluster/container/core/providers/fly_provider.py @@ -84,22 +84,70 @@ async def create_container(self, session_id: str, config: ProviderConfig) -> Con # Convert memory limit to MB (e.g., "4g" -> 4096) memory_mb = self._parse_memory_limit(config.memory_limit) + # Build agent config JSON with MCP servers if configured + agent_config_dict = { + "id": config.platform, + "name": f"Agent {agent_id}", + "allowed_tools": config.allowed_tools, + "system_prompt": config.system_prompt, + "permission_mode": getattr(config, "permission_mode", None) or "acceptEdits", + "max_turns": config.max_turns, + } + + # Add MCP servers if configured + if config.mcp_servers: + # Convert Pydantic models to dicts for JSON serialization + mcp_servers_dict = {} + for server_name, server_config in config.mcp_servers.items(): + if hasattr(server_config, "model_dump"): + # Pydantic v2 + mcp_servers_dict[server_name] = server_config.model_dump(exclude_none=True) + elif hasattr(server_config, "dict"): + # Pydantic v1 + mcp_servers_dict[server_name] = server_config.dict(exclude_none=True) + else: + # Already a dict + mcp_servers_dict[server_name] = server_config + + agent_config_dict["mcp_servers"] = mcp_servers_dict + logger.info(f"Added {len(config.mcp_servers)} MCP server(s) to agent config") + # Prepare environment variables env = { "AGENT_ID": agent_id, "ANTHROPIC_API_KEY": config.api_key, - "AGENT_CONFIG_JSON": json.dumps( - { - "id": config.platform, - "name": f"Agent {agent_id}", - "allowed_tools": config.allowed_tools, - "system_prompt": config.system_prompt, - "permission_mode": "acceptEdits", - "max_turns": config.max_turns, - } - ), + "AGENT_CONFIG_JSON": json.dumps(agent_config_dict), } + # Merge MCP environment variables if provided + if config.mcp_env: + for server_name, server_env in config.mcp_env.items(): + for env_key, env_value in server_env.items(): + env[env_key] = env_value + logger.info(f"Added MCP env var {env_key} for server {server_name}") + + # Also check for environment variable substitution in MCP server configs + if config.mcp_servers: + for server_name, server_config in config.mcp_servers.items(): + # Convert to dict if it's a Pydantic model + server_dict = server_config + if hasattr(server_config, "model_dump"): + server_dict = server_config.model_dump(exclude_none=True) + elif hasattr(server_config, "dict"): + server_dict = server_config.dict(exclude_none=True) + + if "env" in server_dict: + for env_key, env_value in server_dict["env"].items(): + # If value starts with ${, check if it's already in env + # Otherwise use the literal value + if isinstance(env_value, str) and env_value.startswith("${"): + # Skip - will be resolved at runtime + pass + else: + # Use literal value from config + if env_key not in env: + env[env_key] = env_value + # Build machine configuration machine_config = { "name": machine_name, diff --git a/src/agcluster/container/core/session_manager.py b/src/agcluster/container/core/session_manager.py index 94dce06..9801705 100644 --- a/src/agcluster/container/core/session_manager.py +++ b/src/agcluster/container/core/session_manager.py @@ -61,6 +61,7 @@ async def create_session_from_config( config_id: Optional[str] = None, config: Optional[AgentConfig] = None, provider: Optional[str] = None, + mcp_env: Optional[Dict[str, Dict[str, str]]] = None, ) -> tuple[str, AgentContainer]: """ Create a new session from configuration @@ -71,6 +72,7 @@ async def create_session_from_config( config_id: Optional config ID to load config: Optional inline config provider: Optional provider name (docker, fly_machines, cloudflare, vercel) + mcp_env: Optional runtime environment variables for MCP servers Returns: tuple: (session_id, AgentContainer) @@ -109,13 +111,13 @@ async def create_session_from_config( logger.info(f"Creating session {session_id} with provider {provider}") provider_manager = ContainerManager(provider_name=provider) agent_container = await provider_manager.create_agent_container_from_config( - api_key=api_key, config=config, config_id=effective_config_id + api_key=api_key, config=config, config_id=effective_config_id, mcp_env=mcp_env ) else: # Use global container manager (default provider) logger.info(f"Creating session {session_id} with config {effective_config_id}") agent_container = await container_manager.create_agent_container_from_config( - api_key=api_key, config=config, config_id=effective_config_id + api_key=api_key, config=config, config_id=effective_config_id, mcp_env=mcp_env ) # Store session diff --git a/src/agcluster/container/models/agent_config.py b/src/agcluster/container/models/agent_config.py index c64d685..c0018d8 100644 --- a/src/agcluster/container/models/agent_config.py +++ b/src/agcluster/container/models/agent_config.py @@ -1,6 +1,6 @@ """Agent configuration models - mirrors Claude SDK's ClaudeAgentOptions""" -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator from typing import Optional, Dict, List, Literal, Union from datetime import datetime @@ -163,6 +163,39 @@ def validate_id(cls, v: str) -> str: return v + @model_validator(mode="after") + def auto_allow_mcp_tools(self) -> "AgentConfig": + """ + Automatically allow MCP tools from configured servers. + + If mcp_servers are configured, automatically grants permission to use + ListMcpResources and ReadMcpResource tools plus server-specific MCP + tool namespaces. This eliminates the need to explicitly list these + tools in allowed_tools. + """ + if self.mcp_servers: + # Add base MCP tools if not already present + base_mcp_tools = ["ListMcpResources", "ReadMcpResource"] + for tool in base_mcp_tools: + if tool not in self.allowed_tools: + self.allowed_tools.append(tool) + + # Allow server-specific MCP tools (prefix-based) + for server_name in self.mcp_servers.keys(): + wildcard_tool = f"mcp__{server_name}__*" + if wildcard_tool not in self.allowed_tools: + self.allowed_tools.append(wildcard_tool) + + # Log which servers are configured (helps with debugging) + import logging + + logger = logging.getLogger(__name__) + logger.info( + f"Auto-enabled MCP tools for {len(self.mcp_servers)} server(s): {list(self.mcp_servers.keys())}" + ) + + return self + class Config: json_schema_extra = { "example": { diff --git a/src/agcluster/container/models/schemas.py b/src/agcluster/container/models/schemas.py index 0db7cea..cc04c52 100644 --- a/src/agcluster/container/models/schemas.py +++ b/src/agcluster/container/models/schemas.py @@ -115,6 +115,10 @@ class LaunchRequest(BaseModel): provider: Optional[str] = Field( None, description="Container provider (docker, fly_machines, cloudflare, vercel)" ) + mcp_env: Optional[Dict[str, Dict[str, str]]] = Field( + None, + description="Runtime environment variables for MCP servers (e.g., {'github': {'GITHUB_PERSONAL_ACCESS_TOKEN': 'ghp_...'}})", + ) def validate_config_or_id(self): """Ensure either config_id or config is provided""" diff --git a/src/agcluster/container/ui/app/page.tsx b/src/agcluster/container/ui/app/page.tsx index 6771abb..2dc043b 100644 --- a/src/agcluster/container/ui/app/page.tsx +++ b/src/agcluster/container/ui/app/page.tsx @@ -3,14 +3,16 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Code2, Search, BarChart3, Rocket, Plus } from 'lucide-react'; -import { listConfigs, launchAgent, type ConfigInfo } from '@/lib/api-client'; +import { listConfigs, launchAgent, getConfig, type ConfigInfo, type AgentConfig } from '@/lib/api-client'; import Navigation from '@/components/Navigation'; +import { McpCredentialsModal } from '@/components/McpCredentialsModal'; const PRESET_ICONS = { 'code-assistant': Code2, 'research-agent': Search, 'data-analysis': BarChart3, 'fullstack-team': Rocket, + 'github-code-review': Code2, } as const; export default function DashboardPage() { @@ -20,6 +22,8 @@ export default function DashboardPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [backendConnected, setBackendConnected] = useState(false); + const [mcpModalOpen, setMcpModalOpen] = useState(false); + const [selectedConfig, setSelectedConfig] = useState<{ id: string; fullConfig: AgentConfig | null }>({ id: '', fullConfig: null }); // Load API key from localStorage useEffect(() => { @@ -49,6 +53,26 @@ export default function DashboardPage() { return; } + setError(''); + + // Check if config has MCP servers + const configInfo = configs.find(c => c.id === configId); + if (configInfo?.has_mcp_servers) { + // Fetch full config to get MCP server details + try { + const fullConfig = await getConfig(configId); + setSelectedConfig({ id: configId, fullConfig }); + setMcpModalOpen(true); + } catch (err) { + setError('Failed to load config details'); + } + } else { + // Launch directly without MCP credentials + await doLaunchAgent(configId, {}); + } + }; + + const doLaunchAgent = async (configId: string, mcpEnv: Record>) => { setLoading(true); setError(''); @@ -60,6 +84,7 @@ export default function DashboardPage() { const response = await launchAgent({ api_key: apiKey, config_id: configId, + mcp_env: Object.keys(mcpEnv).length > 0 ? mcpEnv : undefined, }); // Navigate to chat with session ID @@ -71,6 +96,11 @@ export default function DashboardPage() { } }; + const handleMcpCredentialsSubmit = async (credentials: Record>) => { + setMcpModalOpen(false); + await doLaunchAgent(selectedConfig.id, credentials); + }; + return (
{/* Header */} @@ -206,6 +236,14 @@ export default function DashboardPage() {
)} + + {/* MCP Credentials Modal */} + setMcpModalOpen(false)} + mcpServers={selectedConfig.fullConfig?.mcp_servers || {}} + onSubmit={handleMcpCredentialsSubmit} + /> ); } diff --git a/src/agcluster/container/ui/components/McpCredentialsModal.tsx b/src/agcluster/container/ui/components/McpCredentialsModal.tsx new file mode 100644 index 0000000..ec8c2e8 --- /dev/null +++ b/src/agcluster/container/ui/components/McpCredentialsModal.tsx @@ -0,0 +1,194 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { X, Key, AlertCircle } from 'lucide-react'; + +interface McpCredentialsModalProps { + isOpen: boolean; + onClose: () => void; + mcpServers: Record; + onSubmit: (credentials: Record>) => void; +} + +export function McpCredentialsModal({ + isOpen, + onClose, + mcpServers, + onSubmit, +}: McpCredentialsModalProps) { + const [credentials, setCredentials] = useState>>({}); + const [errors, setErrors] = useState([]); + + // Extract required env vars from MCP servers + useEffect(() => { + if (isOpen && mcpServers) { + const initialCreds: Record> = {}; + Object.entries(mcpServers).forEach(([serverName, serverConfig]: [string, any]) => { + if (serverConfig.env) { + initialCreds[serverName] = {}; + Object.keys(serverConfig.env).forEach((envKey) => { + initialCreds[serverName][envKey] = ''; + }); + } + }); + setCredentials(initialCreds); + setErrors([]); + } + }, [isOpen, mcpServers]); + + const handleSubmit = () => { + const newErrors: string[] = []; + + // Validate all required fields are filled + Object.entries(credentials).forEach(([serverName, serverCreds]) => { + Object.entries(serverCreds).forEach(([key, value]) => { + if (!value || value.trim() === '') { + newErrors.push(`${serverName}: ${key} is required`); + } + }); + }); + + if (newErrors.length > 0) { + setErrors(newErrors); + return; + } + + onSubmit(credentials); + }; + + const handleSkip = () => { + // Submit empty credentials (will likely fail at runtime, but let user try) + onSubmit({}); + }; + + if (!isOpen) return null; + + const hasAnyMcpServers = Object.keys(mcpServers || {}).length > 0; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

MCP Server Credentials

+

+ This agent uses MCP servers that require authentication +

+
+
+ +
+ + {/* Content */} +
+ {!hasAnyMcpServers && ( +
+

No MCP servers configured for this agent

+
+ )} + + {errors.length > 0 && ( +
+
+ +
+

Missing required credentials:

+
    + {errors.map((error, i) => ( +
  • • {error}
  • + ))} +
+
+
+
+ )} + + {Object.entries(credentials).map(([serverName, serverCreds]) => ( +
+

+ + {serverName} + +

+
+ {Object.entries(serverCreds).map(([key, value]) => { + const placeholder = mcpServers[serverName]?.env?.[key]; + const isPlaceholder = typeof placeholder === 'string' && placeholder.startsWith('${'); + + return ( +
+ + { + setCredentials({ + ...credentials, + [serverName]: { + ...credentials[serverName], + [key]: e.target.value, + }, + }); + // Clear errors when user starts typing + setErrors([]); + }} + className="w-full px-4 py-2 bg-gray-900 border border-gray-800 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm font-mono" + placeholder={isPlaceholder ? placeholder.slice(2, -1) : key} + /> +
+ ); + })} +
+
+ ))} + +
+

+ 💡 Tip: These credentials are passed to the container at launch + time and are never stored. Each session requires its own credentials. +

+
+
+ + {/* Footer */} +
+ + + +
+
+
+ ); +} diff --git a/src/agcluster/container/ui/components/builder/MCPServerEditor.tsx b/src/agcluster/container/ui/components/builder/MCPServerEditor.tsx index 4af39cf..50b4e8b 100644 --- a/src/agcluster/container/ui/components/builder/MCPServerEditor.tsx +++ b/src/agcluster/container/ui/components/builder/MCPServerEditor.tsx @@ -71,7 +71,7 @@ export function MCPServerEditor({ servers, onChange }: MCPServerEditorProps) {