Skip to main content

Overview

Shannon supports three ways to add custom tools:
MethodBest ForCode ChangesRestart Required
MCP ToolsExternal HTTP APIs, rapid prototypingNone✅ Service only
OpenAPI ToolsREST APIs with OpenAPI specsNone✅ Service only
Built-in ToolsComplex logic, database access, performancePython code✅ Service only
No Proto/Rust/Go changes required - all tools use generic containers for maximum flexibility.
Key Features:
  • ✅ Dynamic registration via API or YAML config
  • ✅ Built-in rate limiting and circuit breakers
  • ✅ Domain allowlisting for security
  • ✅ Cost tracking and budget enforcement

Quick Start: Adding MCP Tools

MCP (Model Context Protocol) tools let you integrate any HTTP endpoint as a Shannon tool with zero code changes.
1

Add Tool Definition

Edit config/shannon.yaml under the mcp_tools section:
mcp_tools:
  weather_forecast:
    enabled: true
    url: "https://api.weather.com/v1/forecast"
    func_name: "get_weather"
    description: "Get weather forecast for a location"
    category: "data"
    cost_per_use: 0.001
    parameters:
      - name: "location"
        type: "string"
        required: true
        description: "City name or coordinates"
      - name: "units"
        type: "string"
        required: false
        description: "Temperature units (celsius/fahrenheit)"
        enum: ["celsius", "fahrenheit"]
    headers:
      X-API-Key: "${WEATHER_API_KEY}"  # Resolves from .env
Required Fields:
  • enabled: Set to true to activate
  • url: HTTP endpoint (must be POST, accepts JSON)
  • func_name: Internal function name
  • description: Clear description shown to LLM
  • category: Tool category (e.g., search, data, analytics, code)
  • cost_per_use: Estimated cost in USD
  • parameters: Array of parameter definitions
2

Configure Domain Access

For Development (permissive):Add to .env:
MCP_ALLOWED_DOMAINS=*  # Wildcard - allows all domains
For Production (recommended):
MCP_ALLOWED_DOMAINS=localhost,127.0.0.1,api.weather.com,api.example.com
3

Add API Keys

Add your API key to .env:
# MCP Tool API Keys
WEATHER_API_KEY=your_api_key_here
STOCK_API_KEY=your_stock_key_here
4

Restart Service

You must recreate the service (not just restart):
docker compose -f deploy/compose/docker-compose.yml up -d --force-recreate llm-service
Wait for health check:
docker inspect shannon-llm-service-1 --format='{{.State.Health.Status}}'
5

Verify Registration

Check logs:
docker compose logs llm-service | grep "Loaded MCP tool"
List tools via API:
curl http://localhost:8000/tools/list | jq .
Get tool schema:
curl http://localhost:8000/tools/weather_forecast/schema | jq .
6

Test Your Tool

Direct execution:
curl -X POST http://localhost:8000/tools/execute \
  -H "Content-Type: application/json" \
  -d '{
    "tool_name": "weather_forecast",
    "parameters": {"location": "Tokyo", "units": "celsius"}
  }'
Via workflow:
SESSION_ID="test-$(date +%s)" ./scripts/submit_task.sh "What's the weather forecast for Tokyo?"

MCP Request Convention

Shannon sends POST requests in this format:
{
  "function": "get_weather",
  "args": {
    "location": "Tokyo",
    "units": "celsius"
  }
}
Your endpoint should return JSON:
{
  "temperature": 18,
  "condition": "Cloudy",
  "humidity": 65
}

Alternative: Runtime API Registration

For development/testing only (tools lost on restart):
# Set admin token in .env
MCP_REGISTER_TOKEN=your_secret_token

# Register tool
curl -X POST http://localhost:8000/tools/mcp/register \
  -H "Authorization: Bearer your_secret_token" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "weather_forecast",
    "url": "https://api.weather.com/v1/forecast",
    "func_name": "get_weather",
    "description": "Get weather forecast",
    "category": "data",
    "parameters": [
      {"name": "location", "type": "string", "required": true},
      {"name": "units", "type": "string", "enum": ["celsius", "fahrenheit"]}
    ]
  }'

Adding OpenAPI Tools

