2026-01-20 13:57:36 +08:00
|
|
|
"""Unified extensions configuration for MCP servers and skills."""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
2026-03-01 22:38:58 +08:00
|
|
|
from typing import Any, Literal
|
2026-01-20 13:57:36 +08:00
|
|
|
|
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
|
|
|
|
|
2026-03-01 22:38:58 +08:00
|
|
|
class McpOAuthConfig(BaseModel):
|
|
|
|
|
"""OAuth configuration for an MCP server (HTTP/SSE transports)."""
|
|
|
|
|
|
|
|
|
|
enabled: bool = Field(default=True, description="Whether OAuth token injection is enabled")
|
|
|
|
|
token_url: str = Field(description="OAuth token endpoint URL")
|
|
|
|
|
grant_type: Literal["client_credentials", "refresh_token"] = Field(
|
|
|
|
|
default="client_credentials",
|
|
|
|
|
description="OAuth grant type",
|
|
|
|
|
)
|
|
|
|
|
client_id: str | None = Field(default=None, description="OAuth client ID")
|
|
|
|
|
client_secret: str | None = Field(default=None, description="OAuth client secret")
|
|
|
|
|
refresh_token: str | None = Field(default=None, description="OAuth refresh token (for refresh_token grant)")
|
|
|
|
|
scope: str | None = Field(default=None, description="OAuth scope")
|
|
|
|
|
audience: str | None = Field(default=None, description="OAuth audience (provider-specific)")
|
|
|
|
|
token_field: str = Field(default="access_token", description="Field name containing access token in token response")
|
|
|
|
|
token_type_field: str = Field(default="token_type", description="Field name containing token type in token response")
|
|
|
|
|
expires_in_field: str = Field(default="expires_in", description="Field name containing expiry (seconds) in token response")
|
|
|
|
|
default_token_type: str = Field(default="Bearer", description="Default token type when missing in token response")
|
|
|
|
|
refresh_skew_seconds: int = Field(default=60, description="Refresh token this many seconds before expiry")
|
|
|
|
|
extra_token_params: dict[str, str] = Field(default_factory=dict, description="Additional form params sent to token endpoint")
|
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
|
|
|
|
|
|
|
2026-01-20 13:57:36 +08:00
|
|
|
class McpServerConfig(BaseModel):
|
|
|
|
|
"""Configuration for a single MCP server."""
|
|
|
|
|
|
|
|
|
|
enabled: bool = Field(default=True, description="Whether this MCP server is enabled")
|
2026-01-21 16:14:00 +08:00
|
|
|
type: str = Field(default="stdio", description="Transport type: 'stdio', 'sse', or 'http'")
|
|
|
|
|
command: str | None = Field(default=None, description="Command to execute to start the MCP server (for stdio type)")
|
|
|
|
|
args: list[str] = Field(default_factory=list, description="Arguments to pass to the command (for stdio type)")
|
2026-01-20 13:57:36 +08:00
|
|
|
env: dict[str, str] = Field(default_factory=dict, description="Environment variables for the MCP server")
|
2026-01-21 16:14:00 +08:00
|
|
|
url: str | None = Field(default=None, description="URL of the MCP server (for sse or http type)")
|
|
|
|
|
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
|
2026-03-01 22:38:58 +08:00
|
|
|
oauth: McpOAuthConfig | None = Field(default=None, description="OAuth configuration (for sse or http type)")
|
2026-01-20 13:57:36 +08:00
|
|
|
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
|
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SkillStateConfig(BaseModel):
|
|
|
|
|
"""Configuration for a single skill's state."""
|
|
|
|
|
|
|
|
|
|
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExtensionsConfig(BaseModel):
|
|
|
|
|
"""Unified configuration for MCP servers and skills."""
|
|
|
|
|
|
|
|
|
|
mcp_servers: dict[str, McpServerConfig] = Field(
|
|
|
|
|
default_factory=dict,
|
|
|
|
|
description="Map of MCP server name to configuration",
|
|
|
|
|
alias="mcpServers",
|
|
|
|
|
)
|
|
|
|
|
skills: dict[str, SkillStateConfig] = Field(
|
|
|
|
|
default_factory=dict,
|
|
|
|
|
description="Map of skill name to state configuration",
|
|
|
|
|
)
|
|
|
|
|
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def resolve_config_path(cls, config_path: str | None = None) -> Path | None:
|
|
|
|
|
"""Resolve the extensions config file path.
|
|
|
|
|
|
|
|
|
|
Priority:
|
|
|
|
|
1. If provided `config_path` argument, use it.
|
|
|
|
|
2. If provided `DEER_FLOW_EXTENSIONS_CONFIG_PATH` environment variable, use it.
|
|
|
|
|
3. Otherwise, check for `extensions_config.json` in the current directory, then in the parent directory.
|
|
|
|
|
4. For backward compatibility, also check for `mcp_config.json` if `extensions_config.json` is not found.
|
|
|
|
|
5. If not found, return None (extensions are optional).
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
config_path: Optional path to extensions config file.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Path to the extensions config file if found, otherwise None.
|
|
|
|
|
"""
|
|
|
|
|
if config_path:
|
|
|
|
|
path = Path(config_path)
|
|
|
|
|
if not path.exists():
|
|
|
|
|
raise FileNotFoundError(f"Extensions config file specified by param `config_path` not found at {path}")
|
|
|
|
|
return path
|
|
|
|
|
elif os.getenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH"):
|
|
|
|
|
path = Path(os.getenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH"))
|
|
|
|
|
if not path.exists():
|
|
|
|
|
raise FileNotFoundError(f"Extensions config file specified by environment variable `DEER_FLOW_EXTENSIONS_CONFIG_PATH` not found at {path}")
|
|
|
|
|
return path
|
|
|
|
|
else:
|
|
|
|
|
# Check if the extensions_config.json is in the current directory
|
|
|
|
|
path = Path(os.getcwd()) / "extensions_config.json"
|
|
|
|
|
if path.exists():
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
# Check if the extensions_config.json is in the parent directory of CWD
|
|
|
|
|
path = Path(os.getcwd()).parent / "extensions_config.json"
|
|
|
|
|
if path.exists():
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
# Backward compatibility: check for mcp_config.json
|
|
|
|
|
path = Path(os.getcwd()) / "mcp_config.json"
|
|
|
|
|
if path.exists():
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
path = Path(os.getcwd()).parent / "mcp_config.json"
|
|
|
|
|
if path.exists():
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
# Extensions are optional, so return None if not found
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_file(cls, config_path: str | None = None) -> "ExtensionsConfig":
|
|
|
|
|
"""Load extensions config from JSON file.
|
|
|
|
|
|
|
|
|
|
See `resolve_config_path` for more details.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
config_path: Path to the extensions config file.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
ExtensionsConfig: The loaded config, or empty config if file not found.
|
|
|
|
|
"""
|
|
|
|
|
resolved_path = cls.resolve_config_path(config_path)
|
|
|
|
|
if resolved_path is None:
|
|
|
|
|
# Return empty config if extensions config file is not found
|
|
|
|
|
return cls(mcp_servers={}, skills={})
|
|
|
|
|
|
2026-03-11 10:03:01 +08:00
|
|
|
try:
|
|
|
|
|
with open(resolved_path, encoding="utf-8") as f:
|
|
|
|
|
config_data = json.load(f)
|
|
|
|
|
cls.resolve_env_variables(config_data)
|
|
|
|
|
return cls.model_validate(config_data)
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
raise ValueError(f"Extensions config file at {resolved_path} is not valid JSON: {e}") from e
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise RuntimeError(f"Failed to load extensions config from {resolved_path}: {e}") from e
|
2026-01-20 13:57:36 +08:00
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def resolve_env_variables(cls, config: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
"""Recursively resolve environment variables in the config.
|
|
|
|
|
|
|
|
|
|
Environment variables are resolved using the `os.getenv` function. Example: $OPENAI_API_KEY
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
config: The config to resolve environment variables in.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The config with environment variables resolved.
|
|
|
|
|
"""
|
|
|
|
|
for key, value in config.items():
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
if value.startswith("$"):
|
2026-02-25 16:12:59 +08:00
|
|
|
env_value = os.getenv(value[1:])
|
|
|
|
|
if env_value is None:
|
2026-03-11 10:03:01 +08:00
|
|
|
# Unresolved placeholder — store empty string so downstream
|
|
|
|
|
# consumers (e.g. MCP servers) don't receive the literal "$VAR"
|
|
|
|
|
# token as an actual environment value.
|
|
|
|
|
config[key] = ""
|
|
|
|
|
else:
|
|
|
|
|
config[key] = env_value
|
2026-01-20 13:57:36 +08:00
|
|
|
else:
|
|
|
|
|
config[key] = value
|
|
|
|
|
elif isinstance(value, dict):
|
|
|
|
|
config[key] = cls.resolve_env_variables(value)
|
|
|
|
|
elif isinstance(value, list):
|
|
|
|
|
config[key] = [cls.resolve_env_variables(item) if isinstance(item, dict) else item for item in value]
|
|
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
def get_enabled_mcp_servers(self) -> dict[str, McpServerConfig]:
|
|
|
|
|
"""Get only the enabled MCP servers.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary of enabled MCP servers.
|
|
|
|
|
"""
|
|
|
|
|
return {name: config for name, config in self.mcp_servers.items() if config.enabled}
|
|
|
|
|
|
2026-01-20 20:37:44 +08:00
|
|
|
def is_skill_enabled(self, skill_name: str, skill_category: str) -> bool:
|
2026-01-20 13:57:36 +08:00
|
|
|
"""Check if a skill is enabled.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
skill_name: Name of the skill
|
2026-01-20 20:37:44 +08:00
|
|
|
skill_category: Category of the skill
|
2026-01-20 13:57:36 +08:00
|
|
|
|
|
|
|
|
Returns:
|
2026-01-20 20:37:44 +08:00
|
|
|
True if enabled, False otherwise
|
2026-01-20 13:57:36 +08:00
|
|
|
"""
|
|
|
|
|
skill_config = self.skills.get(skill_name)
|
|
|
|
|
if skill_config is None:
|
2026-01-30 21:58:43 +08:00
|
|
|
# Default to enable for public & custom skill
|
2026-02-05 19:59:25 +08:00
|
|
|
return skill_category in ("public", "custom")
|
2026-01-20 13:57:36 +08:00
|
|
|
return skill_config.enabled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_extensions_config: ExtensionsConfig | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_extensions_config() -> ExtensionsConfig:
|
|
|
|
|
"""Get the extensions config instance.
|
|
|
|
|
|
|
|
|
|
Returns a cached singleton instance. Use `reload_extensions_config()` to reload
|
|
|
|
|
from file, or `reset_extensions_config()` to clear the cache.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The cached ExtensionsConfig instance.
|
|
|
|
|
"""
|
|
|
|
|
global _extensions_config
|
|
|
|
|
if _extensions_config is None:
|
|
|
|
|
_extensions_config = ExtensionsConfig.from_file()
|
|
|
|
|
return _extensions_config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reload_extensions_config(config_path: str | None = None) -> ExtensionsConfig:
|
|
|
|
|
"""Reload the extensions config from file and update the cached instance.
|
|
|
|
|
|
|
|
|
|
This is useful when the config file has been modified and you want
|
|
|
|
|
to pick up the changes without restarting the application.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
config_path: Optional path to extensions config file. If not provided,
|
|
|
|
|
uses the default resolution strategy.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The newly loaded ExtensionsConfig instance.
|
|
|
|
|
"""
|
|
|
|
|
global _extensions_config
|
|
|
|
|
_extensions_config = ExtensionsConfig.from_file(config_path)
|
|
|
|
|
return _extensions_config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reset_extensions_config() -> None:
|
|
|
|
|
"""Reset the cached extensions config instance.
|
|
|
|
|
|
|
|
|
|
This clears the singleton cache, causing the next call to
|
|
|
|
|
`get_extensions_config()` to reload from file. Useful for testing
|
|
|
|
|
or when switching between different configurations.
|
|
|
|
|
"""
|
|
|
|
|
global _extensions_config
|
|
|
|
|
_extensions_config = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_extensions_config(config: ExtensionsConfig) -> None:
|
|
|
|
|
"""Set a custom extensions config instance.
|
|
|
|
|
|
|
|
|
|
This allows injecting a custom or mock config for testing purposes.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
config: The ExtensionsConfig instance to use.
|
|
|
|
|
"""
|
|
|
|
|
global _extensions_config
|
|
|
|
|
_extensions_config = config
|