Merge pull request #48 from bryanadenhq/feat/credential-manager
Feat/credential manager
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-builder": {
|
||||
"command": "bash",
|
||||
"args": ["-c", "cd core && exec python -m framework.mcp.agent_builder_server"],
|
||||
"cwd": "core"
|
||||
"command": "python",
|
||||
"args": ["-m", "framework.mcp.agent_builder_server"],
|
||||
"cwd": "core",
|
||||
"env": {
|
||||
"PYTHONPATH": "../aden-tools/src"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,17 @@ The `setup` script performs these actions:
|
||||
2. Generates `.env` files from your `config.yaml`
|
||||
3. Reports any issues
|
||||
|
||||
### AI Agent Tools (Optional)
|
||||
|
||||
If working with the agent framework:
|
||||
|
||||
```bash
|
||||
# Set up aden-tools credentials
|
||||
cd aden-tools
|
||||
cp .env.example .env
|
||||
# Edit .env with your ANTHROPIC_API_KEY and BRAVE_SEARCH_API_KEY
|
||||
```
|
||||
|
||||
### Verify Setup
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Aden Tools Environment Variables
|
||||
# Copy this file to .env and fill in your values
|
||||
# Or export these as environment variables
|
||||
|
||||
# Required for MCP server startup (Tier 1 - validated at startup)
|
||||
# Get your key at: https://console.anthropic.com/
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
|
||||
# Required for web_search tool (Tier 2 - validated when tool is used)
|
||||
# Get your key at: https://brave.com/search/api/
|
||||
BRAVE_SEARCH_API_KEY=your-brave-search-api-key-here
|
||||
@@ -84,9 +84,119 @@ _TOOL_MODULES = [
|
||||
]
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
## Credential Management
|
||||
|
||||
For tools requiring API keys or configuration, check environment variables at runtime:
|
||||
For tools requiring API keys, use the centralized `CredentialManager`. This enables:
|
||||
- **Agent-aware validation**: Credentials are checked when an agent loads, not at server startup
|
||||
- **Better error messages**: Users see exactly which credentials are missing and how to get them
|
||||
- **Easy testing**: Use `CredentialManager.for_testing()` to mock credentials
|
||||
|
||||
### Adding a New Credential
|
||||
|
||||
1. Find the appropriate category file in `src/aden_tools/credentials/`:
|
||||
- `llm.py` - LLM providers (anthropic, openai, etc.)
|
||||
- `search.py` - Search tools (brave_search, google_search, etc.)
|
||||
- Or create a new category file for integrations
|
||||
|
||||
2. Add the credential spec to the category's dict:
|
||||
|
||||
```python
|
||||
# In credentials/search.py
|
||||
SEARCH_CREDENTIALS = {
|
||||
# ... existing credentials
|
||||
"my_api": CredentialSpec(
|
||||
env_var="MY_API_KEY",
|
||||
tools=["my_api_tool"], # Which tools need this credential
|
||||
required=True, # or False for optional
|
||||
help_url="https://example.com/api-keys",
|
||||
description="API key for My Service",
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
3. If you created a new category file, import and merge it in `credentials/__init__.py`:
|
||||
|
||||
```python
|
||||
from .my_category import MY_CATEGORY_CREDENTIALS
|
||||
|
||||
CREDENTIAL_SPECS = {
|
||||
**LLM_CREDENTIALS,
|
||||
**SEARCH_CREDENTIALS,
|
||||
**MY_CATEGORY_CREDENTIALS, # Add new category
|
||||
}
|
||||
```
|
||||
|
||||
4. Update your tool to accept the optional `credentials` parameter:
|
||||
|
||||
```python
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aden_tools.credentials import CredentialManager
|
||||
|
||||
|
||||
def register_tools(
|
||||
mcp: FastMCP,
|
||||
credentials: Optional["CredentialManager"] = None,
|
||||
) -> None:
|
||||
@mcp.tool()
|
||||
def my_api_tool(query: str) -> dict:
|
||||
"""Tool that requires an API key."""
|
||||
# Use CredentialManager if provided, fallback to direct env access
|
||||
if credentials is not None:
|
||||
api_key = credentials.get("my_api")
|
||||
else:
|
||||
api_key = os.getenv("MY_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
return {
|
||||
"error": "MY_API_KEY environment variable not set",
|
||||
"help": "Get an API key at https://example.com/api-keys",
|
||||
}
|
||||
|
||||
# Use the API key...
|
||||
```
|
||||
|
||||
5. Update `register_all_tools()` in `tools/__init__.py` to pass credentials to your tool.
|
||||
|
||||
### Testing with Mock Credentials
|
||||
|
||||
```python
|
||||
from aden_tools.credentials import CredentialManager
|
||||
|
||||
def test_my_tool_with_valid_key(mcp):
|
||||
creds = CredentialManager.for_testing({"my_api": "test-key"})
|
||||
register_tools(mcp, credentials=creds)
|
||||
tool_fn = mcp._tool_manager._tools["my_api_tool"].fn
|
||||
|
||||
result = tool_fn(query="test")
|
||||
# Assertions...
|
||||
```
|
||||
|
||||
### When Validation Happens
|
||||
|
||||
Credentials are validated when an agent is loaded (via `AgentRunner.validate()`), not at MCP server startup. This means:
|
||||
|
||||
1. The MCP server always starts (even if credentials are missing)
|
||||
2. When you load an agent, validation checks which tools it needs
|
||||
3. If credentials are missing, you get a clear error:
|
||||
|
||||
```
|
||||
Cannot run agent: Missing credentials
|
||||
|
||||
The following tools require credentials that are not set:
|
||||
|
||||
web_search requires BRAVE_SEARCH_API_KEY
|
||||
API key for Brave Search
|
||||
Get an API key at: https://brave.com/search/api/
|
||||
Set via: export BRAVE_SEARCH_API_KEY=your_key
|
||||
|
||||
Set these environment variables and re-run the agent.
|
||||
```
|
||||
|
||||
## Environment Variables (Legacy)
|
||||
|
||||
For simple cases or backward compatibility, you can still check environment variables directly:
|
||||
|
||||
```python
|
||||
import os
|
||||
@@ -105,6 +215,8 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
# Use the API key...
|
||||
```
|
||||
|
||||
However, using `CredentialManager` is recommended for new tools as it provides better validation and testing support.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Error Handling
|
||||
|
||||
@@ -13,6 +13,27 @@ For development:
|
||||
pip install -e "aden-tools[dev]"
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Some tools require API keys to function. Copy the example file and add your credentials:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
| Variable | Required For | Get Key |
|
||||
|----------|--------------|---------|
|
||||
| `ANTHROPIC_API_KEY` | MCP server startup, LLM nodes | [console.anthropic.com](https://console.anthropic.com/) |
|
||||
| `BRAVE_SEARCH_API_KEY` | `web_search` tool | [brave.com/search/api](https://brave.com/search/api/) |
|
||||
|
||||
Alternatively, export as environment variables:
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=your-key-here
|
||||
export BRAVE_SEARCH_API_KEY=your-key-here
|
||||
```
|
||||
|
||||
See [.env.example](.env.example) for details.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### As an MCP Server
|
||||
|
||||
@@ -16,7 +16,14 @@ Usage:
|
||||
|
||||
Environment Variables:
|
||||
MCP_PORT - Server port (default: 4001)
|
||||
BRAVE_SEARCH_API_KEY - Required for web_search tool
|
||||
ANTHROPIC_API_KEY - Required at startup for testing/LLM nodes
|
||||
BRAVE_SEARCH_API_KEY - Required for web_search tool (validated at agent load time)
|
||||
|
||||
Note:
|
||||
Two-tier credential validation:
|
||||
- Tier 1 (startup): ANTHROPIC_API_KEY must be set before server starts
|
||||
- Tier 2 (agent load): Tool credentials validated when agent is loaded
|
||||
See aden_tools.credentials for details.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
@@ -38,12 +45,24 @@ from fastmcp import FastMCP
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
mcp = FastMCP("aden-tools")
|
||||
|
||||
# Register all tools with the MCP server
|
||||
from aden_tools.credentials import CredentialManager, CredentialError
|
||||
from aden_tools.tools import register_all_tools
|
||||
|
||||
tools = register_all_tools(mcp)
|
||||
# Create credential manager
|
||||
credentials = CredentialManager()
|
||||
|
||||
# Tier 1: Validate startup-required credentials (ANTHROPIC_API_KEY)
|
||||
try:
|
||||
credentials.validate_startup()
|
||||
print("[MCP] Startup credentials validated")
|
||||
except CredentialError as e:
|
||||
print(f"[MCP] FATAL: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
mcp = FastMCP("aden-tools")
|
||||
|
||||
# Register all tools with the MCP server, passing credential manager
|
||||
tools = register_all_tools(mcp, credentials=credentials)
|
||||
# Only print to stdout in HTTP mode (STDIO mode requires clean stdout for JSON-RPC)
|
||||
if "--stdio" not in sys.argv:
|
||||
print(f"[MCP] Registered {len(tools)} tools: {tools}")
|
||||
|
||||
@@ -28,6 +28,7 @@ dependencies = [
|
||||
"jsonpath-ng>=1.6.0",
|
||||
"fastmcp>=2.0.0",
|
||||
"diff-match-patch>=20230430",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -7,9 +7,11 @@ external systems, process data, and perform actions.
|
||||
Usage:
|
||||
from fastmcp import FastMCP
|
||||
from aden_tools.tools import register_all_tools
|
||||
from aden_tools.credentials import CredentialManager
|
||||
|
||||
mcp = FastMCP("my-server")
|
||||
register_all_tools(mcp)
|
||||
credentials = CredentialManager()
|
||||
register_all_tools(mcp, credentials=credentials)
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -17,6 +19,14 @@ __version__ = "0.1.0"
|
||||
# Utilities
|
||||
from .utils import get_env_var
|
||||
|
||||
# Credential management
|
||||
from .credentials import (
|
||||
CredentialManager,
|
||||
CredentialSpec,
|
||||
CredentialError,
|
||||
CREDENTIAL_SPECS,
|
||||
)
|
||||
|
||||
# MCP registration
|
||||
from .tools import register_all_tools
|
||||
|
||||
@@ -25,6 +35,11 @@ __all__ = [
|
||||
"__version__",
|
||||
# Utilities
|
||||
"get_env_var",
|
||||
# Credentials
|
||||
"CredentialManager",
|
||||
"CredentialSpec",
|
||||
"CredentialError",
|
||||
"CREDENTIAL_SPECS",
|
||||
# MCP registration
|
||||
"register_all_tools",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Centralized credential management for Aden Tools.
|
||||
|
||||
Provides agent-aware validation, clear error messages, and testability.
|
||||
|
||||
Philosophy: Google Strictness + Apple UX
|
||||
- Validate credentials before running an agent (fail-fast at the right boundary)
|
||||
- Guided error messages with clear next steps
|
||||
|
||||
Usage:
|
||||
# In mcp_server.py (startup validation)
|
||||
credentials = CredentialManager()
|
||||
credentials.validate_startup()
|
||||
|
||||
# In agent runner (validate at agent load time)
|
||||
credentials.validate_for_tools(["web_search", "file_read"])
|
||||
|
||||
# In tools
|
||||
api_key = credentials.get("brave_search")
|
||||
|
||||
# In tests
|
||||
creds = CredentialManager.for_testing({"brave_search": "test-key"})
|
||||
|
||||
Credential categories:
|
||||
- llm.py: LLM provider credentials (anthropic, openai, etc.)
|
||||
- search.py: Search tool credentials (brave_search, google_search, etc.)
|
||||
|
||||
To add a new credential:
|
||||
1. Find the appropriate category file (or create a new one)
|
||||
2. Add the CredentialSpec to that file's dictionary
|
||||
3. If new category, import and merge it in this __init__.py
|
||||
"""
|
||||
from .base import CredentialError, CredentialManager, CredentialSpec
|
||||
from .llm import LLM_CREDENTIALS
|
||||
from .search import SEARCH_CREDENTIALS
|
||||
|
||||
# Merged registry of all credentials
|
||||
CREDENTIAL_SPECS = {
|
||||
**LLM_CREDENTIALS,
|
||||
**SEARCH_CREDENTIALS,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
# Core classes
|
||||
"CredentialSpec",
|
||||
"CredentialManager",
|
||||
"CredentialError",
|
||||
# Merged registry
|
||||
"CREDENTIAL_SPECS",
|
||||
# Category registries (for direct access if needed)
|
||||
"LLM_CREDENTIALS",
|
||||
"SEARCH_CREDENTIALS",
|
||||
]
|
||||
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
Base classes for credential management.
|
||||
|
||||
Contains the core infrastructure: CredentialSpec, CredentialManager, and CredentialError.
|
||||
Credential specs are defined in separate category files (llm.py, search.py, etc.).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CredentialSpec:
|
||||
"""Specification for a single credential."""
|
||||
|
||||
env_var: str
|
||||
"""Environment variable name (e.g., 'BRAVE_SEARCH_API_KEY')"""
|
||||
|
||||
tools: List[str] = field(default_factory=list)
|
||||
"""Tool names that require this credential (e.g., ['web_search'])"""
|
||||
|
||||
node_types: List[str] = field(default_factory=list)
|
||||
"""Node types that require this credential (e.g., ['llm_generate', 'llm_tool_use'])"""
|
||||
|
||||
required: bool = True
|
||||
"""Whether this credential is required (vs optional)"""
|
||||
|
||||
startup_required: bool = False
|
||||
"""Whether this credential must be present at server startup (Tier 1)"""
|
||||
|
||||
help_url: str = ""
|
||||
"""URL where user can obtain this credential"""
|
||||
|
||||
description: str = ""
|
||||
"""Human-readable description of what this credential is for"""
|
||||
|
||||
|
||||
class CredentialError(Exception):
|
||||
"""Raised when required credentials are missing."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CredentialManager:
|
||||
"""
|
||||
Centralized credential management with agent-aware validation.
|
||||
|
||||
Key features:
|
||||
- validate_for_tools(): Validates only credentials needed by specific tools
|
||||
- get(): Retrieves credential value by logical name
|
||||
- for_testing(): Factory for creating test instances with mock values
|
||||
|
||||
Usage:
|
||||
# Production
|
||||
creds = CredentialManager()
|
||||
creds.validate_for_tools(["web_search"]) # Fails if BRAVE_SEARCH_API_KEY missing
|
||||
api_key = creds.get("brave_search")
|
||||
|
||||
# Testing
|
||||
creds = CredentialManager.for_testing({"brave_search": "test-key"})
|
||||
api_key = creds.get("brave_search") # Returns "test-key"
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
specs: Optional[Dict[str, CredentialSpec]] = None,
|
||||
_overrides: Optional[Dict[str, str]] = None,
|
||||
dotenv_path: Optional[Path] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the credential manager.
|
||||
|
||||
Args:
|
||||
specs: Credential specifications (defaults to CREDENTIAL_SPECS)
|
||||
_overrides: Internal - used by for_testing() to inject test values
|
||||
dotenv_path: Optional path to .env file (defaults to cwd/.env)
|
||||
"""
|
||||
if specs is None:
|
||||
# Lazy import to avoid circular dependency
|
||||
from . import CREDENTIAL_SPECS
|
||||
|
||||
specs = CREDENTIAL_SPECS
|
||||
self._specs = specs
|
||||
self._overrides = _overrides or {}
|
||||
self._dotenv_path = dotenv_path
|
||||
# Build reverse mapping: tool_name -> credential_name
|
||||
self._tool_to_cred: Dict[str, str] = {}
|
||||
for cred_name, spec in self._specs.items():
|
||||
for tool_name in spec.tools:
|
||||
self._tool_to_cred[tool_name] = cred_name
|
||||
# Build reverse mapping: node_type -> credential_name
|
||||
self._node_type_to_cred: Dict[str, str] = {}
|
||||
for cred_name, spec in self._specs.items():
|
||||
for node_type in spec.node_types:
|
||||
self._node_type_to_cred[node_type] = cred_name
|
||||
|
||||
@classmethod
|
||||
def for_testing(
|
||||
cls,
|
||||
overrides: Dict[str, str],
|
||||
specs: Optional[Dict[str, CredentialSpec]] = None,
|
||||
dotenv_path: Optional[Path] = None,
|
||||
) -> "CredentialManager":
|
||||
"""
|
||||
Create a CredentialManager with test values.
|
||||
|
||||
Args:
|
||||
overrides: Dict mapping credential names to test values
|
||||
specs: Optional custom specs (defaults to CREDENTIAL_SPECS)
|
||||
dotenv_path: Optional path to .env file (use non-existent path to isolate from real .env)
|
||||
|
||||
Returns:
|
||||
CredentialManager pre-configured for testing
|
||||
|
||||
Example:
|
||||
creds = CredentialManager.for_testing({"brave_search": "test-key"})
|
||||
assert creds.get("brave_search") == "test-key"
|
||||
"""
|
||||
return cls(specs=specs, _overrides=overrides, dotenv_path=dotenv_path)
|
||||
|
||||
def _get_raw(self, name: str) -> Optional[str]:
|
||||
"""Get credential from overrides, os.environ, or .env file.
|
||||
|
||||
Priority order:
|
||||
1. Test overrides (for testing)
|
||||
2. os.environ (explicit environment variables take precedence)
|
||||
3. .env file (hot-reload support - reads fresh each time)
|
||||
"""
|
||||
# 1. Check overrides (for testing)
|
||||
if name in self._overrides:
|
||||
return self._overrides[name]
|
||||
|
||||
spec = self._specs.get(name)
|
||||
if spec is None:
|
||||
return None
|
||||
|
||||
# 2. Check os.environ (takes precedence)
|
||||
env_value = os.environ.get(spec.env_var)
|
||||
if env_value:
|
||||
return env_value
|
||||
|
||||
# 3. Fallback: read from .env file (hot-reload)
|
||||
return self._read_from_dotenv(spec.env_var)
|
||||
|
||||
def _read_from_dotenv(self, env_var: str) -> Optional[str]:
|
||||
"""Read a single env var from .env file.
|
||||
|
||||
Uses dotenv_values() which reads the file without modifying os.environ,
|
||||
allowing for hot-reload without side effects.
|
||||
"""
|
||||
dotenv_path = self._dotenv_path or Path.cwd() / ".env"
|
||||
if not dotenv_path.exists():
|
||||
return None
|
||||
|
||||
# dotenv_values reads file without modifying os.environ
|
||||
values = dotenv_values(dotenv_path)
|
||||
return values.get(env_var)
|
||||
|
||||
def get(self, name: str) -> Optional[str]:
|
||||
"""
|
||||
Get a credential value by logical name.
|
||||
|
||||
Reads fresh from environment/.env each time to support hot-reload.
|
||||
When users add credentials to .env, they take effect immediately
|
||||
without restarting the MCP server.
|
||||
|
||||
Args:
|
||||
name: Logical credential name (e.g., "brave_search")
|
||||
|
||||
Returns:
|
||||
The credential value, or None if not set
|
||||
|
||||
Raises:
|
||||
KeyError: If the credential name is not in specs
|
||||
"""
|
||||
if name not in self._specs:
|
||||
raise KeyError(
|
||||
f"Unknown credential '{name}'. "
|
||||
f"Available: {list(self._specs.keys())}"
|
||||
)
|
||||
|
||||
# No caching - read fresh each time for hot-reload support
|
||||
return self._get_raw(name)
|
||||
|
||||
def get_spec(self, name: str) -> CredentialSpec:
|
||||
"""Get the spec for a credential."""
|
||||
if name not in self._specs:
|
||||
raise KeyError(f"Unknown credential '{name}'")
|
||||
return self._specs[name]
|
||||
|
||||
def is_available(self, name: str) -> bool:
|
||||
"""Check if a credential is available (set and non-empty)."""
|
||||
value = self.get(name)
|
||||
return value is not None and value != ""
|
||||
|
||||
def get_credential_for_tool(self, tool_name: str) -> Optional[str]:
|
||||
"""
|
||||
Get the credential name required by a tool.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool (e.g., "web_search")
|
||||
|
||||
Returns:
|
||||
Credential name if tool requires one, None otherwise
|
||||
"""
|
||||
return self._tool_to_cred.get(tool_name)
|
||||
|
||||
def get_missing_for_tools(
|
||||
self, tool_names: List[str]
|
||||
) -> List[Tuple[str, CredentialSpec]]:
|
||||
"""
|
||||
Get list of missing credentials for the given tools.
|
||||
|
||||
Args:
|
||||
tool_names: List of tool names to check
|
||||
|
||||
Returns:
|
||||
List of (credential_name, spec) tuples for missing credentials
|
||||
"""
|
||||
missing: List[Tuple[str, CredentialSpec]] = []
|
||||
checked: set[str] = set()
|
||||
|
||||
for tool_name in tool_names:
|
||||
cred_name = self._tool_to_cred.get(tool_name)
|
||||
if cred_name is None:
|
||||
# Tool doesn't require credentials
|
||||
continue
|
||||
if cred_name in checked:
|
||||
# Already checked this credential
|
||||
continue
|
||||
checked.add(cred_name)
|
||||
|
||||
spec = self._specs[cred_name]
|
||||
if spec.required and not self.is_available(cred_name):
|
||||
missing.append((cred_name, spec))
|
||||
|
||||
return missing
|
||||
|
||||
def validate_for_tools(self, tool_names: List[str]) -> None:
|
||||
"""
|
||||
Validate that all credentials required by the given tools are available.
|
||||
|
||||
Args:
|
||||
tool_names: List of tool names to validate credentials for
|
||||
|
||||
Raises:
|
||||
CredentialError: If any required credentials are missing
|
||||
|
||||
Example:
|
||||
creds = CredentialManager()
|
||||
creds.validate_for_tools(["web_search", "file_read"])
|
||||
# Raises CredentialError if BRAVE_SEARCH_API_KEY is not set
|
||||
"""
|
||||
missing = self.get_missing_for_tools(tool_names)
|
||||
|
||||
if missing:
|
||||
raise CredentialError(self._format_missing_error(missing, tool_names))
|
||||
|
||||
def _format_missing_error(
|
||||
self,
|
||||
missing: List[Tuple[str, CredentialSpec]],
|
||||
tool_names: List[str],
|
||||
) -> str:
|
||||
"""Format a clear, actionable error message for missing credentials."""
|
||||
lines = ["Cannot run agent: Missing credentials\n"]
|
||||
lines.append("The following tools require credentials that are not set:\n")
|
||||
|
||||
for cred_name, spec in missing:
|
||||
# Find which of the requested tools need this credential
|
||||
affected_tools = [t for t in tool_names if t in spec.tools]
|
||||
tools_str = ", ".join(affected_tools)
|
||||
|
||||
lines.append(f" {tools_str} requires {spec.env_var}")
|
||||
if spec.description:
|
||||
lines.append(f" {spec.description}")
|
||||
if spec.help_url:
|
||||
lines.append(f" Get an API key at: {spec.help_url}")
|
||||
lines.append(f" Set via: export {spec.env_var}=your_key")
|
||||
lines.append("")
|
||||
|
||||
lines.append("Set these environment variables and re-run the agent.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_missing_for_node_types(
|
||||
self, node_types: List[str]
|
||||
) -> List[Tuple[str, CredentialSpec]]:
|
||||
"""
|
||||
Get list of missing credentials for the given node types.
|
||||
|
||||
Args:
|
||||
node_types: List of node types to check (e.g., ['llm_generate', 'llm_tool_use'])
|
||||
|
||||
Returns:
|
||||
List of (credential_name, spec) tuples for missing credentials
|
||||
"""
|
||||
missing: List[Tuple[str, CredentialSpec]] = []
|
||||
checked: set[str] = set()
|
||||
|
||||
for node_type in node_types:
|
||||
cred_name = self._node_type_to_cred.get(node_type)
|
||||
if cred_name is None:
|
||||
# Node type doesn't require credentials
|
||||
continue
|
||||
if cred_name in checked:
|
||||
# Already checked this credential
|
||||
continue
|
||||
checked.add(cred_name)
|
||||
|
||||
spec = self._specs[cred_name]
|
||||
if spec.required and not self.is_available(cred_name):
|
||||
missing.append((cred_name, spec))
|
||||
|
||||
return missing
|
||||
|
||||
def validate_for_node_types(self, node_types: List[str]) -> None:
|
||||
"""
|
||||
Validate that all credentials required by the given node types are available.
|
||||
|
||||
Args:
|
||||
node_types: List of node types to validate credentials for
|
||||
|
||||
Raises:
|
||||
CredentialError: If any required credentials are missing
|
||||
|
||||
Example:
|
||||
creds = CredentialManager()
|
||||
creds.validate_for_node_types(["llm_generate", "llm_tool_use"])
|
||||
# Raises CredentialError if ANTHROPIC_API_KEY is not set
|
||||
"""
|
||||
missing = self.get_missing_for_node_types(node_types)
|
||||
|
||||
if missing:
|
||||
raise CredentialError(
|
||||
self._format_missing_node_type_error(missing, node_types)
|
||||
)
|
||||
|
||||
def _format_missing_node_type_error(
|
||||
self,
|
||||
missing: List[Tuple[str, CredentialSpec]],
|
||||
node_types: List[str],
|
||||
) -> str:
|
||||
"""Format a clear, actionable error message for missing node type credentials."""
|
||||
lines = ["Cannot run agent: Missing credentials\n"]
|
||||
lines.append("The following node types require credentials that are not set:\n")
|
||||
|
||||
for cred_name, spec in missing:
|
||||
# Find which of the requested node types need this credential
|
||||
affected_types = [t for t in node_types if t in spec.node_types]
|
||||
types_str = ", ".join(affected_types)
|
||||
|
||||
lines.append(f" {types_str} nodes require {spec.env_var}")
|
||||
if spec.description:
|
||||
lines.append(f" {spec.description}")
|
||||
if spec.help_url:
|
||||
lines.append(f" Get an API key at: {spec.help_url}")
|
||||
lines.append(f" Set via: export {spec.env_var}=your_key")
|
||||
lines.append("")
|
||||
|
||||
lines.append("Set these environment variables and re-run the agent.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def validate_startup(self) -> None:
|
||||
"""
|
||||
Validate that all startup-required credentials are present.
|
||||
|
||||
This should be called at server startup (e.g., in mcp_server.py).
|
||||
Credentials with startup_required=True must be set before the server starts.
|
||||
|
||||
Raises:
|
||||
CredentialError: If any startup-required credentials are missing
|
||||
|
||||
Example:
|
||||
creds = CredentialManager()
|
||||
creds.validate_startup() # Fails if ANTHROPIC_API_KEY is not set
|
||||
"""
|
||||
missing: List[Tuple[str, CredentialSpec]] = []
|
||||
|
||||
for cred_name, spec in self._specs.items():
|
||||
if spec.startup_required and not self.is_available(cred_name):
|
||||
missing.append((cred_name, spec))
|
||||
|
||||
if missing:
|
||||
raise CredentialError(self._format_startup_error(missing))
|
||||
|
||||
def _format_startup_error(
|
||||
self,
|
||||
missing: List[Tuple[str, CredentialSpec]],
|
||||
) -> str:
|
||||
"""Format a clear, actionable error message for missing startup credentials."""
|
||||
lines = ["Server startup failed: Missing required credentials\n"]
|
||||
|
||||
for cred_name, spec in missing:
|
||||
lines.append(f" {spec.env_var}")
|
||||
if spec.description:
|
||||
lines.append(f" {spec.description}")
|
||||
if spec.help_url:
|
||||
lines.append(f" Get an API key at: {spec.help_url}")
|
||||
lines.append(f" Set via: export {spec.env_var}=your_key")
|
||||
lines.append("")
|
||||
|
||||
lines.append("Set these environment variables and restart the server.")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
LLM provider credentials.
|
||||
|
||||
Contains credentials for language model providers like Anthropic, OpenAI, etc.
|
||||
"""
|
||||
from .base import CredentialSpec
|
||||
|
||||
LLM_CREDENTIALS = {
|
||||
"anthropic": CredentialSpec(
|
||||
env_var="ANTHROPIC_API_KEY",
|
||||
tools=[],
|
||||
node_types=["llm_generate", "llm_tool_use"],
|
||||
required=True,
|
||||
startup_required=True,
|
||||
help_url="https://console.anthropic.com/settings/keys",
|
||||
description="API key for Anthropic Claude models (required for testing)",
|
||||
),
|
||||
# Future LLM providers:
|
||||
# "openai": CredentialSpec(
|
||||
# env_var="OPENAI_API_KEY",
|
||||
# tools=[],
|
||||
# node_types=["openai_generate"],
|
||||
# required=False,
|
||||
# startup_required=False,
|
||||
# help_url="https://platform.openai.com/api-keys",
|
||||
# description="API key for OpenAI models",
|
||||
# ),
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Search tool credentials.
|
||||
|
||||
Contains credentials for search providers like Brave Search, Google, Bing, etc.
|
||||
"""
|
||||
from .base import CredentialSpec
|
||||
|
||||
SEARCH_CREDENTIALS = {
|
||||
"brave_search": CredentialSpec(
|
||||
env_var="BRAVE_SEARCH_API_KEY",
|
||||
tools=["web_search"],
|
||||
node_types=[],
|
||||
required=True,
|
||||
startup_required=False,
|
||||
help_url="https://brave.com/search/api/",
|
||||
description="API key for Brave Search",
|
||||
),
|
||||
# Future search providers:
|
||||
# "google_search": CredentialSpec(
|
||||
# env_var="GOOGLE_SEARCH_API_KEY",
|
||||
# tools=["google_search"],
|
||||
# node_types=[],
|
||||
# required=True,
|
||||
# startup_required=False,
|
||||
# help_url="https://developers.google.com/custom-search/v1/overview",
|
||||
# description="API key for Google Custom Search",
|
||||
# ),
|
||||
}
|
||||
@@ -4,14 +4,19 @@ Aden Tools - Tool implementations for FastMCP.
|
||||
Usage:
|
||||
from fastmcp import FastMCP
|
||||
from aden_tools.tools import register_all_tools
|
||||
from aden_tools.credentials import CredentialManager
|
||||
|
||||
mcp = FastMCP("my-server")
|
||||
register_all_tools(mcp)
|
||||
credentials = CredentialManager()
|
||||
register_all_tools(mcp, credentials=credentials)
|
||||
"""
|
||||
from typing import List
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aden_tools.credentials import CredentialManager
|
||||
|
||||
# Import register_tools from each tool module
|
||||
from .example_tool import register_tools as register_example
|
||||
from .file_read_tool import register_tools as register_file_read
|
||||
@@ -31,23 +36,31 @@ from .file_system_toolkits.grep_search import register_tools as register_grep_se
|
||||
from .file_system_toolkits.execute_command_tool import register_tools as register_execute_command
|
||||
|
||||
|
||||
def register_all_tools(mcp: FastMCP) -> List[str]:
|
||||
def register_all_tools(
|
||||
mcp: FastMCP,
|
||||
credentials: Optional["CredentialManager"] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Register all aden-tools with a FastMCP server.
|
||||
|
||||
Args:
|
||||
mcp: FastMCP server instance
|
||||
credentials: Optional CredentialManager for centralized credential access.
|
||||
If not provided, tools fall back to direct os.getenv() calls.
|
||||
|
||||
Returns:
|
||||
List of registered tool names
|
||||
"""
|
||||
# Tools that don't need credentials
|
||||
register_example(mcp)
|
||||
register_file_read(mcp)
|
||||
register_file_write(mcp)
|
||||
register_web_search(mcp)
|
||||
register_web_scrape(mcp)
|
||||
register_pdf_read(mcp)
|
||||
|
||||
# Tools that need credentials (pass credentials if provided)
|
||||
register_web_search(mcp, credentials=credentials)
|
||||
|
||||
# Register file system toolkits
|
||||
register_view_file(mcp)
|
||||
register_write_to_file(mcp)
|
||||
|
||||
@@ -7,12 +7,19 @@ Returns search results with titles, URLs, and snippets.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aden_tools.credentials import CredentialManager
|
||||
|
||||
def register_tools(mcp: FastMCP) -> None:
|
||||
|
||||
def register_tools(
|
||||
mcp: FastMCP,
|
||||
credentials: Optional["CredentialManager"] = None,
|
||||
) -> None:
|
||||
"""Register web search tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@@ -37,7 +44,13 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
Returns:
|
||||
Dict with search results or error dict
|
||||
"""
|
||||
api_key = os.getenv("BRAVE_SEARCH_API_KEY")
|
||||
# Get API key - use CredentialManager if provided, fallback to direct env
|
||||
if credentials is not None:
|
||||
api_key = credentials.get("brave_search")
|
||||
else:
|
||||
# Backward compatibility: direct env access
|
||||
api_key = os.getenv("BRAVE_SEARCH_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
return {
|
||||
"error": "BRAVE_SEARCH_API_KEY environment variable not set",
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from aden_tools.credentials import CredentialManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp() -> FastMCP:
|
||||
@@ -11,6 +13,16 @@ def mcp() -> FastMCP:
|
||||
return FastMCP("test-server")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_credentials() -> CredentialManager:
|
||||
"""Create a CredentialManager with mock test credentials."""
|
||||
return CredentialManager.for_testing({
|
||||
"anthropic": "test-anthropic-api-key",
|
||||
"brave_search": "test-brave-api-key",
|
||||
# Add other mock credentials as needed
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_text_file(tmp_path: Path) -> Path:
|
||||
"""Create a simple text file for testing."""
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
"""Tests for CredentialManager."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aden_tools.credentials import (
|
||||
CredentialManager,
|
||||
CredentialSpec,
|
||||
CredentialError,
|
||||
CREDENTIAL_SPECS,
|
||||
)
|
||||
|
||||
|
||||
class TestCredentialManager:
|
||||
"""Tests for CredentialManager class."""
|
||||
|
||||
def test_get_returns_env_value(self, monkeypatch):
|
||||
"""get() returns environment variable value."""
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "test-api-key")
|
||||
|
||||
creds = CredentialManager()
|
||||
|
||||
assert creds.get("brave_search") == "test-api-key"
|
||||
|
||||
def test_get_returns_none_when_not_set(self, monkeypatch, tmp_path):
|
||||
"""get() returns None when env var is not set."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
creds = CredentialManager(dotenv_path=tmp_path / ".env")
|
||||
|
||||
assert creds.get("brave_search") is None
|
||||
|
||||
def test_get_raises_for_unknown_credential(self):
|
||||
"""get() raises KeyError for unknown credential name."""
|
||||
creds = CredentialManager()
|
||||
|
||||
with pytest.raises(KeyError) as exc_info:
|
||||
creds.get("unknown_credential")
|
||||
|
||||
assert "unknown_credential" in str(exc_info.value)
|
||||
assert "Available" in str(exc_info.value)
|
||||
|
||||
def test_get_reads_fresh_for_hot_reload(self, monkeypatch):
|
||||
"""get() reads fresh each time to support hot-reload."""
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "original-key")
|
||||
creds = CredentialManager()
|
||||
|
||||
# First call
|
||||
assert creds.get("brave_search") == "original-key"
|
||||
|
||||
# Change env var
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "new-key")
|
||||
|
||||
# Should return the new value (no caching)
|
||||
assert creds.get("brave_search") == "new-key"
|
||||
|
||||
def test_is_available_true_when_set(self, monkeypatch):
|
||||
"""is_available() returns True when credential is set."""
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "test-key")
|
||||
|
||||
creds = CredentialManager()
|
||||
|
||||
assert creds.is_available("brave_search") is True
|
||||
|
||||
def test_is_available_false_when_not_set(self, monkeypatch, tmp_path):
|
||||
"""is_available() returns False when credential is not set."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
creds = CredentialManager(dotenv_path=tmp_path / ".env")
|
||||
|
||||
assert creds.is_available("brave_search") is False
|
||||
|
||||
def test_is_available_false_for_empty_string(self, monkeypatch, tmp_path):
|
||||
"""is_available() returns False for empty string."""
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "")
|
||||
|
||||
creds = CredentialManager(dotenv_path=tmp_path / ".env")
|
||||
|
||||
assert creds.is_available("brave_search") is False
|
||||
|
||||
def test_get_spec_returns_spec(self):
|
||||
"""get_spec() returns the credential spec."""
|
||||
creds = CredentialManager()
|
||||
|
||||
spec = creds.get_spec("brave_search")
|
||||
|
||||
assert spec.env_var == "BRAVE_SEARCH_API_KEY"
|
||||
assert "web_search" in spec.tools
|
||||
|
||||
def test_get_spec_raises_for_unknown(self):
|
||||
"""get_spec() raises KeyError for unknown credential."""
|
||||
creds = CredentialManager()
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
creds.get_spec("unknown")
|
||||
|
||||
|
||||
class TestCredentialManagerToolMapping:
|
||||
"""Tests for tool-to-credential mapping."""
|
||||
|
||||
def test_get_credential_for_tool(self):
|
||||
"""get_credential_for_tool() returns correct credential name."""
|
||||
creds = CredentialManager()
|
||||
|
||||
assert creds.get_credential_for_tool("web_search") == "brave_search"
|
||||
|
||||
def test_get_credential_for_tool_returns_none_for_unknown(self):
|
||||
"""get_credential_for_tool() returns None for tools without credentials."""
|
||||
creds = CredentialManager()
|
||||
|
||||
assert creds.get_credential_for_tool("file_read") is None
|
||||
assert creds.get_credential_for_tool("unknown_tool") is None
|
||||
|
||||
def test_get_missing_for_tools_returns_missing(self, monkeypatch, tmp_path):
|
||||
"""get_missing_for_tools() returns missing required credentials."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
creds = CredentialManager(dotenv_path=tmp_path / ".env")
|
||||
missing = creds.get_missing_for_tools(["web_search", "file_read"])
|
||||
|
||||
assert len(missing) == 1
|
||||
cred_name, spec = missing[0]
|
||||
assert cred_name == "brave_search"
|
||||
assert spec.env_var == "BRAVE_SEARCH_API_KEY"
|
||||
|
||||
def test_get_missing_for_tools_returns_empty_when_all_present(self, monkeypatch):
|
||||
"""get_missing_for_tools() returns empty list when all credentials present."""
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "test-key")
|
||||
|
||||
creds = CredentialManager()
|
||||
missing = creds.get_missing_for_tools(["web_search", "file_read"])
|
||||
|
||||
assert missing == []
|
||||
|
||||
def test_get_missing_for_tools_no_duplicates(self, monkeypatch):
|
||||
"""get_missing_for_tools() doesn't return duplicates for same credential."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
# Create spec where multiple tools share a credential
|
||||
custom_specs = {
|
||||
"shared_cred": CredentialSpec(
|
||||
env_var="SHARED_KEY",
|
||||
tools=["tool_a", "tool_b"],
|
||||
required=True,
|
||||
)
|
||||
}
|
||||
|
||||
creds = CredentialManager(specs=custom_specs)
|
||||
missing = creds.get_missing_for_tools(["tool_a", "tool_b"])
|
||||
|
||||
# Should only appear once even though two tools need it
|
||||
assert len(missing) == 1
|
||||
|
||||
|
||||
class TestCredentialManagerValidation:
|
||||
"""Tests for validate_for_tools() behavior."""
|
||||
|
||||
def test_validate_for_tools_raises_for_missing(self, monkeypatch, tmp_path):
|
||||
"""validate_for_tools() raises CredentialError when required creds missing."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
creds = CredentialManager(dotenv_path=tmp_path / ".env")
|
||||
|
||||
with pytest.raises(CredentialError) as exc_info:
|
||||
creds.validate_for_tools(["web_search"])
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "BRAVE_SEARCH_API_KEY" in error_msg
|
||||
assert "web_search" in error_msg
|
||||
assert "brave.com" in error_msg # help URL
|
||||
|
||||
def test_validate_for_tools_passes_when_present(self, monkeypatch):
|
||||
"""validate_for_tools() succeeds when all required credentials are set."""
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "test-key")
|
||||
|
||||
creds = CredentialManager()
|
||||
|
||||
# Should not raise
|
||||
creds.validate_for_tools(["web_search", "file_read"])
|
||||
|
||||
def test_validate_for_tools_passes_for_tools_without_credentials(self):
|
||||
"""validate_for_tools() succeeds for tools that don't need credentials."""
|
||||
creds = CredentialManager()
|
||||
|
||||
# Should not raise - file_read doesn't need credentials
|
||||
creds.validate_for_tools(["file_read"])
|
||||
|
||||
def test_validate_for_tools_passes_for_empty_list(self):
|
||||
"""validate_for_tools() succeeds for empty tool list."""
|
||||
creds = CredentialManager()
|
||||
|
||||
# Should not raise
|
||||
creds.validate_for_tools([])
|
||||
|
||||
def test_validate_for_tools_skips_optional_credentials(self, monkeypatch):
|
||||
"""validate_for_tools() doesn't fail for missing optional credentials."""
|
||||
custom_specs = {
|
||||
"optional_cred": CredentialSpec(
|
||||
env_var="OPTIONAL_KEY",
|
||||
tools=["optional_tool"],
|
||||
required=False, # Optional
|
||||
)
|
||||
}
|
||||
monkeypatch.delenv("OPTIONAL_KEY", raising=False)
|
||||
|
||||
creds = CredentialManager(specs=custom_specs)
|
||||
|
||||
# Should not raise because credential is optional
|
||||
creds.validate_for_tools(["optional_tool"])
|
||||
|
||||
|
||||
class TestCredentialManagerForTesting:
|
||||
"""Tests for test factory method."""
|
||||
|
||||
def test_for_testing_uses_overrides(self):
|
||||
"""for_testing() uses provided override values."""
|
||||
creds = CredentialManager.for_testing({"brave_search": "mock-key"})
|
||||
|
||||
assert creds.get("brave_search") == "mock-key"
|
||||
|
||||
def test_for_testing_ignores_env(self, monkeypatch):
|
||||
"""for_testing() ignores actual environment variables."""
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "real-key")
|
||||
|
||||
creds = CredentialManager.for_testing({"brave_search": "mock-key"})
|
||||
|
||||
assert creds.get("brave_search") == "mock-key"
|
||||
|
||||
def test_for_testing_validation_passes_with_overrides(self):
|
||||
"""for_testing() credentials pass validation."""
|
||||
creds = CredentialManager.for_testing({"brave_search": "mock-key"})
|
||||
|
||||
# Should not raise
|
||||
creds.validate_for_tools(["web_search"])
|
||||
|
||||
def test_for_testing_validation_fails_without_override(self, monkeypatch, tmp_path):
|
||||
"""for_testing() without override still fails validation."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
creds = CredentialManager.for_testing({}, dotenv_path=tmp_path / ".env") # No overrides
|
||||
|
||||
with pytest.raises(CredentialError):
|
||||
creds.validate_for_tools(["web_search"])
|
||||
|
||||
def test_for_testing_with_custom_specs(self):
|
||||
"""for_testing() works with custom specs."""
|
||||
custom_specs = {
|
||||
"custom_cred": CredentialSpec(
|
||||
env_var="CUSTOM_VAR",
|
||||
tools=["custom_tool"],
|
||||
required=True,
|
||||
)
|
||||
}
|
||||
|
||||
creds = CredentialManager.for_testing(
|
||||
{"custom_cred": "test-value"},
|
||||
specs=custom_specs,
|
||||
)
|
||||
|
||||
assert creds.get("custom_cred") == "test-value"
|
||||
|
||||
|
||||
class TestCredentialSpec:
|
||||
"""Tests for CredentialSpec dataclass."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""CredentialSpec has sensible defaults."""
|
||||
spec = CredentialSpec(env_var="TEST_VAR")
|
||||
|
||||
assert spec.env_var == "TEST_VAR"
|
||||
assert spec.tools == []
|
||||
assert spec.node_types == []
|
||||
assert spec.required is True
|
||||
assert spec.startup_required is False
|
||||
assert spec.help_url == ""
|
||||
assert spec.description == ""
|
||||
|
||||
def test_all_values(self):
|
||||
"""CredentialSpec accepts all values."""
|
||||
spec = CredentialSpec(
|
||||
env_var="API_KEY",
|
||||
tools=["tool_a", "tool_b"],
|
||||
node_types=["llm_generate"],
|
||||
required=False,
|
||||
startup_required=True,
|
||||
help_url="https://example.com",
|
||||
description="Test API key",
|
||||
)
|
||||
|
||||
assert spec.env_var == "API_KEY"
|
||||
assert spec.tools == ["tool_a", "tool_b"]
|
||||
assert spec.node_types == ["llm_generate"]
|
||||
assert spec.required is False
|
||||
assert spec.startup_required is True
|
||||
assert spec.help_url == "https://example.com"
|
||||
assert spec.description == "Test API key"
|
||||
|
||||
|
||||
class TestCredentialSpecs:
|
||||
"""Tests for the CREDENTIAL_SPECS constant."""
|
||||
|
||||
def test_brave_search_spec_exists(self):
|
||||
"""CREDENTIAL_SPECS includes brave_search."""
|
||||
assert "brave_search" in CREDENTIAL_SPECS
|
||||
|
||||
spec = CREDENTIAL_SPECS["brave_search"]
|
||||
assert spec.env_var == "BRAVE_SEARCH_API_KEY"
|
||||
assert "web_search" in spec.tools
|
||||
assert spec.required is True
|
||||
assert spec.startup_required is False
|
||||
assert "brave.com" in spec.help_url
|
||||
|
||||
def test_anthropic_spec_exists(self):
|
||||
"""CREDENTIAL_SPECS includes anthropic with startup_required=True."""
|
||||
assert "anthropic" in CREDENTIAL_SPECS
|
||||
|
||||
spec = CREDENTIAL_SPECS["anthropic"]
|
||||
assert spec.env_var == "ANTHROPIC_API_KEY"
|
||||
assert spec.tools == []
|
||||
assert "llm_generate" in spec.node_types
|
||||
assert "llm_tool_use" in spec.node_types
|
||||
assert spec.required is True
|
||||
assert spec.startup_required is True
|
||||
assert "anthropic.com" in spec.help_url
|
||||
|
||||
|
||||
class TestNodeTypeValidation:
|
||||
"""Tests for node type credential validation."""
|
||||
|
||||
def test_get_missing_for_node_types_returns_missing(self, monkeypatch, tmp_path):
|
||||
"""get_missing_for_node_types() returns missing credentials."""
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
|
||||
creds = CredentialManager(dotenv_path=tmp_path / ".env")
|
||||
missing = creds.get_missing_for_node_types(["llm_generate", "llm_tool_use"])
|
||||
|
||||
assert len(missing) == 1
|
||||
cred_name, spec = missing[0]
|
||||
assert cred_name == "anthropic"
|
||||
assert spec.env_var == "ANTHROPIC_API_KEY"
|
||||
|
||||
def test_get_missing_for_node_types_returns_empty_when_present(self, monkeypatch):
|
||||
"""get_missing_for_node_types() returns empty when credentials present."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
||||
|
||||
creds = CredentialManager()
|
||||
missing = creds.get_missing_for_node_types(["llm_generate", "llm_tool_use"])
|
||||
|
||||
assert missing == []
|
||||
|
||||
def test_get_missing_for_node_types_ignores_unknown_types(self, monkeypatch):
|
||||
"""get_missing_for_node_types() ignores node types without credentials."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
||||
|
||||
creds = CredentialManager()
|
||||
missing = creds.get_missing_for_node_types(["unknown_type", "another_type"])
|
||||
|
||||
assert missing == []
|
||||
|
||||
def test_validate_for_node_types_raises_for_missing(self, monkeypatch, tmp_path):
|
||||
"""validate_for_node_types() raises CredentialError when missing."""
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
|
||||
creds = CredentialManager(dotenv_path=tmp_path / ".env")
|
||||
|
||||
with pytest.raises(CredentialError) as exc_info:
|
||||
creds.validate_for_node_types(["llm_generate"])
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "ANTHROPIC_API_KEY" in error_msg
|
||||
assert "llm_generate" in error_msg
|
||||
|
||||
def test_validate_for_node_types_passes_when_present(self, monkeypatch):
|
||||
"""validate_for_node_types() passes when credentials present."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
||||
|
||||
creds = CredentialManager()
|
||||
|
||||
# Should not raise
|
||||
creds.validate_for_node_types(["llm_generate", "llm_tool_use"])
|
||||
|
||||
|
||||
class TestStartupValidation:
|
||||
"""Tests for startup credential validation."""
|
||||
|
||||
def test_validate_startup_raises_for_missing(self, monkeypatch, tmp_path):
|
||||
"""validate_startup() raises CredentialError when startup creds missing."""
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
|
||||
creds = CredentialManager(dotenv_path=tmp_path / ".env")
|
||||
|
||||
with pytest.raises(CredentialError) as exc_info:
|
||||
creds.validate_startup()
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "ANTHROPIC_API_KEY" in error_msg
|
||||
assert "Server startup failed" in error_msg
|
||||
|
||||
def test_validate_startup_passes_when_present(self, monkeypatch):
|
||||
"""validate_startup() passes when all startup creds are set."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
||||
|
||||
creds = CredentialManager()
|
||||
|
||||
# Should not raise
|
||||
creds.validate_startup()
|
||||
|
||||
def test_validate_startup_ignores_non_startup_creds(self, monkeypatch):
|
||||
"""validate_startup() ignores credentials without startup_required=True."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
creds = CredentialManager()
|
||||
|
||||
# Should not raise - BRAVE_SEARCH_API_KEY is not startup_required
|
||||
creds.validate_startup()
|
||||
|
||||
def test_validate_startup_with_test_overrides(self):
|
||||
"""validate_startup() works with for_testing() overrides."""
|
||||
creds = CredentialManager.for_testing({"anthropic": "test-key"})
|
||||
|
||||
# Should not raise
|
||||
creds.validate_startup()
|
||||
|
||||
|
||||
class TestDotenvReading:
|
||||
"""Tests for .env file reading (hot-reload support)."""
|
||||
|
||||
def test_reads_from_dotenv_file(self, tmp_path, monkeypatch):
|
||||
"""CredentialManager reads credentials from .env file."""
|
||||
# Ensure env var is not set
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
# Create a .env file
|
||||
dotenv_file = tmp_path / ".env"
|
||||
dotenv_file.write_text("BRAVE_SEARCH_API_KEY=dotenv-key\n")
|
||||
|
||||
creds = CredentialManager(dotenv_path=dotenv_file)
|
||||
|
||||
assert creds.get("brave_search") == "dotenv-key"
|
||||
|
||||
def test_env_var_takes_precedence_over_dotenv(self, tmp_path, monkeypatch):
|
||||
"""os.environ takes precedence over .env file."""
|
||||
# Set both env var and .env file
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "env-key")
|
||||
|
||||
dotenv_file = tmp_path / ".env"
|
||||
dotenv_file.write_text("BRAVE_SEARCH_API_KEY=dotenv-key\n")
|
||||
|
||||
creds = CredentialManager(dotenv_path=dotenv_file)
|
||||
|
||||
# Should return env var value, not dotenv value
|
||||
assert creds.get("brave_search") == "env-key"
|
||||
|
||||
def test_missing_dotenv_file_returns_none(self, tmp_path, monkeypatch):
|
||||
"""Missing .env file doesn't crash, returns None."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
# Point to non-existent file
|
||||
dotenv_file = tmp_path / ".env" # Not created
|
||||
|
||||
creds = CredentialManager(dotenv_path=dotenv_file)
|
||||
|
||||
assert creds.get("brave_search") is None
|
||||
|
||||
def test_hot_reload_from_dotenv(self, tmp_path, monkeypatch):
|
||||
"""CredentialManager picks up changes to .env file without restart."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
dotenv_file = tmp_path / ".env"
|
||||
dotenv_file.write_text("BRAVE_SEARCH_API_KEY=original-key\n")
|
||||
|
||||
creds = CredentialManager(dotenv_path=dotenv_file)
|
||||
|
||||
# First read
|
||||
assert creds.get("brave_search") == "original-key"
|
||||
|
||||
# Update the .env file (simulating user adding credential)
|
||||
dotenv_file.write_text("BRAVE_SEARCH_API_KEY=updated-key\n")
|
||||
|
||||
# Should read the new value (hot-reload)
|
||||
assert creds.get("brave_search") == "updated-key"
|
||||
|
||||
def test_is_available_works_with_dotenv(self, tmp_path, monkeypatch):
|
||||
"""is_available() works correctly with .env file credentials."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
dotenv_file = tmp_path / ".env"
|
||||
dotenv_file.write_text("BRAVE_SEARCH_API_KEY=dotenv-key\n")
|
||||
|
||||
creds = CredentialManager(dotenv_path=dotenv_file)
|
||||
|
||||
assert creds.is_available("brave_search") is True
|
||||
|
||||
def test_validation_works_with_dotenv(self, tmp_path, monkeypatch):
|
||||
"""validate_for_tools() works with .env file credentials."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
dotenv_file = tmp_path / ".env"
|
||||
dotenv_file.write_text("BRAVE_SEARCH_API_KEY=dotenv-key\n")
|
||||
|
||||
creds = CredentialManager(dotenv_path=dotenv_file)
|
||||
|
||||
# Should not raise because credential is available in .env
|
||||
creds.validate_for_tools(["web_search"])
|
||||
|
||||
def test_dotenv_with_multiple_credentials(self, tmp_path, monkeypatch):
|
||||
"""CredentialManager reads multiple credentials from .env file."""
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
dotenv_file = tmp_path / ".env"
|
||||
dotenv_file.write_text(
|
||||
"ANTHROPIC_API_KEY=anthropic-key\n"
|
||||
"BRAVE_SEARCH_API_KEY=brave-key\n"
|
||||
)
|
||||
|
||||
creds = CredentialManager(dotenv_path=dotenv_file)
|
||||
|
||||
assert creds.get("anthropic") == "anthropic-key"
|
||||
assert creds.get("brave_search") == "brave-key"
|
||||
|
||||
def test_dotenv_with_quoted_values(self, tmp_path, monkeypatch):
|
||||
"""CredentialManager handles quoted values in .env file."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
dotenv_file = tmp_path / ".env"
|
||||
dotenv_file.write_text('BRAVE_SEARCH_API_KEY="quoted-key"\n')
|
||||
|
||||
creds = CredentialManager(dotenv_path=dotenv_file)
|
||||
|
||||
assert creds.get("brave_search") == "quoted-key"
|
||||
|
||||
def test_dotenv_with_comments(self, tmp_path, monkeypatch):
|
||||
"""CredentialManager ignores comments in .env file."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
dotenv_file = tmp_path / ".env"
|
||||
dotenv_file.write_text(
|
||||
"# This is a comment\n"
|
||||
"BRAVE_SEARCH_API_KEY=key-after-comment\n"
|
||||
)
|
||||
|
||||
creds = CredentialManager(dotenv_path=dotenv_file)
|
||||
|
||||
assert creds.get("brave_search") == "key-after-comment"
|
||||
|
||||
def test_overrides_take_precedence_over_dotenv(self, tmp_path, monkeypatch):
|
||||
"""Test override values take precedence over .env file."""
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
dotenv_file = tmp_path / ".env"
|
||||
dotenv_file.write_text("BRAVE_SEARCH_API_KEY=dotenv-key\n")
|
||||
|
||||
creds = CredentialManager.for_testing(
|
||||
{"brave_search": "override-key"},
|
||||
)
|
||||
# Note: for_testing doesn't use dotenv_path, but we test the principle
|
||||
# that _overrides always win
|
||||
|
||||
assert creds.get("brave_search") == "override-key"
|
||||
@@ -332,3 +332,59 @@ You can register any MCP server that follows the Model Context Protocol specific
|
||||
- Verify you registered at least one MCP server
|
||||
- Check `get_session_status` to see `mcp_servers_count > 0`
|
||||
- Re-export the agent after registering servers
|
||||
|
||||
## Credential Validation
|
||||
|
||||
When adding nodes with tools that require API keys (like `web_search`), the agent builder automatically validates that the required credentials are available.
|
||||
|
||||
### How It Works
|
||||
|
||||
When you call `add_node` or `update_node` with a `tools` parameter, the agent builder:
|
||||
|
||||
1. Checks which tools require credentials (e.g., `web_search` requires `BRAVE_SEARCH_API_KEY`)
|
||||
2. Validates those credentials are set in the environment or `.env` file
|
||||
3. Returns an error if any credentials are missing
|
||||
|
||||
### Missing Credentials Error
|
||||
|
||||
If credentials are missing, you'll receive a response like:
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"errors": ["Missing credentials for tools: ['BRAVE_SEARCH_API_KEY']"],
|
||||
"missing_credentials": [
|
||||
{
|
||||
"credential": "brave_search",
|
||||
"env_var": "BRAVE_SEARCH_API_KEY",
|
||||
"tools_affected": ["web_search"],
|
||||
"help_url": "https://brave.com/search/api/",
|
||||
"description": "API key for Brave Search"
|
||||
}
|
||||
],
|
||||
"action_required": "Add the credentials to your .env file and retry",
|
||||
"example": "Add to .env:\nBRAVE_SEARCH_API_KEY=your_key_here",
|
||||
"message": "Cannot add node: missing API credentials. Add them to .env and retry this command."
|
||||
}
|
||||
```
|
||||
|
||||
### Fixing Credential Errors
|
||||
|
||||
1. Get the required API key from the URL in `help_url`
|
||||
2. Add it to your environment:
|
||||
```bash
|
||||
# Option 1: Export directly
|
||||
export BRAVE_SEARCH_API_KEY=your-key-here
|
||||
|
||||
# Option 2: Add to aden-tools/.env
|
||||
echo "BRAVE_SEARCH_API_KEY=your-key-here" >> aden-tools/.env
|
||||
```
|
||||
3. Retry the `add_node` command
|
||||
|
||||
### Required Credentials by Tool
|
||||
|
||||
| Tool | Credential | Get Key |
|
||||
|------|------------|---------|
|
||||
| `web_search` | `BRAVE_SEARCH_API_KEY` | [brave.com/search/api](https://brave.com/search/api/) |
|
||||
|
||||
Note: The MCP server itself requires `ANTHROPIC_API_KEY` at startup for LLM operations.
|
||||
|
||||
@@ -394,6 +394,55 @@ def set_goal(
|
||||
}, default=str)
|
||||
|
||||
|
||||
def _validate_tool_credentials(tools_list: list[str]) -> dict | None:
|
||||
"""
|
||||
Validate that credentials are available for the specified tools.
|
||||
|
||||
Returns None if all credentials are available, or an error dict if any are missing.
|
||||
"""
|
||||
if not tools_list:
|
||||
return None
|
||||
|
||||
try:
|
||||
from aden_tools.credentials import CredentialManager
|
||||
|
||||
cred_manager = CredentialManager()
|
||||
missing_creds = cred_manager.get_missing_for_tools(tools_list)
|
||||
|
||||
if missing_creds:
|
||||
cred_errors = []
|
||||
for cred_name, spec in missing_creds:
|
||||
affected_tools = [t for t in tools_list if t in spec.tools]
|
||||
cred_errors.append({
|
||||
"credential": cred_name,
|
||||
"env_var": spec.env_var,
|
||||
"tools_affected": affected_tools,
|
||||
"help_url": spec.help_url,
|
||||
"description": spec.description,
|
||||
})
|
||||
|
||||
return {
|
||||
"valid": False,
|
||||
"errors": [f"Missing credentials for tools: {[e['env_var'] for e in cred_errors]}"],
|
||||
"missing_credentials": cred_errors,
|
||||
"action_required": "Add the credentials to your .env file and retry",
|
||||
"example": f"Add to .env:\n{cred_errors[0]['env_var']}=your_key_here",
|
||||
"message": "Cannot add node: missing API credentials. Add them to .env and retry this command.",
|
||||
}
|
||||
except ImportError as e:
|
||||
# Return a warning that credential validation was skipped
|
||||
return {
|
||||
"valid": True,
|
||||
"warnings": [
|
||||
f"⚠️ Credential validation SKIPPED: aden_tools not available ({e}). "
|
||||
"Tools may fail at runtime if credentials are missing. "
|
||||
"Add aden-tools/src to PYTHONPATH to enable validation."
|
||||
],
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def add_node(
|
||||
node_id: Annotated[str, "Unique identifier for the node"],
|
||||
@@ -415,6 +464,11 @@ def add_node(
|
||||
tools_list = json.loads(tools)
|
||||
routes_dict = json.loads(routes)
|
||||
|
||||
# Validate credentials for tools BEFORE adding the node
|
||||
cred_error = _validate_tool_credentials(tools_list)
|
||||
if cred_error:
|
||||
return json.dumps(cred_error)
|
||||
|
||||
# Check for duplicate
|
||||
if any(n.id == node_id for n in session.nodes):
|
||||
return json.dumps({"valid": False, "errors": [f"Node '{node_id}' already exists"]})
|
||||
@@ -582,6 +636,13 @@ def update_node(
|
||||
if not node:
|
||||
return json.dumps({"valid": False, "errors": [f"Node '{node_id}' not found"]})
|
||||
|
||||
# Validate credentials for new tools BEFORE updating
|
||||
if tools:
|
||||
tools_list = json.loads(tools)
|
||||
cred_error = _validate_tool_credentials(tools_list)
|
||||
if cred_error:
|
||||
return json.dumps(cred_error)
|
||||
|
||||
# Update fields if provided
|
||||
if name:
|
||||
node.name = name
|
||||
|
||||
@@ -44,6 +44,7 @@ class ValidationResult:
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
missing_tools: list[str] = field(default_factory=list)
|
||||
missing_credentials: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def load_agent_export(data: str | dict) -> tuple[GraphSpec, Goal]:
|
||||
@@ -501,19 +502,51 @@ class AgentRunner:
|
||||
if missing_tools:
|
||||
warnings.append(f"Missing tool implementations: {', '.join(missing_tools)}")
|
||||
|
||||
# Check for LLM nodes without LLM
|
||||
has_llm_nodes = any(
|
||||
node.node_type in ("llm_generate", "llm_tool_use")
|
||||
for node in self.graph.nodes
|
||||
)
|
||||
if has_llm_nodes and not os.environ.get("ANTHROPIC_API_KEY"):
|
||||
warnings.append("Agent has LLM nodes but ANTHROPIC_API_KEY not set")
|
||||
# Check credentials for required tools and node types
|
||||
missing_credentials = []
|
||||
try:
|
||||
from aden_tools.credentials import CredentialManager
|
||||
|
||||
cred_manager = CredentialManager()
|
||||
|
||||
# Check tool credentials (Tier 2)
|
||||
missing_creds = cred_manager.get_missing_for_tools(info.required_tools)
|
||||
for cred_name, spec in missing_creds:
|
||||
missing_credentials.append(spec.env_var)
|
||||
affected_tools = [t for t in info.required_tools if t in spec.tools]
|
||||
tools_str = ", ".join(affected_tools)
|
||||
warning_msg = f"Missing {spec.env_var} for {tools_str}"
|
||||
if spec.help_url:
|
||||
warning_msg += f"\n Get it at: {spec.help_url}"
|
||||
warnings.append(warning_msg)
|
||||
|
||||
# Check node type credentials (e.g., ANTHROPIC_API_KEY for LLM nodes)
|
||||
node_types = list(set(node.node_type for node in self.graph.nodes))
|
||||
missing_node_creds = cred_manager.get_missing_for_node_types(node_types)
|
||||
for cred_name, spec in missing_node_creds:
|
||||
if spec.env_var not in missing_credentials: # Avoid duplicates
|
||||
missing_credentials.append(spec.env_var)
|
||||
affected_types = [t for t in node_types if t in spec.node_types]
|
||||
types_str = ", ".join(affected_types)
|
||||
warning_msg = f"Missing {spec.env_var} for {types_str} nodes"
|
||||
if spec.help_url:
|
||||
warning_msg += f"\n Get it at: {spec.help_url}"
|
||||
warnings.append(warning_msg)
|
||||
except ImportError:
|
||||
# aden_tools not installed - fall back to direct check
|
||||
has_llm_nodes = any(
|
||||
node.node_type in ("llm_generate", "llm_tool_use")
|
||||
for node in self.graph.nodes
|
||||
)
|
||||
if has_llm_nodes and not os.environ.get("ANTHROPIC_API_KEY"):
|
||||
warnings.append("Agent has LLM nodes but ANTHROPIC_API_KEY not set")
|
||||
|
||||
return ValidationResult(
|
||||
valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
missing_tools=missing_tools,
|
||||
missing_credentials=missing_credentials,
|
||||
)
|
||||
|
||||
async def can_handle(self, request: dict, llm: LLMProvider | None = None) -> "CapabilityResponse":
|
||||
|
||||
@@ -97,6 +97,26 @@ hive/
|
||||
└── config.yaml # Application configuration
|
||||
```
|
||||
|
||||
## AI Agent Tools Setup (Optional)
|
||||
|
||||
If you're using the AI agent framework with aden-tools:
|
||||
|
||||
```bash
|
||||
# 1. Navigate to aden-tools
|
||||
cd aden-tools
|
||||
|
||||
# 2. Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# 3. Add your API keys to .env
|
||||
# - ANTHROPIC_API_KEY: Required for LLM operations
|
||||
# - BRAVE_SEARCH_API_KEY: Required for web search tool
|
||||
```
|
||||
|
||||
Get your API keys:
|
||||
- **Anthropic**: [console.anthropic.com](https://console.anthropic.com/)
|
||||
- **Brave Search**: [brave.com/search/api](https://brave.com/search/api/)
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Configure the Application**: See [Configuration Guide](configuration.md)
|
||||
|
||||
Reference in New Issue
Block a user