For REST APIs with OpenAPI 3.x specifications, Shannon can automatically generate tools.
For domain-specific APIs requiring custom transformations, see the Vendor Adapter Pattern section below or the comprehensive Vendor Adapters Guide.

Features

Supported:
  • ✅ OpenAPI 3.0 and 3.1 specs
  • ✅ URL-based or inline spec loading
  • ✅ JSON request/response bodies
  • ✅ Path and query parameters
  • ✅ Bearer, API Key (header/query), Basic auth
  • ✅ Operation filtering by operationId or tags
  • ✅ Circuit breaker (5 failures → 60s cooldown)
  • ✅ Retry logic with exponential backoff (3 retries, configurable via OPENAPI_RETRIES)
  • ✅ Configurable rate limits and timeouts
  • ✅ Relative server URLs (resolved against spec URL)
  • ✅ Basic $ref resolution (local references to #/components/schemas/*)

Limitations

Shannon OpenAPI integration is production-ready for ~70% of REST APIs (JSON-based with simple auth). The following features are not yet supported:
  • Cannot upload files or binary data
  • Workaround: Use base64-encoded files in JSON body
  • Affected: Image generation, file processing, document upload APIs
  • No OAuth 2.0 flows (Authorization Code, Client Credentials)
  • Can only use Bearer tokens (manually obtained)
  • Affected: Google APIs, GitHub, Slack, Twitter, etc.
  • Workaround: Manually obtain OAuth token and use bearer auth_type
  • No style, explode, or deepObject serialization
  • Only basic path/query parameter substitution
  • Affected: APIs with complex array/object query parameters
  • No remote $ref resolution (e.g., https://example.com/schemas/Pet.json)
  • Only local refs (#/components/...) supported
  • Workaround: Merge external schemas into single spec file
  • No allOf, oneOf, anyOf support
  • Only basic type mapping
  • Affected: APIs with polymorphic types or complex validation
  • No application/x-www-form-urlencoded content type
  • Only JSON request bodies supported
What Works Well:
  • ✅ Simple REST APIs with JSON request/response
  • ✅ APIs with Bearer/API Key/Basic authentication
  • ✅ Read-heavy operations (GET requests)
  • ✅ Well-structured specs with local $ref references
  • ✅ Path and query parameters (primitives)
For specs with relative server URLs (e.g., /api/v3), you must provide the spec via spec_url (not spec_inline) so Shannon can resolve the full base URL.

Quick Start

1

Add Tool Definition

Edit config/shannon.yaml under openapi_tools:
openapi_tools:
  petstore:
    enabled: true
    spec_url: "https://petstore3.swagger.io/api/v3/openapi.json"
    # OR use inline spec:
    # spec_inline: |
    #   <paste OpenAPI JSON/YAML here>

    auth_type: "api_key"  # none|api_key|bearer|basic
    auth_config:
      api_key_name: "X-API-Key"           # Header name or query param name
      api_key_location: "header"          # header|query
      api_key_value: "$PETSTORE_API_KEY"  # Use $ prefix for env vars

    category: "data"
    base_cost_per_use: 0.001
    rate_limit: 30                        # Requests per minute
    timeout_seconds: 30                   # Request timeout
    max_response_bytes: 10485760          # Max response size (10MB)

    # Optional: Filter to specific operations
    operations:
      - "getPetById"
      - "findPetsByStatus"

    # Optional: Filter by tags
    # tags:
    #   - "pet"

    # Optional: Override base URL from spec
    # base_url: "https://custom-petstore.example.com"
2

Configure Environment

Add to .env:
# OpenAPI Security
OPENAPI_ALLOWED_DOMAINS=*                # Use * for dev, specific domains for prod
OPENAPI_MAX_SPEC_SIZE=5242880            # 5MB default
OPENAPI_FETCH_TIMEOUT=30                 # Seconds

# API Keys
PETSTORE_API_KEY=your_key_here
GITHUB_TOKEN=ghp_xxxxxxxxxxxxx
OPENWEATHER_API_KEY=your_key
API_USERNAME=username
API_PASSWORD=password

# Same registration token as MCP
MCP_REGISTER_TOKEN=your_admin_token
3

Restart Service

docker compose -f deploy/compose/docker-compose.yml up -d --force-recreate llm-service
4

Verify & Test

Validate spec first:
curl -X POST http://localhost:8000/tools/openapi/validate \
  -H "Content-Type: application/json" \
  -d '{"spec_url": "https://petstore3.swagger.io/api/v3/openapi.json"}' | jq .
Response:
{
  "valid": true,
  "operations_count": 19,
  "operations": [
    {"operation_id": "getPetById", "method": "GET", "path": "/pet/{petId}"},
    {"operation_id": "addPet", "method": "POST", "path": "/pet"}
  ],
  "base_url": "https://petstore3.swagger.io/api/v3"
}
Execute tool:
curl -X POST http://localhost:8000/tools/execute \
  -H "Content-Type: application/json" \
  -d '{
    "tool_name": "getPetById",
    "parameters": {"petId": 1}
  }' | jq .

Authentication Examples

github:
  enabled: true
  spec_url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json"
  auth_type: "bearer"
  auth_config:
    token: "$GITHUB_TOKEN"
  operations:
    - "repos/get"
    - "repos/list-for-user"

Adding Built-in Python Tools

For complex logic, database access, or performance-critical operations.

When to Use Built-in Tools

Use built-in tools when:
  • Need direct database/Redis access
  • Require complex Python libraries (pandas, numpy)
  • Performance-critical (avoid HTTP roundtrip)
  • Need session state management
  • Implement security-sensitive operations
Use MCP/OpenAPI instead when:
  • Integrating external APIs
  • Want no-code deployment
  • Prototyping quickly
  • Third-party service integration
1

Create Tool Class

Create file in python/llm-service/llm_service/tools/builtin/my_custom_tool.py:
from typing import Any, Dict, List, Optional
from ..base import Tool, ToolMetadata, ToolParameter, ToolParameterType, ToolResult

class MyCustomTool(Tool):
    """
    Brief description of what this tool does.
    """

    def _get_metadata(self) -> ToolMetadata:
        """Define tool metadata."""
        return ToolMetadata(
            name="my_custom_tool",
            version="1.0.0",
            description="Clear description for LLM to understand when/how to use this tool",
            category="custom",  # search, data, analytics, code, file, custom
            author="Your Name",
            requires_auth=False,
            timeout_seconds=30,
            memory_limit_mb=128,
            sandboxed=False,
            session_aware=False,  # Set True if tool needs session state
            dangerous=False,      # Set True for file writes, code execution
            cost_per_use=0.001,   # USD per invocation
            rate_limit=60,        # Requests per minute (enforced by base class)
        )

    def _get_parameters(self) -> List[ToolParameter]:
        """Define tool parameters with validation."""
        return [
            ToolParameter(
                name="required_param",
                type=ToolParameterType.STRING,
                description="Description shown to LLM",
                required=True,
            ),
            ToolParameter(
                name="optional_number",
                type=ToolParameterType.INTEGER,
                description="An optional number parameter",
                required=False,
                default=10,
                min_value=1,
                max_value=100,
            ),
            ToolParameter(
                name="choice_param",
                type=ToolParameterType.STRING,
                description="Parameter with predefined choices",
                required=False,
                enum=["option1", "option2", "option3"],
            ),
        ]

    async def _execute_impl(
        self,
        session_context: Optional[Dict] = None,
        **kwargs
    ) -> ToolResult:
        """
        Execute the tool logic.

        Args:
            session_context: Session data if session_aware=True
            **kwargs: Tool parameters (validated automatically)

        Returns:
            ToolResult with success/error status
        """
        try:
            # Extract parameters (already validated by base class)
            required_param = kwargs.get("required_param")
            optional_number = kwargs.get("optional_number", 10)
            choice_param = kwargs.get("choice_param")

            # Your tool logic here
            result = self._do_work(required_param, optional_number, choice_param)

            return ToolResult(
                success=True,
                output=result,
                metadata={"processed": True},
                execution_time_ms=50,
            )

        except Exception as e:
            return ToolResult(
                success=False,
                output=None,
                error=f"Tool execution failed: {str(e)}"
            )

    def _do_work(self, param1, param2, param3):
        """Your actual implementation."""
        # Example: Database query, API call, computation
        return {"result": "success", "data": [1, 2, 3]}
2

Runtime Registration (Optional)

Register OpenAPI tools dynamically via API (uses the same admin token as MCP):
curl -X POST http://localhost:8000/tools/openapi/register \
  -H "Authorization: Bearer $MCP_REGISTER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "petstore",
    "spec_url": "https://petstore3.swagger.io/api/v3/openapi.json",
    "auth_type": "none",
    "operations": ["getPetById"],
    "rate_limit": 30,
    "timeout_seconds": 30
  }'
Response includes registered operations and effective limits.
3

Register Tool

Edit python/llm-service/llm_service/api/tools.py around line 228:
# Add import at top
from ..tools.builtin.my_custom_tool import MyCustomTool

# Add to registration list in startup_event()
@router.on_event("startup")
async def startup_event():
    registry = get_registry()

    tools_to_register = [
        WebSearchTool,
        CalculatorTool,
        FileReadTool,
        FileWriteTool,
        PythonWasiExecutorTool,
        MyCustomTool,  # Add your tool here
    ]

    for tool_class in tools_to_register:
        try:
            registry.register(tool_class)
            logger.info(f"Registered tool: {tool_class.__name__}")
        except Exception as e:
            logger.error(f"Failed to register {tool_class.__name__}: {e}")
4

Restart Service

docker compose -f deploy/compose/docker-compose.yml up -d --force-recreate llm-service
5

Test Tool

# Verify registration
curl http://localhost:8000/tools/list | grep my_custom_tool

# Get schema
curl http://localhost:8000/tools/my_custom_tool/schema | jq .

# Execute
curl -X POST http://localhost:8000/tools/execute \
  -H "Content-Type: application/json" \
  -d '{
    "tool_name": "my_custom_tool",
    "parameters": {
      "required_param": "test",
      "optional_number": 42,
      "choice_param": "option1"
    }
  }' | jq .

