feat: dynamic config rendering + mcp tool enhancement (#2286)

* feat: enhance modal functionality and configuration handling

- Updated WrapperModal to improve click outside detection for closing the modal.
- Refactored ToolConfig to utilize ConfigFieldSpec for better configuration management.
- Added validation and dynamic handling of configuration fields in ToolConfig.
- Introduced reconnect functionality for MCP tools in the Tools component.
- Enhanced user experience with improved error handling and loading states.
- Updated types for better type safety and clarity in configuration requirements.

* refactor: reorganize imports and improve conditional formatting

* fix: revert API_URL to use backend service name in docker-compose

* feat: add MCP auth status endpoint and integrate into user service and tools

* feat: implement logging for Brave, Postgres, and Telegram tools; add transport sanitization and credential extraction for MCP

---------

Co-authored-by: Alex <a@tushynski.me>
This commit is contained in:
Siddhant Rai
2026-03-13 21:28:50 +05:30
committed by GitHub
parent 13ad3b5dce
commit 65e57be4dd
29 changed files with 1805 additions and 796 deletions

View File

@@ -16,6 +16,7 @@ from application.core.settings import settings
from application.llm.handlers.handler_creator import LLMHandlerCreator
from application.llm.llm_creator import LLMCreator
from application.logging import build_stack_data, log_activity, LogContext
from application.security.encryption import decrypt_credentials
logger = logging.getLogger(__name__)
@@ -264,12 +265,18 @@ class BaseAgent(ABC):
)
else:
tool_config = tool_data["config"].copy() if tool_data["config"] else {}
# Add tool_id from MongoDB _id for tools that need instance isolation (like memory tool)
# Use MongoDB _id if available, otherwise fall back to enumerated tool_id
if tool_config.get("encrypted_credentials") and self.user:
decrypted = decrypt_credentials(
tool_config["encrypted_credentials"], self.user
)
tool_config.update(decrypted)
tool_config["auth_credentials"] = decrypted
tool_config.pop("encrypted_credentials", None)
tool_config["tool_id"] = str(tool_data.get("_id", tool_id))
if hasattr(self, "conversation_id") and self.conversation_id:
tool_config["conversation_id"] = self.conversation_id
if tool_data["name"] == "mcp_tool":
tool_config["query_mode"] = True
tool = tm.load_tool(
tool_data["name"],
tool_config=tool_config,

View File

@@ -46,7 +46,7 @@ class BraveSearchTool(Tool):
"""
Performs a web search using the Brave Search API.
"""
logger.info(f"Brave web search: {query}")
logger.debug("Performing Brave web search for: %s", query)
url = f"{self.base_url}/web/search"
@@ -99,7 +99,7 @@ class BraveSearchTool(Tool):
"""
Performs an image search using the Brave Search API.
"""
logger.info(f"Brave image search: {query}")
logger.debug("Performing Brave image search for: %s", query)
url = f"{self.base_url}/images/search"
@@ -182,6 +182,10 @@ class BraveSearchTool(Tool):
return {
"token": {
"type": "string",
"label": "API Key",
"description": "Brave Search API key for authentication",
"required": True,
"secret": True,
"order": 1,
},
}

View File

@@ -1,20 +1,12 @@
import asyncio
import base64
import concurrent.futures
import json
import logging
import time
from typing import Any, Dict, List, Optional
from urllib.parse import parse_qs, urlparse
from application.agents.tools.base import Tool
from application.api.user.tasks import mcp_oauth_status_task, mcp_oauth_task
from application.cache import get_redis_instance
from application.core.mongo_db import MongoDB
from application.core.settings import settings
from application.security.encryption import decrypt_credentials
from fastmcp import Client
from fastmcp.client.auth import BearerAuth
from fastmcp.client.transports import (
@@ -24,10 +16,16 @@ from fastmcp.client.transports import (
)
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from pydantic import AnyHttpUrl, ValidationError
from redis import Redis
from application.agents.tools.base import Tool
from application.api.user.tasks import mcp_oauth_status_task, mcp_oauth_task
from application.cache import get_redis_instance
from application.core.mongo_db import MongoDB
from application.core.settings import settings
from application.security.encryption import decrypt_credentials
logger = logging.getLogger(__name__)
mongo = MongoDB.get_client()
@@ -58,6 +56,7 @@ class MCPTool(Tool):
- args: Arguments for STDIO transport
- oauth_scopes: OAuth scopes for oauth auth type
- oauth_client_name: OAuth client name for oauth auth type
- query_mode: If True, use non-interactive OAuth (fail-fast on 401)
user_id: User ID for decrypting credentials (required if encrypted_credentials exist)
"""
self.config = config
@@ -78,23 +77,40 @@ class MCPTool(Tool):
self.oauth_scopes = config.get("oauth_scopes", [])
self.oauth_task_id = config.get("oauth_task_id", None)
self.oauth_client_name = config.get("oauth_client_name", "DocsGPT-MCP")
self.redirect_uri = f"{settings.API_URL}/api/mcp_server/callback"
self.redirect_uri = self._resolve_redirect_uri(config.get("redirect_uri"))
self.available_tools = []
self._cache_key = self._generate_cache_key()
self._client = None
# Only validate and setup if server_url is provided and not OAuth
self.query_mode = config.get("query_mode", False)
if self.server_url and self.auth_type != "oauth":
self._setup_client()
def _resolve_redirect_uri(self, configured_redirect_uri: Optional[str]) -> str:
if configured_redirect_uri:
return configured_redirect_uri.rstrip("/")
explicit = getattr(settings, "MCP_OAUTH_REDIRECT_URI", None)
if explicit:
return explicit.rstrip("/")
connector_base = getattr(settings, "CONNECTOR_REDIRECT_BASE_URI", None)
if connector_base:
parsed = urlparse(connector_base)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}/api/mcp_server/callback"
return f"{settings.API_URL.rstrip('/')}/api/mcp_server/callback"
def _generate_cache_key(self) -> str:
"""Generate a unique cache key for this MCP server configuration."""
auth_key = ""
if self.auth_type == "oauth":
scopes_str = ",".join(self.oauth_scopes) if self.oauth_scopes else "none"
auth_key = f"oauth:{self.oauth_client_name}:{scopes_str}"
auth_key = (
f"oauth:{self.oauth_client_name}:{scopes_str}:{self.redirect_uri}"
)
elif self.auth_type in ["bearer"]:
token = self.auth_credentials.get(
"bearer_token", ""
@@ -111,11 +127,10 @@ class MCPTool(Tool):
return f"{self.server_url}#{self.transport_type}#{auth_key}"
def _setup_client(self):
"""Setup FastMCP client with proper transport and authentication."""
global _mcp_clients_cache
if self._cache_key in _mcp_clients_cache:
cached_data = _mcp_clients_cache[self._cache_key]
if time.time() - cached_data["created_at"] < 1800:
if time.time() - cached_data["created_at"] < 300:
self._client = cached_data["client"]
return
else:
@@ -125,15 +140,25 @@ class MCPTool(Tool):
if self.auth_type == "oauth":
redis_client = get_redis_instance()
auth = DocsGPTOAuth(
mcp_url=self.server_url,
scopes=self.oauth_scopes,
redis_client=redis_client,
redirect_uri=self.redirect_uri,
task_id=self.oauth_task_id,
db=db,
user_id=self.user_id,
)
if self.query_mode:
auth = NonInteractiveOAuth(
mcp_url=self.server_url,
scopes=self.oauth_scopes,
redis_client=redis_client,
redirect_uri=self.redirect_uri,
db=db,
user_id=self.user_id,
)
else:
auth = DocsGPTOAuth(
mcp_url=self.server_url,
scopes=self.oauth_scopes,
redis_client=redis_client,
redirect_uri=self.redirect_uri,
task_id=self.oauth_task_id,
db=db,
user_id=self.user_id,
)
elif self.auth_type == "bearer":
token = self.auth_credentials.get(
"bearer_token", ""
@@ -235,38 +260,53 @@ class MCPTool(Tool):
else:
raise Exception(f"Unknown operation: {operation}")
_ERROR_MAP = [
(concurrent.futures.TimeoutError, lambda op, t, _: f"Timed out after {t}s"),
(ConnectionRefusedError, lambda *_: "Connection refused"),
]
_ERROR_PATTERNS = {
("403", "Forbidden"): "Access denied (403 Forbidden)",
("401", "Unauthorized"): "Authentication failed (401 Unauthorized)",
("ECONNREFUSED",): "Connection refused",
("SSL", "certificate"): "SSL/TLS error",
}
def _run_async_operation(self, operation: str, *args, **kwargs):
"""Run async operation in sync context."""
try:
try:
loop = asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(
self._execute_with_client(operation, *args, **kwargs)
)
finally:
new_loop.close()
asyncio.get_running_loop()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
future = executor.submit(
self._run_in_new_loop, operation, *args, **kwargs
)
return future.result(timeout=self.timeout)
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
self._execute_with_client(operation, *args, **kwargs)
)
finally:
loop.close()
return self._run_in_new_loop(operation, *args, **kwargs)
except Exception as e:
logger.error(f"Error occurred while running async operation: {e}")
raise
raise self._map_error(operation, e) from e
raise self._map_error(operation, e) from e
def _run_in_new_loop(self, operation, *args, **kwargs):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
self._execute_with_client(operation, *args, **kwargs)
)
finally:
loop.close()
def _map_error(self, operation: str, exc: Exception) -> Exception:
for exc_type, msg_fn in self._ERROR_MAP:
if isinstance(exc, exc_type):
return Exception(msg_fn(operation, self.timeout, exc))
error_msg = str(exc)
for patterns, friendly in self._ERROR_PATTERNS.items():
if any(p.lower() in error_msg.lower() for p in patterns):
return Exception(friendly)
logger.error("MCP %s failed: %s", operation, exc)
return exc
def discover_tools(self) -> List[Dict]:
"""
@@ -287,16 +327,6 @@ class MCPTool(Tool):
raise Exception(f"Failed to discover tools from MCP server: {str(e)}")
def execute_action(self, action_name: str, **kwargs) -> Any:
"""
Execute an action on the remote MCP server using FastMCP.
Args:
action_name: Name of the action to execute
**kwargs: Parameters for the action
Returns:
Result from the MCP server
"""
if not self.server_url:
raise Exception("No MCP server configured")
if not self._client:
@@ -312,7 +342,37 @@ class MCPTool(Tool):
)
return self._format_result(result)
except Exception as e:
raise Exception(f"Failed to execute action '{action_name}': {str(e)}")
error_msg = str(e)
lower_msg = error_msg.lower()
is_auth_error = (
"401" in error_msg
or "unauthorized" in lower_msg
or "session expired" in lower_msg
or "re-authorize" in lower_msg
)
if is_auth_error:
if self.auth_type == "oauth":
raise Exception(
f"Action '{action_name}' failed: OAuth session expired. "
"Please re-authorize this MCP server in tool settings."
) from e
global _mcp_clients_cache
_mcp_clients_cache.pop(self._cache_key, None)
self._client = None
self._setup_client()
try:
result = self._run_async_operation(
"call_tool", action_name, **cleaned_kwargs
)
return self._format_result(result)
except Exception as retry_e:
raise Exception(
f"Action '{action_name}' failed after re-auth attempt: {retry_e}. "
"Your credentials may have expired — please re-authorize in tool settings."
) from retry_e
raise Exception(
f"Failed to execute action '{action_name}': {error_msg}"
) from e
def _format_result(self, result) -> Dict:
"""Format FastMCP result to match expected format."""
@@ -335,23 +395,35 @@ class MCPTool(Tool):
return result
def test_connection(self) -> Dict:
"""
Test the connection to the MCP server and validate functionality.
Returns:
Dictionary with connection test results including tool count
"""
if not self.server_url:
return {
"success": False,
"message": "No MCP server URL configured",
"message": "No server URL configured",
"tools_count": 0,
}
try:
parsed = urlparse(self.server_url)
if parsed.scheme not in ("http", "https"):
return {
"success": False,
"message": f"Invalid URL scheme '{parsed.scheme}' — use http:// or https://",
"tools_count": 0,
}
except Exception:
return {
"success": False,
"message": "Invalid URL format",
"tools_count": 0,
"transport_type": self.transport_type,
"auth_type": self.auth_type,
"error_type": "ConfigurationError",
}
if not self._client:
self._setup_client()
try:
self._setup_client()
except Exception as e:
return {
"success": False,
"message": f"Client init failed: {str(e)}",
"tools_count": 0,
}
try:
if self.auth_type == "oauth":
return self._test_oauth_connection()
@@ -362,56 +434,94 @@ class MCPTool(Tool):
"success": False,
"message": f"Connection failed: {str(e)}",
"tools_count": 0,
"transport_type": self.transport_type,
"auth_type": self.auth_type,
"error_type": type(e).__name__,
}
def _test_regular_connection(self) -> Dict:
"""Test connection for non-OAuth auth types."""
ping_ok = False
ping_error = None
try:
self._run_async_operation("ping")
ping_success = True
except Exception:
ping_success = False
tools = self.discover_tools()
ping_ok = True
except Exception as e:
ping_error = str(e)
message = f"Successfully connected to MCP server. Found {len(tools)} tools."
if not ping_success:
message += " (Ping not supported, but tool discovery worked)"
return {
"success": True,
"message": message,
"tools_count": len(tools),
"transport_type": self.transport_type,
"auth_type": self.auth_type,
"ping_supported": ping_success,
"tools": [tool.get("name", "unknown") for tool in tools],
}
def _test_oauth_connection(self) -> Dict:
"""Test connection for OAuth auth type with proper async handling."""
try:
task = mcp_oauth_task.delay(config=self.config, user=self.user_id)
if not task:
raise Exception("Failed to start OAuth authentication")
return {
"success": True,
"requires_oauth": True,
"task_id": task.id,
"status": "pending",
"message": "OAuth flow started",
}
tools = self.discover_tools()
except Exception as e:
return {
"success": False,
"message": f"OAuth connection failed: {str(e)}",
"message": f"Connection failed: {ping_error or str(e)}",
"tools_count": 0,
"transport_type": self.transport_type,
"auth_type": self.auth_type,
"error_type": type(e).__name__,
}
if not tools and not ping_ok:
return {
"success": False,
"message": f"Connection failed: {ping_error or 'No tools found'}",
"tools_count": 0,
}
return {
"success": True,
"message": f"Connected — found {len(tools)} tool{'s' if len(tools) != 1 else ''}.",
"tools_count": len(tools),
"tools": [
{
"name": tool.get("name", "unknown"),
"description": tool.get("description", ""),
}
for tool in tools
],
}
def _test_oauth_connection(self) -> Dict:
storage = DBTokenStorage(
server_url=self.server_url, user_id=self.user_id, db_client=db
)
loop = asyncio.new_event_loop()
try:
tokens = loop.run_until_complete(storage.get_tokens())
finally:
loop.close()
if tokens and tokens.access_token:
self.query_mode = True
_mcp_clients_cache.pop(self._cache_key, None)
self._client = None
self._setup_client()
try:
tools = self.discover_tools()
return {
"success": True,
"message": f"Connected — found {len(tools)} tool{'s' if len(tools) != 1 else ''}.",
"tools_count": len(tools),
"tools": [
{
"name": t.get("name", "unknown"),
"description": t.get("description", ""),
}
for t in tools
],
}
except Exception as e:
logger.warning("OAuth token validation failed: %s", e)
_mcp_clients_cache.pop(self._cache_key, None)
self._client = None
return self._start_oauth_task()
def _start_oauth_task(self) -> Dict:
task_config = self.config.copy()
task_config.pop("query_mode", None)
result = mcp_oauth_task.delay(task_config, self.user_id)
return {
"success": False,
"requires_oauth": True,
"task_id": result.id,
"message": "OAuth authorization required.",
"tools_count": 0,
}
def get_actions_metadata(self) -> List[Dict]:
"""
Get metadata for all available actions.
@@ -457,110 +567,88 @@ class MCPTool(Tool):
return actions
def get_config_requirements(self) -> Dict:
"""Get configuration requirements for the MCP tool."""
transport_enum = ["auto", "sse", "http"]
transport_help = {
"auto": "Automatically detect best transport",
"sse": "Server-Sent Events (for real-time streaming)",
"http": "HTTP streaming (recommended for production)",
}
return {
"server_url": {
"type": "string",
"description": "URL of the remote MCP server (e.g., https://api.example.com/mcp or https://docs.mcp.cloudflare.com/sse)",
"label": "Server URL",
"description": "URL of the remote MCP server",
"required": True,
},
"transport_type": {
"type": "string",
"description": "Transport type for connection",
"enum": transport_enum,
"default": "auto",
"required": False,
"help": {
**transport_help,
},
"secret": False,
"order": 1,
},
"auth_type": {
"type": "string",
"description": "Authentication type",
"label": "Authentication Type",
"description": "Authentication method for the MCP server",
"enum": ["none", "bearer", "oauth", "api_key", "basic"],
"default": "none",
"required": True,
"help": {
"none": "No authentication",
"bearer": "Bearer token authentication",
"oauth": "OAuth 2.1 authentication (with frontend integration)",
"api_key": "API key authentication",
"basic": "Basic authentication",
},
"secret": False,
"order": 2,
},
"auth_credentials": {
"type": "object",
"description": "Authentication credentials (varies by auth_type)",
"api_key": {
"type": "string",
"label": "API Key",
"description": "API key for authentication",
"required": False,
"properties": {
"bearer_token": {
"type": "string",
"description": "Bearer token for bearer auth",
},
"access_token": {
"type": "string",
"description": "Access token for OAuth (if pre-obtained)",
},
"api_key": {
"type": "string",
"description": "API key for api_key auth",
},
"api_key_header": {
"type": "string",
"description": "Header name for API key (default: X-API-Key)",
},
"username": {
"type": "string",
"description": "Username for basic auth",
},
"password": {
"type": "string",
"description": "Password for basic auth",
},
},
"secret": True,
"order": 3,
"depends_on": {"auth_type": "api_key"},
},
"api_key_header": {
"type": "string",
"label": "API Key Header",
"description": "Header name for API key (default: X-API-Key)",
"default": "X-API-Key",
"required": False,
"secret": False,
"order": 4,
"depends_on": {"auth_type": "api_key"},
},
"bearer_token": {
"type": "string",
"label": "Bearer Token",
"description": "Bearer token for authentication",
"required": False,
"secret": True,
"order": 3,
"depends_on": {"auth_type": "bearer"},
},
"username": {
"type": "string",
"label": "Username",
"description": "Username for basic authentication",
"required": False,
"secret": False,
"order": 3,
"depends_on": {"auth_type": "basic"},
},
"password": {
"type": "string",
"label": "Password",
"description": "Password for basic authentication",
"required": False,
"secret": True,
"order": 4,
"depends_on": {"auth_type": "basic"},
},
"oauth_scopes": {
"type": "array",
"description": "OAuth scopes to request (for oauth auth_type)",
"items": {"type": "string"},
"required": False,
"default": [],
},
"oauth_client_name": {
"type": "string",
"description": "Client name for OAuth registration (for oauth auth_type)",
"default": "DocsGPT-MCP",
"required": False,
},
"headers": {
"type": "object",
"description": "Custom headers to send with requests",
"label": "OAuth Scopes",
"description": "Comma-separated OAuth scopes to request",
"required": False,
"secret": False,
"order": 3,
"depends_on": {"auth_type": "oauth"},
},
"timeout": {
"type": "integer",
"description": "Request timeout in seconds",
"type": "number",
"label": "Timeout (seconds)",
"description": "Request timeout in seconds (1-300)",
"default": 30,
"minimum": 1,
"maximum": 300,
"required": False,
},
"command": {
"type": "string",
"description": "Command to run for STDIO transport (e.g., 'python')",
"required": False,
},
"args": {
"type": "array",
"description": "Arguments for STDIO command",
"items": {"type": "string"},
"required": False,
"secret": False,
"order": 10,
},
}
@@ -582,23 +670,8 @@ class DocsGPTOAuth(OAuthClientProvider):
user_id=None,
db=None,
additional_client_metadata: dict[str, Any] | None = None,
skip_redirect_validation: bool = False,
):
"""
Initialize custom OAuth client provider for DocsGPT.
Args:
mcp_url: Full URL to the MCP endpoint
redirect_uri: Custom redirect URI for DocsGPT frontend
redis_client: Redis client for storing auth state
redis_prefix: Prefix for Redis keys
task_id: Task ID for tracking auth status
scopes: OAuth scopes to request
client_name: Name for this client during registration
user_id: User ID for token storage
db: Database instance for token storage
additional_client_metadata: Extra fields for OAuthClientMetadata
"""
self.redirect_uri = redirect_uri
self.redis_client = redis_client
self.redis_prefix = redis_prefix
@@ -621,7 +694,10 @@ class DocsGPTOAuth(OAuthClientProvider):
)
storage = DBTokenStorage(
server_url=self.server_base_url, user_id=self.user_id, db_client=self.db
server_url=self.server_base_url,
user_id=self.user_id,
db_client=self.db,
expected_redirect_uri=None if skip_redirect_validation else redirect_uri,
)
super().__init__(
@@ -653,22 +729,20 @@ class DocsGPTOAuth(OAuthClientProvider):
async def redirect_handler(self, authorization_url: str) -> None:
"""Store auth URL and state in Redis for frontend to use."""
auth_url, state = self._process_auth_url(authorization_url)
logging.info(
"[DocsGPTOAuth] Processed auth_url: %s, state: %s", auth_url, state
)
logger.info("Processed auth_url: %s, state: %s", auth_url, state)
self.auth_url = auth_url
self.extracted_state = state
if self.redis_client and self.extracted_state:
key = f"{self.redis_prefix}auth_url:{self.extracted_state}"
self.redis_client.setex(key, 600, auth_url)
logging.info("[DocsGPTOAuth] Stored auth_url in Redis: %s", key)
logger.info("Stored auth_url in Redis: %s", key)
if self.task_id:
status_key = f"mcp_oauth_status:{self.task_id}"
status_data = {
"status": "requires_redirect",
"message": "OAuth authorization required",
"message": "Authorization required",
"authorization_url": self.auth_url,
"state": self.extracted_state,
"requires_oauth": True,
@@ -688,7 +762,7 @@ class DocsGPTOAuth(OAuthClientProvider):
status_key = f"mcp_oauth_status:{self.task_id}"
status_data = {
"status": "awaiting_callback",
"message": "Waiting for OAuth callback...",
"message": "Waiting for authorization...",
"authorization_url": self.auth_url,
"state": self.extracted_state,
"requires_oauth": True,
@@ -713,7 +787,7 @@ class DocsGPTOAuth(OAuthClientProvider):
if self.task_id:
status_data = {
"status": "callback_received",
"message": "OAuth callback received, completing authentication...",
"message": "Completing authentication...",
"task_id": self.task_id,
}
self.redis_client.setex(status_key, 600, json.dumps(status_data))
@@ -733,14 +807,44 @@ class DocsGPTOAuth(OAuthClientProvider):
await asyncio.sleep(poll_interval)
self.redis_client.delete(f"{self.redis_prefix}auth_url:{self.extracted_state}")
self.redis_client.delete(f"{self.redis_prefix}state:{self.extracted_state}")
raise Exception("OAuth callback timeout: no code received within 5 minutes")
raise Exception("OAuth timeout: no code received within 5 minutes")
class NonInteractiveOAuth(DocsGPTOAuth):
"""OAuth provider that fails fast on 401 instead of starting interactive auth.
Used during query execution to prevent the streaming response from blocking
while waiting for user authorization that will never come.
"""
def __init__(self, **kwargs):
kwargs.setdefault("task_id", None)
kwargs["skip_redirect_validation"] = True
super().__init__(**kwargs)
async def redirect_handler(self, authorization_url: str) -> None:
raise Exception(
"OAuth session expired — please re-authorize this MCP server in tool settings."
)
async def callback_handler(self) -> tuple[str, str | None]:
raise Exception(
"OAuth session expired — please re-authorize this MCP server in tool settings."
)
class DBTokenStorage(TokenStorage):
def __init__(self, server_url: str, user_id: str, db_client):
def __init__(
self,
server_url: str,
user_id: str,
db_client,
expected_redirect_uri: Optional[str] = None,
):
self.server_url = server_url
self.user_id = user_id
self.db_client = db_client
self.expected_redirect_uri = expected_redirect_uri
self.collection = db_client["connector_sessions"]
@staticmethod
@@ -759,10 +863,9 @@ class DBTokenStorage(TokenStorage):
if not doc or "tokens" not in doc:
return None
try:
tokens = OAuthToken.model_validate(doc["tokens"])
return tokens
return OAuthToken.model_validate(doc["tokens"])
except ValidationError as e:
logging.error(f"Could not load tokens: {e}")
logger.error("Could not load tokens: %s", e)
return None
async def set_tokens(self, tokens: OAuthToken) -> None:
@@ -772,28 +875,38 @@ class DBTokenStorage(TokenStorage):
{"$set": {"tokens": tokens.model_dump()}},
True,
)
logging.info(f"Saved tokens for {self.get_base_url(self.server_url)}")
logger.info("Saved tokens for %s", self.get_base_url(self.server_url))
async def get_client_info(self) -> OAuthClientInformationFull | None:
doc = await asyncio.to_thread(self.collection.find_one, self.get_db_key())
if not doc or "client_info" not in doc:
logger.debug(
"No client_info in DB for %s", self.get_base_url(self.server_url)
)
return None
try:
client_info = OAuthClientInformationFull.model_validate(doc["client_info"])
tokens = await self.get_tokens()
if tokens is None:
logging.debug(
"No tokens found, clearing client info to force fresh registration."
)
await asyncio.to_thread(
self.collection.update_one,
self.get_db_key(),
{"$unset": {"client_info": ""}},
)
return None
if self.expected_redirect_uri:
stored_uris = [
str(uri).rstrip("/") for uri in client_info.redirect_uris
]
expected_uri = self.expected_redirect_uri.rstrip("/")
if expected_uri not in stored_uris:
logger.warning(
"Redirect URI mismatch for %s: expected=%s stored=%s — clearing.",
self.get_base_url(self.server_url),
expected_uri,
stored_uris,
)
await asyncio.to_thread(
self.collection.update_one,
self.get_db_key(),
{"$unset": {"client_info": "", "tokens": ""}},
)
return None
return client_info
except ValidationError as e:
logging.error(f"Could not load client info: {e}")
logger.error("Could not load client info: %s", e)
return None
def _serialize_client_info(self, info: dict) -> dict:
@@ -809,17 +922,17 @@ class DBTokenStorage(TokenStorage):
{"$set": {"client_info": serialized_info}},
True,
)
logging.info(f"Saved client info for {self.get_base_url(self.server_url)}")
logger.info("Saved client info for %s", self.get_base_url(self.server_url))
async def clear(self) -> None:
await asyncio.to_thread(self.collection.delete_one, self.get_db_key())
logging.info(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}")
logger.info("Cleared OAuth cache for %s", self.get_base_url(self.server_url))
@classmethod
async def clear_all(cls, db_client) -> None:
collection = db_client["connector_sessions"]
await asyncio.to_thread(collection.delete_many, {})
logging.info("Cleared all OAuth client cache data.")
logger.info("Cleared all OAuth client cache data.")
class MCPOAuthManager:
@@ -858,7 +971,7 @@ class MCPOAuthManager:
return True
except Exception as e:
logging.error(f"Error handling OAuth callback: {e}")
logger.error("Error handling OAuth callback: %s", e)
return False
def get_oauth_status(self, task_id: str) -> Dict[str, Any]:

View File

@@ -116,12 +116,13 @@ class NtfyTool(Tool):
]
def get_config_requirements(self):
"""
Specify the configuration requirements.
Returns:
dict: Dictionary describing required config parameters.
"""
return {
"token": {"type": "string", "description": "Access token for authentication"},
"token": {
"type": "string",
"label": "Access Token",
"description": "Ntfy access token for authentication",
"required": True,
"secret": True,
"order": 1,
},
}

View File

@@ -28,6 +28,9 @@ class PostgresTool(Tool):
return actions[action_name](**kwargs)
def _execute_sql(self, sql_query):
"""
Executes an SQL query against the PostgreSQL database using a connection string.
"""
conn = None
try:
conn = psycopg2.connect(self.connection_string)
@@ -36,7 +39,9 @@ class PostgresTool(Tool):
conn.commit()
if sql_query.strip().lower().startswith("select"):
column_names = [desc[0] for desc in cur.description] if cur.description else []
column_names = (
[desc[0] for desc in cur.description] if cur.description else []
)
results = []
rows = cur.fetchall()
for row in rows:
@@ -44,7 +49,9 @@ class PostgresTool(Tool):
response_data = {"data": results, "column_names": column_names}
else:
row_count = cur.rowcount
response_data = {"message": f"Query executed successfully, {row_count} rows affected."}
response_data = {
"message": f"Query executed successfully, {row_count} rows affected."
}
cur.close()
return {
@@ -55,7 +62,7 @@ class PostgresTool(Tool):
except psycopg2.Error as e:
error_message = f"Database error: {e}"
logger.error(error_message)
logger.error("PostgreSQL execute_sql error: %s", e)
return {
"status_code": 500,
"message": "Failed to execute SQL query.",
@@ -69,12 +76,13 @@ class PostgresTool(Tool):
"""
Retrieves the schema of the PostgreSQL database using a connection string.
"""
conn = None # Initialize conn to None for error handling
conn = None
try:
conn = psycopg2.connect(self.connection_string)
cur = conn.cursor()
cur.execute("""
cur.execute(
"""
SELECT
table_name,
column_name,
@@ -88,19 +96,22 @@ class PostgresTool(Tool):
ORDER BY
table_name,
ordinal_position;
""")
"""
)
schema_data = {}
for row in cur.fetchall():
table_name, column_name, data_type, column_default, is_nullable = row
if table_name not in schema_data:
schema_data[table_name] = []
schema_data[table_name].append({
"column_name": column_name,
"data_type": data_type,
"column_default": column_default,
"is_nullable": is_nullable
})
schema_data[table_name].append(
{
"column_name": column_name,
"data_type": data_type,
"column_default": column_default,
"is_nullable": is_nullable,
}
)
cur.close()
return {
@@ -111,7 +122,7 @@ class PostgresTool(Tool):
except psycopg2.Error as e:
error_message = f"Database error: {e}"
logger.error(error_message)
logger.error("PostgreSQL get_schema error: %s", e)
return {
"status_code": 500,
"message": "Failed to retrieve database schema.",
@@ -159,6 +170,10 @@ class PostgresTool(Tool):
return {
"token": {
"type": "string",
"description": "PostgreSQL database connection string (e.g., 'postgresql://user:password@host:port/dbname')",
"label": "Connection String",
"description": "PostgreSQL database connection string",
"required": True,
"secret": True,
"order": 1,
},
}
}

View File

@@ -28,14 +28,14 @@ class TelegramTool(Tool):
return actions[action_name](**kwargs)
def _send_message(self, text, chat_id):
logger.info(f"Telegram: sending message to {chat_id}")
logger.debug("Sending Telegram message to chat_id=%s", chat_id)
url = f"https://api.telegram.org/bot{self.token}/sendMessage"
payload = {"chat_id": chat_id, "text": text}
response = requests.post(url, data=payload)
return {"status_code": response.status_code, "message": "Message sent"}
def _send_image(self, image_url, chat_id):
logger.info(f"Telegram: sending image to {chat_id}")
logger.debug("Sending Telegram image to chat_id=%s", chat_id)
url = f"https://api.telegram.org/bot{self.token}/sendPhoto"
payload = {"chat_id": chat_id, "photo": image_url}
response = requests.post(url, data=payload)
@@ -85,5 +85,12 @@ class TelegramTool(Tool):
def get_config_requirements(self):
return {
"token": {"type": "string", "description": "Bot token for authentication"},
"token": {
"type": "string",
"label": "Bot Token",
"description": "Telegram bot token for authentication",
"required": True,
"secret": True,
"order": 1,
},
}

View File

@@ -36,7 +36,7 @@ class ToolManager:
def execute_action(self, tool_name, action_name, user_id=None, **kwargs):
if tool_name not in self.tools:
raise ValueError(f"Tool '{tool_name}' not loaded")
if tool_name in {"mcp_tool", "memory", "todo_list"} and user_id:
if tool_name in {"mcp_tool", "memory", "todo_list", "notes"} and user_id:
tool_config = self.config.get(tool_name, {})
tool = self.load_tool(tool_name, tool_config, user_id)
return tool.execute_action(action_name, **kwargs)

View File

@@ -1,21 +1,67 @@
"""Tool management MCP server integration."""
import json
from urllib.parse import unquote, urlencode
from urllib.parse import urlencode, urlparse
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, redirect, request
from flask_restx import fields, Namespace, Resource
from flask_restx import Namespace, Resource, fields
from application.agents.tools.mcp_tool import MCPOAuthManager, MCPTool
from application.api import api
from application.api.user.base import user_tools_collection
from application.api.user.tools.routes import transform_actions
from application.cache import get_redis_instance
from application.security.encryption import encrypt_credentials
from application.core.mongo_db import MongoDB
from application.core.settings import settings
from application.security.encryption import decrypt_credentials, encrypt_credentials
from application.utils import check_required_fields
tools_mcp_ns = Namespace("tools", description="Tool management operations", path="/api")
_mongo = MongoDB.get_client()
_db = _mongo[settings.MONGO_DB_NAME]
_connector_sessions = _db["connector_sessions"]
_ALLOWED_TRANSPORTS = {"auto", "sse", "http"}
def _sanitize_mcp_transport(config):
"""Normalise and validate the transport_type field.
Strips ``command`` / ``args`` keys that are only valid for local STDIO
transports and returns the cleaned transport type string.
"""
transport_type = (config.get("transport_type") or "auto").lower()
if transport_type not in _ALLOWED_TRANSPORTS:
raise ValueError(f"Unsupported transport_type: {transport_type}")
config.pop("command", None)
config.pop("args", None)
config["transport_type"] = transport_type
return transport_type
def _extract_auth_credentials(config):
"""Build an ``auth_credentials`` dict from the raw MCP config."""
auth_credentials = {}
auth_type = config.get("auth_type", "none")
if auth_type == "api_key":
if config.get("api_key"):
auth_credentials["api_key"] = config["api_key"]
if config.get("api_key_header"):
auth_credentials["api_key_header"] = config["api_key_header"]
elif auth_type == "bearer":
if config.get("bearer_token"):
auth_credentials["bearer_token"] = config["bearer_token"]
elif auth_type == "basic":
if config.get("username"):
auth_credentials["username"] = config["username"]
if config.get("password"):
auth_credentials["password"] = config["password"]
return auth_credentials
@tools_mcp_ns.route("/mcp_server/test")
class TestMCPServerConfig(Resource):
@@ -43,49 +89,35 @@ class TestMCPServerConfig(Resource):
return missing_fields
try:
config = data["config"]
transport_type = (config.get("transport_type") or "auto").lower()
allowed_transports = {"auto", "sse", "http"}
if transport_type not in allowed_transports:
try:
_sanitize_mcp_transport(config)
except ValueError:
return make_response(
jsonify({"success": False, "error": "Unsupported transport_type"}),
400,
)
config.pop("command", None)
config.pop("args", None)
config["transport_type"] = transport_type
auth_credentials = {}
auth_type = config.get("auth_type", "none")
if auth_type == "api_key" and "api_key" in config:
auth_credentials["api_key"] = config["api_key"]
if "api_key_header" in config:
auth_credentials["api_key_header"] = config["api_key_header"]
elif auth_type == "bearer" and "bearer_token" in config:
auth_credentials["bearer_token"] = config["bearer_token"]
elif auth_type == "basic":
if "username" in config:
auth_credentials["username"] = config["username"]
if "password" in config:
auth_credentials["password"] = config["password"]
auth_credentials = _extract_auth_credentials(config)
test_config = config.copy()
test_config["auth_credentials"] = auth_credentials
mcp_tool = MCPTool(config=test_config, user_id=user)
result = mcp_tool.test_connection()
# Sanitize the response to avoid exposing internal error details
if result.get("requires_oauth"):
return make_response(jsonify(result), 200)
if not result.get("success") and "message" in result:
current_app.logger.error(f"MCP connection test failed: {result.get('message')}")
current_app.logger.error(
f"MCP connection test failed: {result.get('message')}"
)
result["message"] = "Connection test failed"
return make_response(jsonify(result), 200)
except Exception as e:
current_app.logger.error(f"Error testing MCP server: {e}", exc_info=True)
return make_response(
jsonify(
{"success": False, "error": "Connection test failed"}
),
jsonify({"success": False, "error": "Connection test failed"}),
500,
)
@@ -125,32 +157,16 @@ class MCPServerSave(Resource):
return missing_fields
try:
config = data["config"]
transport_type = (config.get("transport_type") or "auto").lower()
allowed_transports = {"auto", "sse", "http"}
if transport_type not in allowed_transports:
try:
_sanitize_mcp_transport(config)
except ValueError:
return make_response(
jsonify({"success": False, "error": "Unsupported transport_type"}),
400,
)
config.pop("command", None)
config.pop("args", None)
config["transport_type"] = transport_type
auth_credentials = {}
auth_credentials = _extract_auth_credentials(config)
auth_type = config.get("auth_type", "none")
if auth_type == "api_key":
if "api_key" in config and config["api_key"]:
auth_credentials["api_key"] = config["api_key"]
if "api_key_header" in config:
auth_credentials["api_key_header"] = config["api_key_header"]
elif auth_type == "bearer":
if "bearer_token" in config and config["bearer_token"]:
auth_credentials["bearer_token"] = config["bearer_token"]
elif auth_type == "basic":
if "username" in config and config["username"]:
auth_credentials["username"] = config["username"]
if "password" in config and config["password"]:
auth_credentials["password"] = config["password"]
mcp_config = config.copy()
mcp_config["auth_credentials"] = auth_credentials
@@ -188,30 +204,39 @@ class MCPServerSave(Resource):
"No valid credentials provided for the selected authentication type"
)
storage_config = config.copy()
tool_id = data.get("id")
existing_encrypted = None
if tool_id:
existing_doc = user_tools_collection.find_one(
{"_id": ObjectId(tool_id), "user": user, "name": "mcp_tool"}
)
if existing_doc:
existing_encrypted = existing_doc.get("config", {}).get(
"encrypted_credentials"
)
if auth_credentials:
encrypted_credentials_string = encrypt_credentials(
if existing_encrypted:
existing_secrets = decrypt_credentials(existing_encrypted, user)
existing_secrets.update(auth_credentials)
auth_credentials = existing_secrets
storage_config["encrypted_credentials"] = encrypt_credentials(
auth_credentials, user
)
storage_config["encrypted_credentials"] = encrypted_credentials_string
elif existing_encrypted:
storage_config["encrypted_credentials"] = existing_encrypted
for field in [
"api_key",
"bearer_token",
"username",
"password",
"api_key_header",
"redirect_uri",
]:
storage_config.pop(field, None)
transformed_actions = []
for action in actions_metadata:
action["active"] = True
if "parameters" in action:
if "properties" in action["parameters"]:
for param_name, param_details in action["parameters"][
"properties"
].items():
param_details["filled_by_llm"] = True
param_details["value"] = ""
transformed_actions.append(action)
transformed_actions = transform_actions(actions_metadata)
tool_data = {
"name": "mcp_tool",
"displayName": data["displayName"],
@@ -223,7 +248,6 @@ class MCPServerSave(Resource):
"user": user,
}
tool_id = data.get("id")
if tool_id:
result = user_tools_collection.update_one(
{"_id": ObjectId(tool_id), "user": user, "name": "mcp_tool"},
@@ -258,9 +282,7 @@ class MCPServerSave(Resource):
except Exception as e:
current_app.logger.error(f"Error saving MCP server: {e}", exc_info=True)
return make_response(
jsonify(
{"success": False, "error": "Failed to save MCP server"}
),
jsonify({"success": False, "error": "Failed to save MCP server"}),
500,
)
@@ -291,7 +313,7 @@ class MCPOAuthCallback(Resource):
params = {
"status": "error",
"message": f"OAuth error: {error}. Please try again and make sure to grant all requested permissions, including offline access.",
"provider": "mcp_tool"
"provider": "mcp_tool",
}
return redirect(f"/api/connectors/callback-status?{urlencode(params)}")
if not code or not state:
@@ -304,7 +326,6 @@ class MCPOAuthCallback(Resource):
return redirect(
"/api/connectors/callback-status?status=error&message=Internal+server+error:+Redis+not+available.&provider=mcp_tool"
)
code = unquote(code)
manager = MCPOAuthManager(redis_client)
success = manager.handle_oauth_callback(state, code, error)
if success:
@@ -327,10 +348,6 @@ class MCPOAuthCallback(Resource):
@tools_mcp_ns.route("/mcp_server/oauth_status/<string:task_id>")
class MCPOAuthStatus(Resource):
def get(self, task_id):
"""
Get current status of OAuth flow.
Frontend should poll this endpoint periodically.
"""
try:
redis_client = get_redis_instance()
status_key = f"mcp_oauth_status:{task_id}"
@@ -338,6 +355,14 @@ class MCPOAuthStatus(Resource):
if status_data:
status = json.loads(status_data)
if "tools" in status and isinstance(status["tools"], list):
status["tools"] = [
{
"name": t.get("name", "unknown"),
"description": t.get("description", ""),
}
for t in status["tools"]
]
return make_response(
jsonify({"success": True, "task_id": task_id, **status})
)
@@ -345,17 +370,93 @@ class MCPOAuthStatus(Resource):
return make_response(
jsonify(
{
"success": False,
"error": "Task not found or expired",
"success": True,
"task_id": task_id,
"status": "pending",
"message": "Waiting for OAuth to start...",
}
),
404,
200,
)
except Exception as e:
current_app.logger.error(
f"Error getting OAuth status for task {task_id}: {str(e)}", exc_info=True
f"Error getting OAuth status for task {task_id}: {str(e)}",
exc_info=True,
)
return make_response(
jsonify({"success": False, "error": "Failed to get OAuth status", "task_id": task_id}), 500
jsonify(
{
"success": False,
"error": "Failed to get OAuth status",
"task_id": task_id,
}
),
500,
)
@tools_mcp_ns.route("/mcp_server/auth_status")
class MCPAuthStatus(Resource):
@api.doc(
description="Batch check auth status for all MCP tools. "
"Lightweight DB-only check — no network calls to MCP servers."
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
mcp_tools = list(
user_tools_collection.find(
{"user": user, "name": "mcp_tool"},
{"_id": 1, "config": 1},
)
)
if not mcp_tools:
return make_response(jsonify({"success": True, "statuses": {}}), 200)
oauth_server_urls = {}
statuses = {}
for tool in mcp_tools:
tool_id = str(tool["_id"])
config = tool.get("config", {})
auth_type = config.get("auth_type", "none")
if auth_type == "oauth":
server_url = config.get("server_url", "")
if server_url:
parsed = urlparse(server_url)
base_url = f"{parsed.scheme}://{parsed.netloc}"
oauth_server_urls[tool_id] = base_url
else:
statuses[tool_id] = "needs_auth"
else:
statuses[tool_id] = "configured"
if oauth_server_urls:
unique_urls = list(set(oauth_server_urls.values()))
sessions = list(
_connector_sessions.find(
{"user_id": user, "server_url": {"$in": unique_urls}},
{"server_url": 1, "tokens": 1},
)
)
url_has_tokens = {
doc["server_url"]: bool(doc.get("tokens", {}).get("access_token"))
for doc in sessions
}
for tool_id, base_url in oauth_server_urls.items():
if url_has_tokens.get(base_url):
statuses[tool_id] = "connected"
else:
statuses[tool_id] = "needs_auth"
return make_response(jsonify({"success": True, "statuses": statuses}), 200)
except Exception as e:
current_app.logger.error(
"Error checking MCP auth status: %s", e, exc_info=True
)
return make_response(
jsonify({"success": False, "error": "Failed to check auth status"}),
500,
)

View File

@@ -15,6 +15,114 @@ tool_config = {}
tool_manager = ToolManager(config=tool_config)
def _encrypt_secret_fields(config, config_requirements, user_id):
secret_keys = [
key for key, spec in config_requirements.items()
if spec.get("secret") and key in config and config[key]
]
if not secret_keys:
return config
storage_config = config.copy()
secret_values = {k: config[k] for k in secret_keys}
storage_config["encrypted_credentials"] = encrypt_credentials(secret_values, user_id)
for key in secret_keys:
storage_config.pop(key, None)
return storage_config
def _validate_config(config, config_requirements, has_existing_secrets=False):
errors = {}
for key, spec in config_requirements.items():
depends_on = spec.get("depends_on")
if depends_on:
if not all(config.get(dk) == dv for dk, dv in depends_on.items()):
continue
if spec.get("required") and not config.get(key):
if has_existing_secrets and spec.get("secret"):
continue
errors[key] = f"{spec.get('label', key)} is required"
value = config.get(key)
if value is not None and value != "":
if spec.get("type") == "number":
try:
num = float(value)
if key == "timeout" and (num < 1 or num > 300):
errors[key] = "Timeout must be between 1 and 300"
except (ValueError, TypeError):
errors[key] = f"{spec.get('label', key)} must be a number"
if spec.get("enum") and value not in spec["enum"]:
errors[key] = f"Invalid value for {spec.get('label', key)}"
return errors
def _merge_secrets_on_update(new_config, existing_config, config_requirements, user_id):
"""Merge incoming config with existing encrypted secrets and re-encrypt.
For updates, the client may omit unchanged secret values. This helper
decrypts any previously stored secrets, overlays whatever the client *did*
send, strips plain-text secrets from the stored config, and re-encrypts
the merged result.
Returns the final ``config`` dict ready for persistence.
"""
secret_keys = [
key for key, spec in config_requirements.items()
if spec.get("secret")
]
if not secret_keys:
return new_config
existing_secrets = {}
if "encrypted_credentials" in existing_config:
existing_secrets = decrypt_credentials(
existing_config["encrypted_credentials"], user_id
)
merged_secrets = existing_secrets.copy()
for key in secret_keys:
if key in new_config and new_config[key]:
merged_secrets[key] = new_config[key]
# Start from existing non-secret values, then overlay incoming non-secrets
storage_config = {
k: v for k, v in existing_config.items()
if k not in secret_keys and k != "encrypted_credentials"
}
storage_config.update(
{k: v for k, v in new_config.items() if k not in secret_keys}
)
if merged_secrets:
storage_config["encrypted_credentials"] = encrypt_credentials(
merged_secrets, user_id
)
else:
storage_config.pop("encrypted_credentials", None)
storage_config.pop("has_encrypted_credentials", None)
return storage_config
def transform_actions(actions_metadata):
"""Set default flags on action metadata for storage.
Marks each action as active, sets ``filled_by_llm`` and ``value`` on every
parameter property. Used by both the generic create_tool and MCP save routes.
"""
transformed = []
for action in actions_metadata:
action["active"] = True
if "parameters" in action:
props = action["parameters"].get("properties", {})
for param_details in props.values():
param_details["filled_by_llm"] = True
param_details["value"] = ""
transformed.append(action)
return transformed
tools_ns = Namespace("tools", description="Tool management operations", path="/api")
@@ -29,12 +137,15 @@ class AvailableTools(Resource):
lines = doc.split("\n", 1)
name = lines[0].strip()
description = lines[1].strip() if len(lines) > 1 else ""
config_req = tool_instance.get_config_requirements()
actions = tool_instance.get_actions_metadata()
tools_metadata.append(
{
"name": tool_name,
"displayName": name,
"description": description,
"configRequirements": tool_instance.get_config_requirements(),
"configRequirements": config_req,
"actions": actions,
}
)
except Exception as err:
@@ -60,6 +171,21 @@ class GetTools(Resource):
tool_copy = {**tool}
tool_copy["id"] = str(tool["_id"])
tool_copy.pop("_id", None)
config_req = tool_copy.get("configRequirements", {})
if not config_req:
tool_instance = tool_manager.tools.get(tool_copy.get("name"))
if tool_instance:
config_req = tool_instance.get_config_requirements()
tool_copy["configRequirements"] = config_req
has_secrets = any(
spec.get("secret") for spec in config_req.values()
) if config_req else False
if has_secrets and "encrypted_credentials" in tool_copy.get("config", {}):
tool_copy["config"]["has_encrypted_credentials"] = True
tool_copy["config"].pop("encrypted_credentials", None)
user_tools.append(tool_copy)
except Exception as err:
current_app.logger.error(f"Error getting user tools: {err}", exc_info=True)
@@ -116,23 +242,32 @@ class CreateTool(Resource):
jsonify({"success": False, "message": "Tool not found"}), 404
)
actions_metadata = tool_instance.get_actions_metadata()
transformed_actions = []
for action in actions_metadata:
action["active"] = True
if "parameters" in action:
if "properties" in action["parameters"]:
for param_name, param_details in action["parameters"][
"properties"
].items():
param_details["filled_by_llm"] = True
param_details["value"] = ""
transformed_actions.append(action)
transformed_actions = transform_actions(actions_metadata)
except Exception as err:
current_app.logger.error(
f"Error getting tool actions: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
try:
config_requirements = tool_instance.get_config_requirements()
if config_requirements:
validation_errors = _validate_config(
data["config"], config_requirements
)
if validation_errors:
return make_response(
jsonify(
{
"success": False,
"message": "Validation failed",
"errors": validation_errors,
}
),
400,
)
storage_config = _encrypt_secret_fields(
data["config"], config_requirements, user
)
new_tool = {
"user": user,
"name": data["name"],
@@ -140,7 +275,8 @@ class CreateTool(Resource):
"description": data["description"],
"customName": data.get("customName", ""),
"actions": transformed_actions,
"config": data["config"],
"config": storage_config,
"configRequirements": config_requirements,
"status": data["status"],
}
resp = user_tools_collection.insert_one(new_tool)
@@ -210,57 +346,37 @@ class UpdateTool(Resource):
tool_doc = user_tools_collection.find_one(
{"_id": ObjectId(data["id"]), "user": user}
)
if tool_doc and tool_doc.get("name") == "mcp_tool":
config = data["config"]
existing_config = tool_doc.get("config", {})
storage_config = existing_config.copy()
if not tool_doc:
return make_response(
jsonify({"success": False, "message": "Tool not found"}),
404,
)
tool_name = tool_doc.get("name", data.get("name"))
tool_instance = tool_manager.tools.get(tool_name)
config_requirements = (
tool_instance.get_config_requirements() if tool_instance else {}
)
existing_config = tool_doc.get("config", {})
has_existing_secrets = "encrypted_credentials" in existing_config
storage_config.update(config)
existing_credentials = {}
if "encrypted_credentials" in existing_config:
existing_credentials = decrypt_credentials(
existing_config["encrypted_credentials"], user
if config_requirements:
validation_errors = _validate_config(
data["config"], config_requirements,
has_existing_secrets=has_existing_secrets,
)
if validation_errors:
return make_response(
jsonify({
"success": False,
"message": "Validation failed",
"errors": validation_errors,
}),
400,
)
auth_credentials = existing_credentials.copy()
auth_type = storage_config.get("auth_type", "none")
if auth_type == "api_key":
if "api_key" in config and config["api_key"]:
auth_credentials["api_key"] = config["api_key"]
if "api_key_header" in config:
auth_credentials["api_key_header"] = config[
"api_key_header"
]
elif auth_type == "bearer":
if "bearer_token" in config and config["bearer_token"]:
auth_credentials["bearer_token"] = config["bearer_token"]
elif "encrypted_token" in config and config["encrypted_token"]:
auth_credentials["bearer_token"] = config["encrypted_token"]
elif auth_type == "basic":
if "username" in config and config["username"]:
auth_credentials["username"] = config["username"]
if "password" in config and config["password"]:
auth_credentials["password"] = config["password"]
if auth_type != "none" and auth_credentials:
encrypted_credentials_string = encrypt_credentials(
auth_credentials, user
)
storage_config["encrypted_credentials"] = (
encrypted_credentials_string
)
elif auth_type == "none":
storage_config.pop("encrypted_credentials", None)
for field in [
"api_key",
"bearer_token",
"encrypted_token",
"username",
"password",
"api_key_header",
]:
storage_config.pop(field, None)
update_data["config"] = storage_config
else:
update_data["config"] = data["config"]
update_data["config"] = _merge_secrets_on_update(
data["config"], existing_config, config_requirements, user
)
if "status" in data:
update_data["status"] = data["status"]
user_tools_collection.update_one(
@@ -298,9 +414,42 @@ class UpdateToolConfig(Resource):
if missing_fields:
return missing_fields
try:
tool_doc = user_tools_collection.find_one(
{"_id": ObjectId(data["id"]), "user": user}
)
if not tool_doc:
return make_response(jsonify({"success": False}), 404)
tool_name = tool_doc.get("name")
tool_instance = tool_manager.tools.get(tool_name)
config_requirements = (
tool_instance.get_config_requirements() if tool_instance else {}
)
existing_config = tool_doc.get("config", {})
has_existing_secrets = "encrypted_credentials" in existing_config
if config_requirements:
validation_errors = _validate_config(
data["config"], config_requirements,
has_existing_secrets=has_existing_secrets,
)
if validation_errors:
return make_response(
jsonify({
"success": False,
"message": "Validation failed",
"errors": validation_errors,
}),
400,
)
final_config = _merge_secrets_on_update(
data["config"], existing_config, config_requirements, user
)
user_tools_collection.update_one(
{"_id": ObjectId(data["id"]), "user": user},
{"$set": {"config": data["config"]}},
{"$set": {"config": final_config}},
)
except Exception as err:
current_app.logger.error(
@@ -410,11 +559,13 @@ class DeleteTool(Resource):
{"_id": ObjectId(data["id"]), "user": user}
)
if result.deleted_count == 0:
return {"success": False, "message": "Tool not found"}, 404
return make_response(
jsonify({"success": False, "message": "Tool not found"}), 404
)
except Exception as err:
current_app.logger.error(f"Error deleting tool: {err}", exc_info=True)
return {"success": False}, 400
return {"success": True}, 200
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@tools_ns.route("/parse_spec")
@@ -511,7 +662,6 @@ class GetArtifact(Resource):
todo_doc = db["todos"].find_one({"_id": obj_id, "user_id": user_id})
if todo_doc:
tool_id = todo_doc.get("tool_id")
# Return all todos for the tool
query = {"user_id": user_id, "tool_id": tool_id}
all_todos = list(db["todos"].find(query))
items = []

View File

@@ -78,6 +78,7 @@ class Settings(BaseSettings):
CACHE_REDIS_URL: str = "redis://localhost:6379/2"
API_URL: str = "http://localhost:7091" # backend url for celery worker
MCP_OAUTH_REDIRECT_URI: Optional[str] = None # public callback URL for MCP OAuth
INTERNAL_KEY: Optional[str] = None # internal api key for worker-to-backend auth
API_KEY: Optional[str] = None # LLM api key (used by LLM_PROVIDER)

View File

@@ -1449,34 +1449,28 @@ def ingest_connector(
def mcp_oauth(self, config: Dict[str, Any], user_id: str = None) -> Dict[str, Any]:
"""Worker to handle MCP OAuth flow asynchronously."""
logging.info(
"[MCP OAuth] Worker started for user_id=%s, config=%s", user_id, config
)
try:
import asyncio
from application.agents.tools.mcp_tool import MCPTool
task_id = self.request.id
logging.info("[MCP OAuth] Task ID: %s", task_id)
redis_client = get_redis_instance()
def update_status(status_data: Dict[str, Any]):
logging.info("[MCP OAuth] Updating status: %s", status_data)
status_key = f"mcp_oauth_status:{task_id}"
redis_client.setex(status_key, 600, json.dumps(status_data))
update_status(
{
"status": "in_progress",
"message": "Starting OAuth flow...",
"message": "Starting OAuth...",
"task_id": task_id,
}
)
tool_config = config.copy()
tool_config["oauth_task_id"] = task_id
logging.info("[MCP OAuth] Initializing MCPTool with config: %s", tool_config)
mcp_tool = MCPTool(tool_config, user_id)
async def run_oauth_discovery():
@@ -1487,7 +1481,7 @@ def mcp_oauth(self, config: Dict[str, Any], user_id: str = None) -> Dict[str, An
update_status(
{
"status": "awaiting_redirect",
"message": "Waiting for OAuth redirect...",
"message": "Awaiting OAuth redirect...",
"task_id": task_id,
}
)
@@ -1496,66 +1490,40 @@ def mcp_oauth(self, config: Dict[str, Any], user_id: str = None) -> Dict[str, An
asyncio.set_event_loop(loop)
try:
logging.info("[MCP OAuth] Starting event loop for OAuth discovery...")
tools_response = loop.run_until_complete(run_oauth_discovery())
logging.info(
"[MCP OAuth] Tools response after async call: %s", tools_response
)
status_key = f"mcp_oauth_status:{task_id}"
redis_status = redis_client.get(status_key)
if redis_status:
logging.info(
"[MCP OAuth] Redis status after async call: %s", redis_status
)
else:
logging.warning(
"[MCP OAuth] No Redis status found after async call for key: %s",
status_key,
)
loop.run_until_complete(run_oauth_discovery())
tools = mcp_tool.get_actions_metadata()
update_status(
{
"status": "completed",
"message": f"OAuth completed successfully. Found {len(tools)} tools.",
"message": f"Connected \u2014 found {len(tools)} tool{'s' if len(tools) != 1 else ''}.",
"tools": tools,
"tools_count": len(tools),
"task_id": task_id,
}
)
logging.info(
"[MCP OAuth] OAuth flow completed successfully for task_id=%s", task_id
)
return {"success": True, "tools": tools, "tools_count": len(tools)}
except Exception as e:
error_msg = f"OAuth flow failed: {str(e)}"
logging.error(
"[MCP OAuth] Exception in OAuth discovery: %s", error_msg, exc_info=True
)
error_msg = f"OAuth failed: {str(e)}"
logging.error("MCP OAuth discovery failed: %s", error_msg, exc_info=True)
update_status(
{
"status": "error",
"message": error_msg,
"error": str(e),
"task_id": task_id,
}
)
return {"success": False, "error": error_msg}
finally:
logging.info("[MCP OAuth] Closing event loop for task_id=%s", task_id)
loop.close()
except Exception as e:
error_msg = f"Failed to initialize OAuth flow: {str(e)}"
logging.error(
"[MCP OAuth] Exception during initialization: %s", error_msg, exc_info=True
)
error_msg = f"OAuth init failed: {str(e)}"
logging.error("MCP OAuth init failed: %s", error_msg, exc_info=True)
update_status(
{
"status": "error",
"message": error_msg,
"error": str(e),
"task_id": task_id,
}
)

View File

@@ -35,6 +35,7 @@
"lucide-react": "^0.562.0",
"mermaid": "^11.12.1",
"prop-types": "^15.8.1",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.1",

View File

@@ -65,6 +65,7 @@ const endpoints = {
MCP_SAVE_SERVER: '/api/mcp_server/save',
MCP_OAUTH_STATUS: (task_id: string) =>
`/api/mcp_server/oauth_status/${task_id}`,
MCP_AUTH_STATUS: '/api/mcp_server/auth_status',
AGENT_FOLDERS: '/api/agents/folders/',
AGENT_FOLDER: (id: string) => `/api/agents/folders/${id}`,
MOVE_AGENT_TO_FOLDER: '/api/agents/folders/move_agent',

View File

@@ -123,6 +123,8 @@ const userService = {
apiClient.post(endpoints.USER.MCP_SAVE_SERVER, data, token),
getMCPOAuthStatus: (task_id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.MCP_OAUTH_STATUS(task_id), token),
getMCPAuthStatus: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.MCP_AUTH_STATUS, token),
syncConnector: (
docId: string,
provider: string,

View File

@@ -0,0 +1,149 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { ConfigRequirements } from '../modals/types';
import { Input } from './ui/input';
import { Label } from './ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select';
type ConfigValues = { [key: string]: any };
interface ConfigFieldsProps {
configRequirements: ConfigRequirements;
values: ConfigValues;
onChange: (key: string, value: any) => void;
errors?: { [key: string]: string };
isEditing?: boolean;
hasEncryptedCredentials?: boolean;
}
function shouldShowField(
spec: ConfigRequirements[string],
values: ConfigValues,
): boolean {
if (!spec.depends_on) return true;
return Object.entries(spec.depends_on).every(
([depKey, depValue]) => values[depKey] === depValue,
);
}
export default function ConfigFields({
configRequirements,
values,
onChange,
errors = {},
isEditing = false,
hasEncryptedCredentials = false,
}: ConfigFieldsProps) {
const sortedFields = useMemo(
() =>
Object.entries(configRequirements).sort(
([, a], [, b]) => (a.order ?? 99) - (b.order ?? 99),
),
[configRequirements],
);
if (sortedFields.length === 0) return null;
return (
<div className="flex flex-col gap-4">
{sortedFields.map(([key, spec]) => {
if (!shouldShowField(spec, values)) return null;
const value = values[key] ?? spec.default ?? '';
const hasEncrypted =
isEditing && spec.secret && hasEncryptedCredentials;
const placeholder = hasEncrypted ? '••••••••' : '';
const hasError = !!errors[key];
if (spec.enum) {
return (
<div key={key} className="flex flex-col gap-1.5">
<Label htmlFor={key}>
{spec.label || key}
{spec.required && (
<span className="text-red-500">*</span>
)}
</Label>
<Select
value={value || spec.default || ''}
onValueChange={(v) => onChange(key, v)}
>
<SelectTrigger
id={key}
variant="ghost"
size="lg"
className={cn(
'w-full rounded-xl',
hasError && 'border-destructive aria-invalid:ring-destructive/20',
)}
>
<SelectValue placeholder={spec.label || key} />
</SelectTrigger>
<SelectContent>
{spec.enum.map((v) => (
<SelectItem key={v} value={v}>
{v.charAt(0).toUpperCase() + v.slice(1).replace(/_/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
{hasError && (
<p className="text-xs text-destructive">{errors[key]}</p>
)}
</div>
);
}
return (
<div key={key} className="flex flex-col gap-1.5">
<Label htmlFor={key}>
{spec.label || key}
{spec.required && (
<span className="text-red-500">*</span>
)}
</Label>
<Input
id={key}
type={
spec.secret
? 'password'
: spec.type === 'number'
? 'number'
: 'text'
}
value={value}
onChange={(e) => {
const v = e.target.value;
if (spec.type === 'number') {
if (v === '') onChange(key, '');
else {
const num = parseInt(v, 10);
if (!isNaN(num)) onChange(key, num);
}
} else {
onChange(key, v);
}
}}
placeholder={placeholder || spec.description || ''}
min={spec.type === 'number' ? 1 : undefined}
max={spec.type === 'number' && key === 'timeout' ? 300 : undefined}
aria-invalid={hasError || undefined}
className={cn('rounded-xl', hasError && 'border-destructive')}
/>
{hasError && (
<p className="text-xs text-destructive">{errors[key]}</p>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,7 +1,9 @@
import { SyntheticEvent, useRef, useEffect, CSSProperties } from 'react';
import { CSSProperties, SyntheticEvent, useEffect, useRef } from 'react';
import type { LucideIcon } from 'lucide-react';
export interface MenuOption {
icon?: string;
icon?: string | LucideIcon;
label: string;
onClick: (event: SyntheticEvent) => void;
variant?: 'primary' | 'danger';
@@ -145,16 +147,28 @@ export default function ContextMenu({
>
{option.icon && (
<div className="flex w-4 min-w-4 shrink-0 justify-center">
<img
width={option.iconWidth || 16}
height={option.iconHeight || 16}
src={option.icon}
alt={option.label}
className={`cursor-pointer ${option.iconClassName || ''}`}
/>
{typeof option.icon === 'string' ? (
<img
width={option.iconWidth || 16}
height={option.iconHeight || 16}
src={option.icon}
alt={option.label}
className={`cursor-pointer ${option.iconClassName || ''}`}
/>
) : (
<option.icon
size={Math.max(
option.iconWidth || 16,
option.iconHeight || 16,
)}
strokeWidth={1.75}
aria-hidden="true"
className={`cursor-pointer ${option.iconClassName || ''}`}
/>
)}
</div>
)}
<span className="break-words hyphens-auto">{option.label}</span>
<span className="wrap-break-word hyphens-auto">{option.label}</span>
</button>
))}
</div>

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'text-foreground file:text-foreground placeholder:text-muted-foreground border-silver h-[42px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'dark:border-silver/40 dark:bg-transparent dark:text-white dark:placeholder:text-gray-400',
'selection:bg-primary selection:text-primary-foreground',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,22 @@
import { Label as LabelPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -25,17 +25,23 @@ function SelectValue({
function SelectTrigger({
className,
size = 'default',
variant = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
size?: 'sm' | 'default' | 'lg';
variant?: 'default' | 'ghost';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-light-silver aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-visible:ring-purple-30/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-white px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none hover:bg-gray-50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-placeholder:text-gray-500 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:hover:bg-[#383838] dark:data-placeholder:text-gray-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-gray-600 dark:[&_svg:not([class*='text-'])]:text-gray-400",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 data-[size=lg]:h-[42px] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-gray-600 dark:[&_svg:not([class*='text-'])]:text-gray-400",
variant === 'default' &&
'border-light-silver bg-white focus-visible:ring-purple-30/50 hover:bg-gray-50 data-placeholder:text-gray-500 dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:hover:bg-[#383838] dark:data-placeholder:text-gray-400',
variant === 'ghost' &&
'border-silver bg-transparent focus-visible:ring-purple-30/50 hover:bg-gray-50 data-[state=open]:bg-gray-50 data-placeholder:text-gray-500 dark:border-silver/40 dark:bg-transparent dark:hover:bg-white/5 dark:data-[state=open]:bg-white/10 dark:data-placeholder:text-gray-400',
className,
)}
{...props}
@@ -60,7 +66,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'border-light-silver bg-lotion data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border text-gray-900 shadow-md dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white',
'border-light-silver bg-lotion data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-200 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border text-gray-900 shadow-md dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,

View File

@@ -841,18 +841,18 @@ Avoid over-scrolling in mobile browsers
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary: oklch(0.554 0.185 294.8); /* purple-30 #7d54d1 */
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.914 0.035 300.2); /* purple-3000 - light purple */
--secondary-foreground: oklch(0.554 0.185 294.8);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent: oklch(0.914 0.035 300.2); /* purple-3000 - light purple hover */
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--input: oklch(0.870 0 0); /* neutral gray border */
--ring: oklch(0.554 0.185 294.8); /* purple-30 focus ring */
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@@ -860,12 +860,12 @@ Avoid over-scrolling in mobile browsers
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary: oklch(0.554 0.185 294.8); /* purple-30 */
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent: oklch(0.914 0.035 300.2); /* purple-3000 */
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar-ring: oklch(0.554 0.185 294.8); /* purple-30 */
}
/*
@@ -879,18 +879,18 @@ Avoid over-scrolling in mobile browsers
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--primary: oklch(0.636 0.197 295.4); /* violets-are-blue #976af3 */
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0.03 295.0); /* dark muted purple */
--secondary-foreground: oklch(0.867 0.052 300.1);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent: oklch(0.269 0.04 295.0); /* dark purple hover */
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--ring: oklch(0.636 0.197 295.4); /* violets-are-blue focus ring */
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
@@ -898,12 +898,12 @@ Avoid over-scrolling in mobile browsers
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary: oklch(0.636 0.197 295.4); /* violets-are-blue */
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent: oklch(0.269 0.04 295.0); /* dark purple */
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-ring: oklch(0.636 0.197 295.4); /* violets-are-blue */
}
/*

View File

@@ -154,6 +154,12 @@
"manageTools": "Go to Tools",
"edit": "Edit",
"delete": "Delete",
"reconnect": "Reconnect",
"authStatus": {
"connected": "Connected",
"needsAuth": "Needs Auth",
"configured": "Configured"
},
"deleteWarning": "Are you sure you want to delete the tool \"{{toolName}}\" ?",
"unsavedChanges": "You have unsaved changes that will be lost if you leave without saving.",
"leaveWithoutSaving": "Leave without Saving",
@@ -180,6 +186,8 @@
"deleteActionWarning": "Are you sure you want to delete the action \"{{name}}\"?",
"backToAllTools": "Back to all tools",
"save": "Save",
"saving": "Saving...",
"saveFailed": "Failed to save tool configuration",
"fieldName": "Field Name",
"fieldType": "Field Type",
"filledByLLM": "Filled by LLM",
@@ -195,6 +203,8 @@
"mcp": {
"addServer": "Add MCP Server",
"editServer": "Edit Server",
"reconnectServer": "Reconnect Server",
"reenterCredentials": "Re-enter your credentials to test and update the connection.",
"serverName": "Server Name",
"serverUrl": "Server URL",
"headerName": "Header Name",
@@ -207,6 +217,8 @@
"noAuth": "No Authentication",
"oauthInProgress": "Waiting for OAuth completion...",
"oauthCompleted": "OAuth completed successfully",
"oauthPopupBlocked": "Popup blocked by browser. Click below to authorize:",
"openAuthPage": "Open authorization page",
"authType": "Authentication Type",
"defaultServerName": "My MCP Server",
"authTypes": {

View File

@@ -1,9 +1,11 @@
import React from 'react';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import Input from '../components/Input';
import ConfigFields from '../components/ConfigFields';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import { AvailableToolType } from './types';
@@ -24,74 +26,141 @@ export default function ConfigToolModal({
}: ConfigToolModalProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [authKey, setAuthKey] = React.useState<string>('');
const [customName, setCustomName] = React.useState<string>('');
const [configValues, setConfigValues] = useState<{ [key: string]: any }>({});
const [customName, setCustomName] = useState('');
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [saving, setSaving] = useState(false);
const handleAddTool = (tool: AvailableToolType) => {
const configRequirements = useMemo(
() => tool?.configRequirements ?? {},
[tool],
);
const hasConfig = Object.keys(configRequirements).length > 0;
const handleFieldChange = (key: string, value: any) => {
setConfigValues((prev) => ({ ...prev, [key]: value }));
if (errors[key]) setErrors((prev) => ({ ...prev, [key]: '' }));
};
const validate = () => {
const newErrors: { [key: string]: string } = {};
Object.entries(configRequirements).forEach(([key, spec]) => {
if (spec.depends_on) {
const visible = Object.entries(spec.depends_on).every(
([dk, dv]) => configValues[dk] === dv,
);
if (!visible) return;
}
if (spec.required && !configValues[key]?.toString().trim()) {
newErrors[key] = `${spec.label || key} is required`;
}
if (spec.type === 'number' && configValues[key] !== undefined) {
const num = Number(configValues[key]);
if (isNaN(num) || num < 1) {
newErrors[key] = 'Must be a positive number';
}
if (key === 'timeout' && num > 300) {
newErrors[key] = 'Maximum timeout is 300 seconds';
}
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleClose = () => {
setModalState('INACTIVE');
setConfigValues({});
setCustomName('');
setErrors({});
};
const handleAddTool = () => {
if (!tool || !validate()) return;
const config: { [key: string]: any } = {};
Object.entries(configRequirements).forEach(([key, spec]) => {
const val = configValues[key];
if (val !== undefined && val !== '') {
config[key] = val;
} else if (spec.default !== undefined) {
config[key] = spec.default;
}
});
setSaving(true);
userService
.createTool(
{
name: tool.name,
displayName: tool.displayName,
description: tool.description,
config: { token: authKey },
customName: customName,
config,
customName,
actions: tool.actions,
status: true,
},
token,
)
.then(() => {
setModalState('INACTIVE');
handleClose();
getUserTools();
});
})
.finally(() => setSaving(false));
};
// Only render when modal is active
if (modalState !== 'ACTIVE') return null;
if (modalState !== 'ACTIVE' || !tool) return null;
return (
<WrapperModal close={() => setModalState('INACTIVE')}>
<div>
<h2 className="text-jet dark:text-bright-gray px-3 text-xl font-semibold">
<WrapperModal close={handleClose}>
<div className="w-[400px] max-w-[90vw]">
<h2 className="text-eerie-black dark:text-bright-gray text-xl font-semibold">
{t('modals.configTool.title')}
</h2>
<p className="mt-5 px-3 text-sm text-gray-600 dark:text-gray-400">
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t('modals.configTool.type')}:{' '}
<span className="font-semibold">{tool?.name}</span>
<span className="font-medium text-gray-700 dark:text-gray-200">
{tool.displayName}
</span>
</p>
<div className="mt-6 px-3">
<Input
type="text"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
borderVariant="thin"
placeholder={t('modals.configTool.customNamePlaceholder')}
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
<div className="mt-6 flex flex-col gap-4 px-1">
<div className="flex flex-col gap-1.5">
<Label htmlFor="customName">
{t('modals.configTool.customNamePlaceholder')}
</Label>
<Input
id="customName"
type="text"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
placeholder={tool.displayName}
className="rounded-xl"
/>
</div>
{hasConfig && <ConfigFields
configRequirements={configRequirements}
values={configValues}
onChange={handleFieldChange}
errors={errors}
/>}
</div>
<div className="mt-6 px-3">
<Input
type="text"
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
borderVariant="thin"
placeholder={t('modals.configTool.apiKeyPlaceholder')}
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
</div>
<div className="mt-8 flex flex-row-reverse gap-1 px-3">
<div className="mt-8 flex flex-row-reverse gap-2">
<button
onClick={() => {
tool && handleAddTool(tool);
}}
className="bg-purple-30 hover:bg-violets-are-blue rounded-3xl px-5 py-2 text-sm text-white transition-all"
onClick={handleAddTool}
disabled={saving}
className="bg-purple-30 hover:bg-violets-are-blue disabled:opacity-60 rounded-full px-5 py-2 text-sm font-medium text-white transition-colors"
>
{t('modals.configTool.addButton')}
{saving
? t('modals.configTool.addButton') + '…'
: t('modals.configTool.addButton')}
</button>
<button
onClick={() => setModalState('INACTIVE')}
className="dark:text-light-gray cursor-pointer rounded-3xl px-5 py-2 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:hover:bg-[#767183]/50"
onClick={handleClose}
className="dark:text-light-gray cursor-pointer rounded-full px-5 py-2 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:hover:bg-[#767183]/50"
>
{t('modals.configTool.closeButton')}
</button>

View File

@@ -1,12 +1,19 @@
import { useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { baseURL } from '../api/client';
import userService from '../api/services/userService';
import Dropdown from '../components/Dropdown';
import Input from '../components/Input';
import Spinner from '../components/Spinner';
import { useOutsideAlerter } from '../hooks';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import WrapperComponent from './WrapperModal';
@@ -26,7 +33,6 @@ export default function MCPServerModal({
}: MCPServerModalProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const modalRef = useRef<HTMLDivElement>(null);
const authTypes = [
{ label: t('settings.tools.mcp.authTypes.none'), value: 'none' },
@@ -41,12 +47,12 @@ export default function MCPServerModal({
server_url: server?.server_url || '',
auth_type: server?.auth_type || 'none',
api_key: '',
header_name: 'X-API-Key',
header_name: server?.api_key_header || 'X-API-Key',
bearer_token: '',
username: '',
password: '',
timeout: server?.timeout || 30,
oauth_scopes: '',
oauth_scopes: server?.oauth_scopes || '',
oauth_task_id: '',
});
@@ -57,20 +63,63 @@ export default function MCPServerModal({
message: string;
status?: string;
authorization_url?: string;
tools?: { name: string; description?: string }[];
tools_count?: number;
} | null>(null);
const [discoveredTools, setDiscoveredTools] = useState<
{ name: string; description?: string }[]
>([]);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const oauthPopupRef = useRef<Window | null>(null);
const pollingCancelledRef = useRef(false);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [oauthCompleted, setOAuthCompleted] = useState(false);
const [saveActive, setSaveActive] = useState(false);
useOutsideAlerter(modalRef, () => {
if (modalState === 'ACTIVE') {
setModalState('INACTIVE');
resetForm();
const cleanupPolling = useCallback(() => {
pollingCancelledRef.current = true;
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
}, [modalState]);
if (oauthPopupRef.current && !oauthPopupRef.current.closed) {
oauthPopupRef.current.close();
}
oauthPopupRef.current = null;
}, []);
useEffect(() => {
return cleanupPolling;
}, [cleanupPolling]);
useEffect(() => {
if (modalState === 'ACTIVE' && server) {
const oauthScopes = Array.isArray(server.oauth_scopes)
? server.oauth_scopes.join(', ')
: server.oauth_scopes || '';
setFormData({
name: server.displayName || t('settings.tools.mcp.defaultServerName'),
server_url: server.server_url || '',
auth_type: server.auth_type || 'none',
api_key: '',
header_name: server.api_key_header || 'X-API-Key',
bearer_token: '',
username: '',
password: '',
timeout: server.timeout || 30,
oauth_scopes: oauthScopes,
oauth_task_id: '',
});
setErrors({});
setTestResult(null);
setDiscoveredTools([]);
setSaveActive(false);
setOAuthCompleted(false);
}
}, [modalState, server]);
const resetForm = () => {
cleanupPolling();
setFormData({
name: t('settings.tools.mcp.defaultServerName'),
server_url: '',
@@ -86,7 +135,10 @@ export default function MCPServerModal({
});
setErrors({});
setTestResult(null);
setDiscoveredTools([]);
setSaveActive(false);
setTesting(false);
setOAuthCompleted(false);
};
const validateForm = () => {
@@ -168,9 +220,10 @@ export default function MCPServerModal({
} else if (formData.auth_type === 'oauth') {
config.oauth_scopes = formData.oauth_scopes
.split(',')
.map((s) => s.trim())
.map((s: string) => s.trim())
.filter(Boolean);
config.oauth_task_id = formData.oauth_task_id.trim();
config.redirect_uri = `${baseURL.replace(/\/$/, '')}/api/mcp_server/callback`;
}
return config;
};
@@ -182,10 +235,16 @@ export default function MCPServerModal({
let attempts = 0;
const maxAttempts = 60;
let popupOpened = false;
pollingCancelledRef.current = false;
const poll = async () => {
if (pollingCancelledRef.current) return;
try {
const resp = await userService.getMCPOAuthStatus(taskId, token);
if (pollingCancelledRef.current) return;
const data = await resp.json();
if (pollingCancelledRef.current) return;
if (data.authorization_url && !popupOpened) {
if (oauthPopupRef.current && !oauthPopupRef.current.closed) {
oauthPopupRef.current.close();
@@ -196,7 +255,22 @@ export default function MCPServerModal({
'width=600,height=700',
);
popupOpened = true;
if (!oauthPopupRef.current) {
setTestResult({
success: true,
message: t('settings.tools.mcp.oauthPopupBlocked', {
defaultValue:
'Popup blocked by browser. Click below to authorize:',
}),
authorization_url: data.authorization_url,
});
}
}
const callbackReceived =
data.status === 'callback_received' || data.status === 'completed';
if (data.status === 'completed') {
setOAuthCompleted(true);
setSaveActive(true);
@@ -213,15 +287,30 @@ export default function MCPServerModal({
onComplete({
...data,
success: false,
message: t('settings.tools.mcp.errors.oauthFailed'),
message: data.message || t('settings.tools.mcp.errors.oauthFailed'),
});
if (oauthPopupRef.current && !oauthPopupRef.current.closed) {
oauthPopupRef.current.close();
}
} else {
if (++attempts < maxAttempts) setTimeout(poll, 1000);
else {
if (++attempts < maxAttempts) {
if (
oauthPopupRef.current &&
oauthPopupRef.current.closed &&
popupOpened &&
!callbackReceived
) {
setSaveActive(false);
onComplete({
success: false,
message: t('settings.tools.mcp.errors.oauthFailed'),
});
return;
}
pollTimerRef.current = setTimeout(poll, 1000);
} else {
setSaveActive(false);
cleanupPolling();
onComplete({
success: false,
message: t('settings.tools.mcp.errors.oauthTimeout'),
@@ -229,12 +318,16 @@ export default function MCPServerModal({
}
}
} catch {
if (++attempts < maxAttempts) setTimeout(poll, 1000);
else
if (pollingCancelledRef.current) return;
if (++attempts < maxAttempts) {
pollTimerRef.current = setTimeout(poll, 1000);
} else {
cleanupPolling();
onComplete({
success: false,
message: t('settings.tools.mcp.errors.oauthTimeout'),
});
}
}
};
poll();
@@ -242,8 +335,11 @@ export default function MCPServerModal({
const testConnection = async () => {
if (!validateForm()) return;
cleanupPolling();
setTesting(true);
setTestResult(null);
setDiscoveredTools([]);
setOAuthCompleted(false);
try {
const config = buildToolConfig();
const response = await userService.testMCPConnection({ config }, token);
@@ -258,10 +354,12 @@ export default function MCPServerModal({
success: true,
message: t('settings.tools.mcp.oauthInProgress'),
});
setOAuthCompleted(false);
setSaveActive(false);
pollOAuthStatus(result.task_id, (finalResult) => {
setTestResult(finalResult);
if (finalResult.tools && Array.isArray(finalResult.tools)) {
setDiscoveredTools(finalResult.tools);
}
setFormData((prev) => ({
...prev,
oauth_task_id: result.task_id || '',
@@ -270,6 +368,9 @@ export default function MCPServerModal({
});
} else {
setTestResult(result);
if (result.success && result.tools && Array.isArray(result.tools)) {
setDiscoveredTools(result.tools);
}
setSaveActive(result.success === true);
setTesting(false);
}
@@ -312,8 +413,7 @@ export default function MCPServerModal({
general: result.error || t('settings.tools.mcp.errors.saveFailed'),
});
}
} catch (error) {
console.error('Error saving MCP server:', error);
} catch {
setErrors({ general: t('settings.tools.mcp.errors.saveFailed') });
} finally {
setLoading(false);
@@ -324,113 +424,123 @@ export default function MCPServerModal({
switch (formData.auth_type) {
case 'api_key':
return (
<div className="mb-10">
<div className="mt-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="api_key">
{t('settings.tools.mcp.placeholders.apiKey')}
<span className="text-red-500">*</span>
</Label>
<Input
name="api_key"
id="api_key"
type="text"
className="rounded-md"
value={formData.api_key}
onChange={(e) => handleInputChange('api_key', e.target.value)}
placeholder={t('settings.tools.mcp.placeholders.apiKey')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
aria-invalid={!!errors.api_key || undefined}
className="rounded-xl"
/>
{errors.api_key && (
<p className="mt-1 text-sm text-red-600">{errors.api_key}</p>
<p className="text-destructive text-xs">{errors.api_key}</p>
)}
</div>
<div className="mt-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="header_name">
{t('settings.tools.mcp.headerName')}
</Label>
<Input
name="header_name"
id="header_name"
type="text"
className="rounded-md"
value={formData.header_name}
onChange={(e) =>
handleInputChange('header_name', e.target.value)
}
placeholder={t('settings.tools.mcp.headerName')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
placeholder="X-API-Key"
className="rounded-xl"
/>
</div>
</div>
);
case 'bearer':
return (
<div className="mb-10">
<div className="flex flex-col gap-1.5">
<Label htmlFor="bearer_token">
{t('settings.tools.mcp.placeholders.bearerToken')}
<span className="text-red-500">*</span>
</Label>
<Input
name="bearer_token"
id="bearer_token"
type="text"
className="rounded-md"
value={formData.bearer_token}
onChange={(e) =>
handleInputChange('bearer_token', e.target.value)
}
placeholder={t('settings.tools.mcp.placeholders.bearerToken')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
aria-invalid={!!errors.bearer_token || undefined}
className="rounded-xl"
/>
{errors.bearer_token && (
<p className="mt-1 text-sm text-red-600">{errors.bearer_token}</p>
<p className="text-destructive text-xs">{errors.bearer_token}</p>
)}
</div>
);
case 'basic':
return (
<div className="mb-10">
<div className="mt-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="username">
{t('settings.tools.mcp.username')}
<span className="text-red-500">*</span>
</Label>
<Input
name="username"
id="username"
type="text"
className="rounded-md"
value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value)}
placeholder={t('settings.tools.mcp.username')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
aria-invalid={!!errors.username || undefined}
className="rounded-xl"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-600">{errors.username}</p>
<p className="text-destructive text-xs">{errors.username}</p>
)}
</div>
<div className="mt-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="password">
{t('settings.tools.mcp.password')}
<span className="text-red-500">*</span>
</Label>
<Input
name="password"
type="text"
className="rounded-md"
id="password"
type="password"
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
placeholder={t('settings.tools.mcp.password')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
aria-invalid={!!errors.password || undefined}
className="rounded-xl"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
<p className="text-destructive text-xs">{errors.password}</p>
)}
</div>
</div>
);
case 'oauth':
return (
<div className="mb-10">
<div className="mt-6">
<Input
name="oauth_scopes"
type="text"
className="rounded-md"
value={formData.oauth_scopes}
onChange={(e) =>
handleInputChange('oauth_scopes', e.target.value)
}
placeholder={
t('settings.tools.mcp.placeholders.oauthScopes') ||
'Scopes (comma separated)'
}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth_scopes">
{t('settings.tools.mcp.placeholders.oauthScopes') ||
'Scopes (comma separated)'}
</Label>
<Input
id="oauth_scopes"
type="text"
value={formData.oauth_scopes}
onChange={(e) =>
handleInputChange('oauth_scopes', e.target.value)
}
placeholder="read, write"
className="rounded-xl"
/>
</div>
);
default:
@@ -451,69 +561,99 @@ export default function MCPServerModal({
<div className="px-6 py-4">
<h2 className="text-jet dark:text-bright-gray text-xl font-semibold">
{server
? t('settings.tools.mcp.editServer')
? t('settings.tools.mcp.reconnectServer', {
defaultValue: 'Reconnect Server',
})
: t('settings.tools.mcp.addServer')}
</h2>
</div>
<div className="flex-1 px-6">
<div className="space-y-6 py-6">
<div>
<div className="flex flex-col gap-4 px-0.5 py-4">
{server?.has_encrypted_credentials &&
formData.auth_type !== 'oauth' && (
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
{t('settings.tools.mcp.reenterCredentials', {
defaultValue:
'Re-enter your credentials to test and update the connection.',
})}
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="mcp-name">
{t('settings.tools.mcp.serverName')}
<span className="text-red-500">*</span>
</Label>
<Input
id="mcp-name"
type="text"
className="rounded-md"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
borderVariant="thin"
placeholder={t('settings.tools.mcp.serverName')}
labelBgClassName="bg-white dark:bg-charleston-green-2"
aria-invalid={!!errors.name || undefined}
className="rounded-xl"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
<p className="text-destructive text-xs">{errors.name}</p>
)}
</div>
<div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mcp-url">
{t('settings.tools.mcp.serverUrl')}
<span className="text-red-500">*</span>
</Label>
<Input
name="server_url"
id="mcp-url"
type="text"
className="rounded-md"
value={formData.server_url}
onChange={(e) =>
handleInputChange('server_url', e.target.value)
}
placeholder={t('settings.tools.mcp.serverUrl')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
placeholder="https://example.com/mcp"
aria-invalid={!!errors.server_url || undefined}
className="rounded-xl"
/>
{errors.server_url && (
<p className="mt-1 text-sm text-red-600">
<p className="text-destructive text-xs">
{errors.server_url}
</p>
)}
</div>
<Dropdown
placeholder={t('settings.tools.mcp.authType')}
selectedValue={
authTypes.find((type) => type.value === formData.auth_type)
?.label || null
}
onSelect={(selection: { label: string; value: string }) => {
handleInputChange('auth_type', selection.value);
}}
options={authTypes}
size="w-full"
rounded="3xl"
border="border"
/>
<div className="flex flex-col gap-1.5">
<Label>{t('settings.tools.mcp.authType')}</Label>
<Select
value={formData.auth_type}
onValueChange={(v) => handleInputChange('auth_type', v)}
>
<SelectTrigger
variant="ghost"
size="lg"
className="w-full rounded-xl"
>
<SelectValue
placeholder={t('settings.tools.mcp.authType')}
/>
</SelectTrigger>
<SelectContent>
{authTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{renderAuthFields()}
<div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mcp-timeout">
{t('settings.tools.mcp.timeout')}
</Label>
<Input
name="timeout"
id="mcp-timeout"
type="number"
className="rounded-md"
value={formData.timeout}
onChange={(e) => {
const value = e.target.value;
@@ -526,40 +666,94 @@ export default function MCPServerModal({
}
}
}}
placeholder={t('settings.tools.mcp.timeout')}
borderVariant="thin"
labelBgClassName="bg-white dark:bg-charleston-green-2"
placeholder="30"
min={1}
max={300}
aria-invalid={!!errors.timeout || undefined}
className="rounded-xl"
/>
{errors.timeout && (
<p className="mt-2 text-sm text-red-600">{errors.timeout}</p>
<p className="text-destructive text-xs">{errors.timeout}</p>
)}
</div>
{testResult && (
<div
className={`rounded-2xl p-5 ${
className={`rounded-xl p-4 text-sm ${
testResult.success
? 'bg-green-50 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-red-50 text-red-700 dark:bg-red-900 dark:text-red-300'
: 'bg-red-50 text-red-700 dark:bg-red-900/40 dark:text-red-300'
}`}
>
{testResult.message}
<p>{testResult.message}</p>
{testResult.authorization_url && (
<a
href={testResult.authorization_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
const popup = window.open(
testResult.authorization_url,
'oauthPopup',
'width=600,height=700',
);
if (popup) oauthPopupRef.current = popup;
}}
className="mt-1.5 inline-block font-medium underline"
>
{t('settings.tools.mcp.openAuthPage', {
defaultValue: 'Open authorization page',
})}
</a>
)}
</div>
)}
{discoveredTools.length > 0 && testResult?.success && (
<div className="border-silver dark:border-silver/40 rounded-xl border p-4">
<h4 className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{t('settings.tools.mcp.discoveredTools', {
count: discoveredTools.length,
defaultValue: `Discovered Actions (${discoveredTools.length})`,
})}
</h4>
<ul className="flex max-h-40 flex-col gap-1.5 overflow-y-auto">
{discoveredTools.map((tool) => (
<li
key={tool.name}
className="flex items-start gap-2 rounded-lg bg-gray-50 px-3 py-2 text-sm dark:bg-white/5"
>
<span className="text-purple-30 mt-0.5">&#9679;</span>
<div className="min-w-0">
<span className="font-medium text-gray-900 dark:text-white">
{tool.name}
</span>
{tool.description && (
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
{tool.description}
</p>
)}
</div>
</li>
))}
</ul>
</div>
)}
{errors.general && (
<div className="rounded-2xl bg-red-50 p-5 text-red-700 dark:bg-red-900 dark:text-red-300">
<div className="rounded-xl bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/40 dark:text-red-300">
{errors.general}
</div>
)}
</div>
</div>
<div className="px-6 py-2">
<div className="flex flex-col gap-4 sm:flex-row sm:justify-between">
<div className="px-6 py-4">
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between">
<button
onClick={testConnection}
disabled={testing}
className="border-silver dark:border-dim-gray dark:text-light-gray w-full rounded-3xl border px-6 py-2 text-sm font-medium transition-all hover:bg-gray-100 disabled:opacity-50 sm:w-auto dark:hover:bg-[#767183]/50"
className="border-silver dark:border-silver/40 dark:text-light-gray w-full rounded-3xl border px-6 py-2 text-sm font-medium transition-all hover:bg-gray-100 disabled:opacity-50 sm:w-auto dark:hover:bg-[#767183]/50"
>
{testing ? (
<div className="flex items-center justify-center">

View File

@@ -24,7 +24,15 @@ export default function WrapperModal({
if (isPerformingTask) return;
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node))
const target = event.target as Node;
if (
(target as Element)?.closest?.(
'[data-radix-popper-content-wrapper], [data-radix-select-viewport], [role="listbox"]',
)
)
return;
if (document.querySelector('[data-radix-select-content]')) return;
if (modalRef.current && !modalRef.current.contains(target))
close();
};
@@ -43,7 +51,7 @@ export default function WrapperModal({
const modalContent = (
<div
className="fixed top-0 left-0 z-[100] flex h-screen w-screen items-center justify-center"
className="fixed top-0 left-0 z-100 flex h-screen w-screen items-center justify-center"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
onMouseDown={(e: React.MouseEvent) => e.stopPropagation()}
>

View File

@@ -1,8 +1,24 @@
export type ConfigFieldSpec = {
type: 'string' | 'number' | 'boolean';
label: string;
description: string;
required?: boolean;
secret?: boolean;
order?: number;
enum?: string[];
default?: string | number | boolean;
depends_on?: { [key: string]: string };
};
export type ConfigRequirements = {
[key: string]: ConfigFieldSpec;
};
export type AvailableToolType = {
name: string;
displayName: string;
description: string;
configRequirements: object;
configRequirements: ConfigRequirements;
actions: {
name: string;
description: string;

View File

@@ -10,9 +10,11 @@ import CircleX from '../assets/circle-x.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import Trash from '../assets/trash.svg';
import ConfigFields from '../components/ConfigFields';
import Dropdown from '../components/Dropdown';
import Input from '../components/Input';
import ToggleSwitch from '../components/ToggleSwitch';
import { Input as ShadInput } from '../components/ui/input';
import { useDarkTheme } from '../hooks';
import AddActionModal from '../modals/AddActionModal';
import ConfirmationModal from '../modals/ConfirmationModal';
@@ -44,21 +46,21 @@ export default function ToolConfig({
handleGoBack: () => void;
}) {
const token = useSelector(selectToken);
const [authKey, setAuthKey] = React.useState<string>(() => {
if (tool.name === 'mcp_tool') {
const config = tool.config as any;
if (config.auth_type === 'api_key') {
return config.api_key || '';
} else if (config.auth_type === 'bearer') {
return config.encrypted_token || '';
} else if (config.auth_type === 'basic') {
return config.password || '';
const configRequirements = React.useMemo(
() => tool.configRequirements ?? {},
[tool.configRequirements],
);
const [configValues, setConfigValues] = React.useState<{
[key: string]: any;
}>(() => {
const vals: { [key: string]: any } = {};
const cfg = tool.config as { [key: string]: any } | undefined;
Object.keys(configRequirements).forEach((key) => {
if (cfg && key in cfg) {
vals[key] = cfg[key];
}
return '';
} else if ('token' in tool.config) {
return tool.config.token;
}
return '';
});
return vals;
});
const [customName, setCustomName] = React.useState<string>(
tool.customName || '',
@@ -69,12 +71,17 @@ export default function ToolConfig({
React.useState<ActiveState>('INACTIVE');
const [initialState, setInitialState] = React.useState({
customName: tool.customName || '',
authKey: 'token' in tool.config ? tool.config.token : '',
configValues: { ...configValues } as { [key: string]: any },
config: tool.config,
actions: 'actions' in tool ? tool.actions : [],
});
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
const [showUnsavedModal, setShowUnsavedModal] = React.useState(false);
const [configErrors, setConfigErrors] = React.useState<{
[key: string]: string;
}>({});
const [saving, setSaving] = React.useState(false);
const [saveError, setSaveError] = React.useState('');
const [userActionsSearch, setUserActionsSearch] = React.useState('');
const [expandedUserActions, setExpandedUserActions] = React.useState<
Set<number>
@@ -115,16 +122,76 @@ export default function ToolConfig({
}
};
const handleFieldChange = (key: string, value: any) => {
setConfigValues((prev) => ({ ...prev, [key]: value }));
if (configErrors[key]) setConfigErrors((prev) => ({ ...prev, [key]: '' }));
};
const validateConfig = () => {
if (tool.name === 'api_tool') return true;
const newErrors: { [key: string]: string } = {};
Object.entries(configRequirements).forEach(([key, spec]) => {
if (spec.depends_on) {
const visible = Object.entries(spec.depends_on).every(
([dk, dv]) => configValues[dk] === dv,
);
if (!visible) return;
}
if (spec.required && !configValues[key]?.toString().trim()) {
const hasEncCreds = !!(tool as any).config?.has_encrypted_credentials;
if (!(spec.secret && hasEncCreds)) {
newErrors[key] = `${spec.label || key} is required`;
}
}
if (
spec.type === 'number' &&
configValues[key] !== undefined &&
configValues[key] !== ''
) {
const num = Number(configValues[key]);
if (isNaN(num) || num < 1) {
newErrors[key] = 'Must be a positive number';
}
if (key === 'timeout' && num > 300) {
newErrors[key] = 'Maximum timeout is 300 seconds';
}
}
});
setConfigErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const buildConfigToSave = () => {
if (tool.name === 'api_tool') return tool.config;
const config: { [key: string]: any } = {};
Object.entries(configRequirements).forEach(([key, spec]) => {
const val = configValues[key];
if (val !== undefined && val !== '') {
config[key] = val;
} else if (spec.secret) {
return;
} else {
const cfg = tool.config as { [key: string]: any } | undefined;
if (cfg && key in cfg) {
config[key] = cfg[key];
} else if (spec.default !== undefined) {
config[key] = spec.default;
}
}
});
return config;
};
React.useEffect(() => {
const currentState = {
customName,
authKey,
configValues,
config: tool.config,
actions: 'actions' in tool ? tool.actions : [],
};
setHasUnsavedChanges(!areObjectsEqual(initialState, currentState));
}, [customName, authKey, tool]);
}, [customName, configValues, tool]);
const handleCheckboxChange = (actionIndex: number, property: string) => {
setTool({
@@ -156,29 +223,15 @@ export default function ToolConfig({
});
};
const handleSaveChanges = () => {
let configToSave;
if (tool.name === 'api_tool') {
configToSave = tool.config;
} else if (tool.name === 'mcp_tool') {
configToSave = { ...tool.config } as any;
const mcpConfig = tool.config as any;
const handleSaveChanges = async () => {
if (!validateConfig()) return;
const configToSave = buildConfigToSave();
if (authKey.trim()) {
if (mcpConfig.auth_type === 'api_key') {
configToSave.api_key = authKey;
} else if (mcpConfig.auth_type === 'bearer') {
configToSave.encrypted_token = authKey;
} else if (mcpConfig.auth_type === 'basic') {
configToSave.password = authKey;
}
}
} else {
configToSave = { token: authKey };
}
setSaving(true);
setSaveError('');
userService
.updateTool(
try {
await userService.updateTool(
{
id: tool.id,
name: tool.name,
@@ -190,18 +243,20 @@ export default function ToolConfig({
status: tool.status,
},
token,
)
.then(() => {
// Update initialState to match current state
setInitialState({
customName,
authKey,
config: tool.config,
actions: 'actions' in tool ? tool.actions : [],
});
setHasUnsavedChanges(false);
handleGoBack();
);
setInitialState({
customName,
configValues: { ...configValues },
config: tool.config,
actions: 'actions' in tool ? tool.actions : [],
});
setHasUnsavedChanges(false);
handleGoBack();
} catch {
setSaveError(t('settings.tools.saveFailed'));
} finally {
setSaving(false);
}
};
const handleDelete = () => {
@@ -285,65 +340,53 @@ export default function ToolConfig({
<p className="mt-px">{t('settings.tools.backToAllTools')}</p>
</div>
<button
className="bg-purple-30 hover:bg-violets-are-blue rounded-full px-3 py-2 text-xs text-nowrap text-white sm:px-4 sm:py-2"
className="bg-purple-30 hover:bg-violets-are-blue rounded-full px-3 py-2 text-xs text-nowrap text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:px-4 sm:py-2"
onClick={handleSaveChanges}
disabled={!hasUnsavedChanges || saving}
>
{t('settings.tools.save')}
{saving ? t('settings.tools.saving') : t('settings.tools.save')}
</button>
</div>
{/* Custom name section */}
{saveError && (
<div className="mb-2 rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
{saveError}
</div>
)}
<div className="mt-1">
<p className="text-eerie-black dark:text-bright-gray text-sm font-semibold">
{t('settings.tools.customName')}
</p>
<div className="relative mt-4 w-full max-w-96">
<Input
<ShadInput
type="text"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
borderVariant="thin"
placeholder={t('settings.tools.customNamePlaceholder')}
className="rounded-xl"
/>
</div>
</div>
<div className="mt-1">
{Object.keys(tool?.config).length !== 0 && tool.name !== 'api_tool' && (
<p className="text-eerie-black dark:text-bright-gray text-sm font-semibold">
{tool.name === 'mcp_tool'
? (tool.config as any)?.auth_type === 'bearer'
? 'Bearer Token'
: (tool.config as any)?.auth_type === 'api_key'
? 'API Key'
: (tool.config as any)?.auth_type === 'basic'
? 'Password'
: t('settings.tools.authentication')
: t('settings.tools.authentication')}
</p>
)}
<div className="mt-4 flex flex-col items-start gap-2 sm:flex-row sm:items-center">
{Object.keys(tool?.config).length !== 0 &&
tool.name !== 'api_tool' && (
<div className="relative w-full max-w-96">
<Input
type="text"
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
borderVariant="thin"
placeholder={
tool.name === 'mcp_tool'
? (tool.config as any)?.auth_type === 'bearer'
? 'Bearer Token'
: (tool.config as any)?.auth_type === 'api_key'
? 'API Key'
: (tool.config as any)?.auth_type === 'basic'
? 'Password'
: t('modals.configTool.apiKeyPlaceholder')
: t('modals.configTool.apiKeyPlaceholder')
{tool.name !== 'api_tool' &&
Object.keys(configRequirements).length > 0 && (
<div>
<p className="text-eerie-black dark:text-bright-gray mb-4 text-sm font-semibold">
{t('settings.tools.authentication')}
</p>
<div className="max-w-96">
<ConfigFields
configRequirements={configRequirements}
values={configValues}
onChange={handleFieldChange}
errors={configErrors}
isEditing
hasEncryptedCredentials={
!!(tool as any).config?.has_encrypted_credentials
}
/>
</div>
)}
</div>
</div>
)}
</div>
<div className="flex flex-col gap-4">
<div className="mx-0 my-2 h-[0.8px] w-full rounded-full bg-[#C4C4C4]/40"></div>
@@ -522,7 +565,7 @@ export default function ToolConfig({
<td>
<label
htmlFor={uniqueKey}
className="ml-[10px] flex cursor-pointer items-start gap-4"
className="ml-2.5 flex cursor-pointer items-start gap-4"
>
<div className="flex items-center">
&#8203;
@@ -658,29 +701,17 @@ export default function ToolConfig({
modalState="ACTIVE"
setModalState={(state) => setShowUnsavedModal(state === 'ACTIVE')}
submitLabel={t('settings.tools.saveAndLeave')}
handleSubmit={() => {
let configToSave;
if (tool.name === 'api_tool') {
configToSave = tool.config;
} else if (tool.name === 'mcp_tool') {
configToSave = { ...tool.config } as any;
const mcpConfig = tool.config as any;
if (authKey.trim()) {
if (mcpConfig.auth_type === 'api_key') {
configToSave.api_key = authKey;
} else if (mcpConfig.auth_type === 'bearer') {
configToSave.encrypted_token = authKey;
} else if (mcpConfig.auth_type === 'basic') {
configToSave.password = authKey;
}
}
} else {
configToSave = { token: authKey };
handleSubmit={async () => {
if (!validateConfig()) {
setShowUnsavedModal(false);
return;
}
const configToSave = buildConfigToSave();
setSaving(true);
setSaveError('');
userService
.updateTool(
try {
await userService.updateTool(
{
id: tool.id,
name: tool.name,
@@ -692,11 +723,15 @@ export default function ToolConfig({
status: tool.status,
},
token,
)
.then(() => {
setShowUnsavedModal(false);
handleGoBack();
});
);
setShowUnsavedModal(false);
handleGoBack();
} catch {
setSaveError(t('settings.tools.saveFailed'));
setShowUnsavedModal(false);
} finally {
setSaving(false);
}
}}
cancelLabel={t('settings.tools.leaveWithoutSaving')}
handleCancel={() => {
@@ -1091,7 +1126,6 @@ function APIToolConfig({
})}
</div>
{/* Confirmation Modal */}
{deleteModalState === 'ACTIVE' && actionToDelete && (
<ConfirmationModal
message={t('settings.tools.deleteActionWarning', {
@@ -1365,7 +1399,7 @@ function APIActionTable({
</select>
</td>
<td>
<label className="ml-[10px] flex cursor-pointer items-start gap-4">
<label className="ml-2.5 flex cursor-pointer items-start gap-4">
<div className="flex items-center">
<input
checked={param.filled_by_llm}
@@ -1456,13 +1490,13 @@ function APIActionTable({
<td colSpan={3} className="text-right">
<button
onClick={handleAddProperty}
className="bg-purple-30 hover:bg-violets-are-blue mr-1 rounded-full px-5 py-[4px] text-sm text-white"
className="bg-purple-30 hover:bg-violets-are-blue mr-1 rounded-full px-5 py-1 text-sm text-white"
>
{t('settings.tools.add')}
</button>
<button
onClick={handleAddPropertyCancel}
className="rounded-full border border-solid border-red-500 px-5 py-[4px] text-sm text-red-500 hover:bg-red-500 hover:text-white"
className="rounded-full border border-solid border-red-500 px-5 py-1 text-sm text-red-500 hover:bg-red-500 hover:text-white"
>
{t('settings.tools.cancel')}
</button>
@@ -1481,7 +1515,7 @@ function APIActionTable({
<td colSpan={5}>
<button
onClick={() => handleAddPropertyStart(section)}
className="border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue flex items-start rounded-full border border-solid px-5 py-[4px] text-sm text-nowrap transition-colors hover:text-white"
className="border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue flex items-start rounded-full border border-solid px-5 py-1 text-sm text-nowrap transition-colors hover:text-white"
>
{t('settings.tools.addNew')}
</button>
@@ -1614,13 +1648,13 @@ function APIActionTable({
<td colSpan={2} className="text-right">
<button
onClick={handleAddProperty}
className="bg-purple-30 hover:bg-violets-are-blue mr-1 rounded-full px-5 py-[4px] text-sm text-white"
className="bg-purple-30 hover:bg-violets-are-blue mr-1 rounded-full px-5 py-1 text-sm text-white"
>
{t('settings.tools.add')}
</button>
<button
onClick={handleAddPropertyCancel}
className="rounded-full border border-solid border-red-500 px-5 py-[4px] text-sm text-red-500 hover:bg-red-500 hover:text-white"
className="rounded-full border border-solid border-red-500 px-5 py-1 text-sm text-red-500 hover:bg-red-500 hover:text-white"
>
{t('settings.tools.cancel')}
</button>
@@ -1639,7 +1673,7 @@ function APIActionTable({
<td colSpan={3}>
<button
onClick={() => handleAddPropertyStart('headers')}
className="border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue flex items-start rounded-full border border-solid px-5 py-[4px] text-sm text-nowrap transition-colors hover:text-white"
className="border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue flex items-start rounded-full border border-solid px-5 py-1 text-sm text-nowrap transition-colors hover:text-white"
>
{t('settings.tools.addNew')}
</button>

View File

@@ -1,24 +1,25 @@
import { RefreshCcw, Trash } from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import ThreeDotsIcon from '../assets/three-dots.svg';
import NoFilesIcon from '../assets/no-files.svg';
import Edit from '../assets/edit.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import ThreeDotsIcon from '../assets/three-dots.svg';
import ContextMenu, { MenuOption } from '../components/ContextMenu';
import Input from '../components/Input';
import Spinner from '../components/Spinner';
import ToggleSwitch from '../components/ToggleSwitch';
import { useDarkTheme } from '../hooks';
import AddToolModal from '../modals/AddToolModal';
import ConfirmationModal from '../modals/ConfirmationModal';
import MCPServerModal from '../modals/MCPServerModal';
import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import ToolConfig from './ToolConfig';
import { APIToolType, UserToolType } from './types';
import ContextMenu, { MenuOption } from '../components/ContextMenu';
import Edit from '../assets/edit.svg';
import Trash from '../assets/red-trash.svg';
import ConfirmationModal from '../modals/ConfirmationModal';
export default function Tools() {
const { t } = useTranslation();
@@ -42,6 +43,12 @@ export default function Tools() {
const [toolToDelete, setToolToDelete] = React.useState<UserToolType | null>(
null,
);
const [reconnectModalState, setReconnectModalState] =
React.useState<ActiveState>('INACTIVE');
const [reconnectTool, setReconnectTool] = React.useState<any>(null);
const [mcpStatuses, setMcpStatuses] = React.useState<{
[toolId: string]: string;
}>({});
React.useEffect(() => {
userTools.forEach((tool) => {
@@ -60,30 +67,74 @@ export default function Tools() {
if (toolToDelete) {
userService.deleteTool({ id: toolToDelete.id }, token).then(() => {
getUserTools();
fetchMcpStatuses();
setDeleteModalState('INACTIVE');
setToolToDelete(null);
});
}
};
const getMenuOptions = (tool: UserToolType): MenuOption[] => [
{
icon: Edit,
label: t('settings.tools.edit'),
onClick: () => handleSettingsClick(tool),
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
},
{
icon: Trash,
label: t('settings.tools.delete'),
onClick: () => handleDeleteTool(tool),
variant: 'danger',
iconWidth: 12,
iconHeight: 12,
},
];
const handleReconnect = (tool: UserToolType) => {
const config = tool.config as Record<string, any>;
const oauthScopes = Array.isArray(config.oauth_scopes)
? config.oauth_scopes.join(', ')
: config.oauth_scopes || '';
setReconnectTool({
id: tool.id,
displayName: tool.customName || tool.displayName,
server_url: config.server_url || '',
auth_type: config.auth_type || 'none',
timeout: config.timeout || 30,
oauth_scopes: oauthScopes,
has_encrypted_credentials: !!config.has_encrypted_credentials,
});
setReconnectModalState('ACTIVE');
};
const getMenuOptions = (tool: UserToolType): MenuOption[] => {
const options: MenuOption[] = [
{
icon: Edit,
label: t('settings.tools.edit'),
onClick: () => handleSettingsClick(tool),
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
},
{
icon: Trash,
label: t('settings.tools.delete'),
onClick: () => handleDeleteTool(tool),
variant: 'danger',
iconWidth: 16,
iconHeight: 16,
},
];
if (tool.name === 'mcp_tool') {
options.splice(1, 0, {
icon: RefreshCcw,
label: t('settings.tools.reconnect'),
onClick: () => handleReconnect(tool),
variant: 'primary',
iconWidth: 16,
iconHeight: 16,
iconClassName: 'text-[#747474]',
});
}
return options;
};
const fetchMcpStatuses = React.useCallback(() => {
userService
.getMCPAuthStatus(token)
.then((res) => res.json())
.then((data) => {
if (data.success && data.statuses) {
setMcpStatuses(data.statuses);
}
})
.catch(() => {});
}, [token]);
const getUserTools = () => {
setLoading(true);
@@ -124,6 +175,7 @@ export default function Tools() {
const handleGoBack = () => {
setSelectedTool(null);
getUserTools();
fetchMcpStatuses();
};
const handleToolAdded = (toolId: string) => {
@@ -145,6 +197,7 @@ export default function Tools() {
React.useEffect(() => {
getUserTools();
fetchMcpStatuses();
}, []);
return (
<div>
@@ -171,7 +224,7 @@ export default function Tools() {
/>
</div>
<button
className="bg-purple-30 hover:bg-violets-are-blue flex h-[32px] min-w-[108px] items-center justify-center rounded-full px-4 text-sm whitespace-normal text-white"
className="bg-purple-30 hover:bg-violets-are-blue flex h-8 min-w-[108px] items-center justify-center rounded-full px-4 text-sm whitespace-normal text-white"
onClick={() => {
setAddToolModalState('ACTIVE');
}}
@@ -209,7 +262,7 @@ export default function Tools() {
.map((tool, index) => (
<div
key={index}
className="relative flex h-52 w-[300px] flex-col justify-between rounded-2xl bg-[#F5F5F5] p-6 hover:bg-[#ECECEC] dark:bg-[#383838] dark:hover:bg-[#303030]"
className="relative flex h-52 w-[300px] flex-col justify-between overflow-hidden rounded-2xl bg-[#F5F5F5] p-6 hover:bg-[#ECECEC] dark:bg-[#383838] dark:hover:bg-[#303030]"
>
<div
ref={menuRefs.current[tool.id]}
@@ -238,12 +291,32 @@ export default function Tools() {
/>
</div>
<div className="w-full">
<div className="flex w-full items-center px-1">
<div className="flex w-full items-center gap-2 px-1">
<img
src={`/toolIcons/tool_${tool.name}.svg`}
alt={`${tool.displayName} icon`}
className="h-6 w-6"
/>
{tool.name === 'mcp_tool' &&
mcpStatuses[tool.id] && (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium leading-none ${
mcpStatuses[tool.id] === 'connected'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: mcpStatuses[tool.id] === 'needs_auth'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/40 dark:text-gray-300'
}`}
>
{mcpStatuses[tool.id] === 'connected'
? t('settings.tools.authStatus.connected')
: mcpStatuses[tool.id] === 'needs_auth'
? t('settings.tools.authStatus.needsAuth')
: t(
'settings.tools.authStatus.configured',
)}
</span>
)}
</div>
<div className="mt-[9px]">
<p
@@ -252,7 +325,10 @@ export default function Tools() {
>
{tool.customName || tool.displayName}
</p>
<p className="text-old-silver dark:text-sonic-silver-light mt-1 h-24 overflow-auto px-1 text-[12px] leading-relaxed">
<p
className="text-old-silver dark:text-sonic-silver-light mt-1 line-clamp-4 max-h-24 overflow-hidden px-1 text-[12px] leading-relaxed break-all"
title={tool.description}
>
{tool.description}
</p>
</div>
@@ -294,6 +370,16 @@ export default function Tools() {
submitLabel={t('settings.tools.delete')}
variant="danger"
/>
<MCPServerModal
modalState={reconnectModalState}
setModalState={setReconnectModalState}
server={reconnectTool}
onServerSaved={() => {
setReconnectTool(null);
getUserTools();
fetchMcpStatuses();
}}
/>
</div>
)}
</div>

View File

@@ -1,3 +1,5 @@
import { ConfigRequirements } from '../../modals/types';
export type ChunkType = {
doc_id: string;
text: string;
@@ -46,8 +48,9 @@ export type UserToolType = {
description: string;
status: boolean;
config: {
[key: string]: string;
[key: string]: any;
};
configRequirements?: ConfigRequirements;
actions: {
name: string;
description: string;
@@ -101,4 +104,5 @@ export type APIToolType = {
description: string;
status: boolean;
config: { actions: { [key: string]: APIActionType } };
configRequirements?: ConfigRequirements;
};

View File

@@ -2,3 +2,4 @@ pytest>=8.0.0
pytest-cov>=4.1.0
coverage>=7.4.0
mongomock>=4.3.0
cryptography>=46.0.0