Advanced: Session-Aware Tools

For tools that maintain state across executions:
class SessionAwareTool(Tool):
    def _get_metadata(self) -> ToolMetadata:
        return ToolMetadata(
            name="session_tool",
            session_aware=True,  # Enable session context
            ...
        )

    async def _execute_impl(
        self,
        session_context: Optional[Dict] = None,
        **kwargs
    ) -> ToolResult:
        # Access session data
        session_id = session_context.get("session_id") if session_context else None
        user_id = session_context.get("user_id") if session_context else None

        # Store/retrieve session-specific data
        # Example: Redis, database, in-memory cache

        return ToolResult(success=True, output={"session": session_id})

Configuration Reference

MCP Tool Configuration

mcp_tools:
  tool_name:
    enabled: true                    # Required: Enable/disable tool
    url: "https://api.example.com"   # Required: HTTP endpoint
    func_name: "function_name"       # Required: Remote function name
    description: "Tool description"  # Required: LLM-visible description
    category: "data"                 # Required: Tool category
    cost_per_use: 0.001             # Required: Cost in USD
    parameters:                      # Required: Parameter definitions
      - name: "param1"
        type: "string"               # string|integer|float|boolean|array|object
        required: true
        description: "Param description"
        enum: ["val1", "val2"]       # Optional: Allowed values
        default: "val1"              # Optional: Default value
    headers:                         # Optional: HTTP headers
      X-API-Key: "${API_KEY_VAR}"   # Use ${} for env vars

OpenAPI Tool Configuration

openapi_tools:
  collection_name:
    enabled: true
    spec_url: "https://api.example.com/openapi.json"  # OR spec_inline
    auth_type: "none"                # none|api_key|bearer|basic
    auth_config:                     # Required if auth_type != "none"
      # For api_key:
      api_key_name: "X-API-Key"
      api_key_location: "header"     # header|query
      api_key_value: "$API_KEY"
      # For bearer:
      token: "$BEARER_TOKEN"
      # For basic:
      username: "$USERNAME"
      password: "$PASSWORD"
    category: "api"
    base_cost_per_use: 0.001
    rate_limit: 30                   # Requests per minute
    timeout_seconds: 30              # Request timeout
    max_response_bytes: 10485760     # Max response size (bytes)
    operations:                      # Optional: Filter operations
      - "operationId1"
      - "operationId2"
    tags:                            # Optional: Filter by tags
      - "tag1"
    base_url: "https://override.com" # Optional: Override spec base URL

Environment Variables

MCP Configuration:
# Domain Security
MCP_ALLOWED_DOMAINS=localhost,127.0.0.1,api.example.com  # Or * for dev

# Circuit Breaker
MCP_CB_FAILURES=5                    # Failures before circuit opens
MCP_CB_RECOVERY_SECONDS=60           # Circuit open duration

# Request Limits
MCP_MAX_RESPONSE_BYTES=10485760      # 10MB default
MCP_RETRIES=3                        # Retry attempts
MCP_TIMEOUT_SECONDS=10               # Request timeout

# Registration Security
MCP_REGISTER_TOKEN=your_secret       # API registration protection
OpenAPI Configuration:
# Domain Security
OPENAPI_ALLOWED_DOMAINS=*            # Comma-separated or * for dev
OPENAPI_MAX_SPEC_SIZE=5242880        # 5MB spec size limit
OPENAPI_FETCH_TIMEOUT=30             # Spec fetch timeout

# Request Behavior
OPENAPI_RETRIES=2                    # Retry attempts (default: 2)

Testing & Verification

Health Checks

# Check admin service health
curl http://localhost:8081/health/ready | jq .
curl http://localhost:8081/health/live | jq .

# Check LLM service status
docker inspect shannon-llm-service-1 --format='{{.State.Health.Status}}'

List Tools

# All tools
curl http://localhost:8000/tools/list | jq .

# By category
curl "http://localhost:8000/tools/list?category=data" | jq .

# Exclude dangerous
curl "http://localhost:8000/tools/list?exclude_dangerous=true" | jq .

# List categories
curl http://localhost:8000/tools/categories | jq .

Execute Tools

Direct execution:
curl -X POST http://localhost:8000/tools/execute \
  -H "Content-Type: application/json" \
  -d '{
    "tool_name": "calculator",
    "parameters": {"expression": "sqrt(144) + 2^3"}
  }' | jq .
Batch execution:
curl -X POST http://localhost:8000/tools/batch-execute \
  -H "Content-Type: application/json" \
  -d '[
    {"tool_name": "calculator", "parameters": {"expression": "2+2"}},
    {"tool_name": "calculator", "parameters": {"expression": "10*5"}}
  ]' | jq .
Via workflow:
SESSION_ID="test-$(date +%s)" ./scripts/submit_task.sh "Calculate 2+2 and then multiply by 5"

Troubleshooting

Symptom: Tool doesn’t appear in /tools/listDebug steps:
# 1. Check YAML syntax
yamllint config/shannon.yaml

# 2. Check logs for errors
docker compose logs llm-service | grep -i error

# 3. Verify enabled flag
grep -A 10 "my_tool" config/shannon.yaml | grep enabled

# 4. Force recreate service
docker compose -f deploy/compose/docker-compose.yml up -d --force-recreate llm-service

# 5. Wait for health
sleep 10
docker inspect shannon-llm-service-1 --format='{{.State.Health.Status}}'
Symptom: URL host 'example.com' not in allowed domainsSolutions:
  1. Development: Use wildcard
    # .env
    MCP_ALLOWED_DOMAINS=*
    OPENAPI_ALLOWED_DOMAINS=*
    
  2. Production: Add specific domain
    # .env
    MCP_ALLOWED_DOMAINS=localhost,127.0.0.1,api.example.com
    OPENAPI_ALLOWED_DOMAINS=api.example.com,api.github.com
    
Symptom: ToolResult { success: false, error: "..." }Debug:
# 1. Test tool directly
curl -X POST http://localhost:8000/tools/execute \
  -H "Content-Type: application/json" \
  -d '{"tool_name":"my_tool","parameters":{...}}' | jq .

# 2. Check parameter types
curl http://localhost:8000/tools/my_tool/schema | jq '.parameters'

# 3. Check agent core logs
docker logs shannon-agent-core-1 | grep "Tool execution error"

# 4. Check LLM service logs
docker logs shannon-llm-service-1 | grep my_tool
Symptom: Circuit breaker open for <url> (too many failures)Debug:
# Check recent errors
docker logs shannon-llm-service-1 --tail 100 | grep -i "circuit\|failure"

# Wait for recovery (default 60s)
sleep 60

# Or restart to reset
docker compose restart llm-service
Prevent:
  • Increase failure threshold: MCP_CB_FAILURES=10
  • Increase recovery time: MCP_CB_RECOVERY_SECONDS=120
  • Fix underlying API issues

Security Best Practices

Domain Allowlisting

# Permissive for testing
MCP_ALLOWED_DOMAINS=*
OPENAPI_ALLOWED_DOMAINS=*

API Key Management

Never hardcode API keys in configuration files!
❌ Bad:
headers:
  X-API-Key: "sk-1234567890abcdef"
✅ Good:
headers:
  X-API-Key: "${WEATHER_API_KEY}"
Store in .env (not tracked by git):
# .env
WEATHER_API_KEY=sk-real-key-here
STOCK_API_KEY=your-stock-key
For production: Use secrets management
  • Docker secrets
  • Kubernetes secrets
  • HashiCorp Vault
  • AWS Secrets Manager

Dangerous Tools

Mark tools that modify state or access sensitive resources:
ToolMetadata(
    name="file_write",
    dangerous=True,        # Triggers OPA policy checks
    requires_auth=True,    # Requires user authentication
    ...
)

Vendor Adapter Pattern

For domain-specific APIs and custom agents When integrating proprietary or internal APIs that require domain-specific transformations, use the vendor adapter pattern to keep vendor logic separate from Shannon’s core infrastructure.

When to Use

Use vendor adapters when your API integration requires:
  • Custom field name aliasing (e.g., usersmy:unique_users)
  • Request/response transformations
  • Dynamic parameter injection from session context
  • Domain-specific validation or normalization
  • Specialized agent roles with custom system prompts

Quick Example

1

Create Vendor Adapter

python/llm-service/llm_service/tools/vendor_adapters/myvendor.py:
class MyVendorAdapter:
    def transform_body(self, body, operation_id, prompt_params):
        # Transform field names
        if isinstance(body.get("metrics"), list):
            body["metrics"] = [m.replace("users", "my:users") for m in body["metrics"]]

        # Inject session params
        if prompt_params and "account_id" in prompt_params:
            body["account_id"] = prompt_params["account_id"]

        return body
2

Register Adapter

python/llm-service/llm_service/tools/vendor_adapters/__init__.py:
def get_vendor_adapter(name: str):
    if name.lower() == "myvendor":
        from .myvendor import MyVendorAdapter
        return MyVendorAdapter()
    return None
3

Configure with Vendor Flag

config/overlays/shannon.myvendor.yaml:
openapi_tools:
  myvendor_api:
    enabled: true
    spec_url: file:///app/config/openapi_specs/myvendor_api.yaml
    auth_type: bearer
    auth_config:
      vendor: myvendor  # Triggers adapter loading
      token: "${MYVENDOR_API_TOKEN}"
    category: custom
4

Use Environment

SHANNON_CONFIG_PATH=config/overlays/shannon.myvendor.yaml
MYVENDOR_API_TOKEN=your_token_here

Benefits

  • Clean separation: Vendor code isolated from Shannon core
  • No core changes: Shannon infrastructure remains generic
  • Conditional loading: Graceful fallback if vendor module unavailable
  • Easy testing: Unit test adapters in isolation
  • Secrets management: All tokens via environment variables

Complete Guide

For a comprehensive guide including:
  • Custom agent roles for specialized domains
  • Session context injection patterns
  • Testing strategies
  • Best practices and troubleshooting

Vendor Adapters Guide

Learn how to build vendor-specific integrations with the adapter pattern

Summary

Three ways to add tools:
MethodCommandConfig FileCode Changes
MCPdocker compose up -d --force-recreate llm-serviceconfig/shannon.yamlNone
OpenAPIdocker compose up -d --force-recreate llm-serviceconfig/shannon.yamlNone
Built-indocker compose up -d --force-recreate llm-serviceapi/tools.py + new filePython only
Key takeaways:
  • ✅ Zero proto/Rust/Go changes (generic google.protobuf.Struct containers)
  • ✅ Security built-in (domain allowlisting, rate limiting, circuit breakers)
  • ✅ Cost tracking automatic (set cost_per_use in metadata)
  • ✅ Schema-driven (OpenAI-compatible JSON schemas)

Next Steps