mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-09 14:25:25 -05:00
feat(frontend/mcp): Add MCP tool discovery UI, OAuth flow, and dynamic block schema
- Add MCPToolDialog with tool discovery, OAuth sign-in, and card-based tool selection - Add OAuth callback route using BroadcastChannel API for popup communication - Add API client methods for MCP discovery, OAuth login, and callback - Register MCP API routes on the backend REST API - Render dynamic input schema for MCP blocks (credentials + tool params) in both legacy and new builder CustomNode components - Nest MCP tool argument values under tool_arguments in hardcodedValues - Display tool name with server name prefix in block header - Add backend route tests for discovery, OAuth login, and callback endpoints
This commit is contained in:
365
autogpt_platform/backend/backend/api/features/mcp/routes.py
Normal file
365
autogpt_platform/backend/backend/api/features/mcp/routes.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
MCP (Model Context Protocol) API routes.
|
||||
|
||||
Provides endpoints for MCP tool discovery and OAuth authentication so the
|
||||
frontend can list available tools on an MCP server before placing a block.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import fastapi
|
||||
from autogpt_libs.auth import get_user_id
|
||||
from fastapi import Security
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.blocks.mcp.client import MCPClient, MCPClientError
|
||||
from backend.blocks.mcp.oauth import MCPOAuthHandler
|
||||
from backend.data.model import OAuth2Credentials
|
||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||
from backend.integrations.providers import ProviderName
|
||||
from backend.util.request import HTTPClientError, Requests
|
||||
from backend.util.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = Settings()
|
||||
router = fastapi.APIRouter(tags=["mcp"])
|
||||
creds_manager = IntegrationCredentialsManager()
|
||||
|
||||
|
||||
# ====================== Tool Discovery ====================== #
|
||||
|
||||
|
||||
class DiscoverToolsRequest(BaseModel):
|
||||
"""Request to discover tools on an MCP server."""
|
||||
|
||||
server_url: str = Field(description="URL of the MCP server")
|
||||
auth_token: str | None = Field(
|
||||
default=None,
|
||||
description="Optional Bearer token for authenticated MCP servers",
|
||||
)
|
||||
|
||||
|
||||
class MCPToolResponse(BaseModel):
|
||||
"""A single MCP tool returned by discovery."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
input_schema: dict[str, Any]
|
||||
|
||||
|
||||
class DiscoverToolsResponse(BaseModel):
|
||||
"""Response containing the list of tools available on an MCP server."""
|
||||
|
||||
tools: list[MCPToolResponse]
|
||||
server_name: str | None = None
|
||||
protocol_version: str | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/discover-tools",
|
||||
summary="Discover available tools on an MCP server",
|
||||
response_model=DiscoverToolsResponse,
|
||||
)
|
||||
async def discover_tools(
|
||||
request: DiscoverToolsRequest,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> DiscoverToolsResponse:
|
||||
"""
|
||||
Connect to an MCP server and return its available tools.
|
||||
|
||||
If the user has a stored MCP credential for this server URL, it will be
|
||||
used automatically — no need to pass an explicit auth token.
|
||||
"""
|
||||
auth_token = request.auth_token
|
||||
|
||||
# Auto-use stored MCP credential when no explicit token is provided
|
||||
if not auth_token:
|
||||
try:
|
||||
mcp_creds = await creds_manager.store.get_creds_by_provider(
|
||||
user_id, str(ProviderName.MCP)
|
||||
)
|
||||
for cred in mcp_creds:
|
||||
if (
|
||||
isinstance(cred, OAuth2Credentials)
|
||||
and cred.metadata.get("mcp_server_url") == request.server_url
|
||||
):
|
||||
auth_token = cred.access_token.get_secret_value()
|
||||
break
|
||||
except Exception:
|
||||
logger.debug("Could not look up stored MCP credentials", exc_info=True)
|
||||
|
||||
try:
|
||||
client = MCPClient(
|
||||
request.server_url,
|
||||
auth_token=auth_token,
|
||||
trusted_origins=[request.server_url],
|
||||
)
|
||||
|
||||
init_result = await client.initialize()
|
||||
tools = await client.list_tools()
|
||||
|
||||
return DiscoverToolsResponse(
|
||||
tools=[
|
||||
MCPToolResponse(
|
||||
name=t.name,
|
||||
description=t.description,
|
||||
input_schema=t.input_schema,
|
||||
)
|
||||
for t in tools
|
||||
],
|
||||
server_name=init_result.get("serverInfo", {}).get("name"),
|
||||
protocol_version=init_result.get("protocolVersion"),
|
||||
)
|
||||
except HTTPClientError as e:
|
||||
if e.status_code in (401, 403):
|
||||
raise fastapi.HTTPException(
|
||||
status_code=401,
|
||||
detail="This MCP server requires authentication. "
|
||||
"Please provide a valid auth token.",
|
||||
)
|
||||
raise fastapi.HTTPException(status_code=502, detail=str(e))
|
||||
except MCPClientError as e:
|
||||
raise fastapi.HTTPException(status_code=502, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("MCP tool discovery failed")
|
||||
raise fastapi.HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Failed to connect to MCP server: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
# ======================== OAuth Flow ======================== #
|
||||
|
||||
|
||||
class MCPOAuthLoginRequest(BaseModel):
|
||||
"""Request to start an OAuth flow for an MCP server."""
|
||||
|
||||
server_url: str = Field(description="URL of the MCP server that requires OAuth")
|
||||
|
||||
|
||||
class MCPOAuthLoginResponse(BaseModel):
|
||||
"""Response with the OAuth login URL for the user to authenticate."""
|
||||
|
||||
login_url: str
|
||||
state_token: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/oauth/login",
|
||||
summary="Initiate OAuth login for an MCP server",
|
||||
)
|
||||
async def mcp_oauth_login(
|
||||
request: MCPOAuthLoginRequest,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> MCPOAuthLoginResponse:
|
||||
"""
|
||||
Discover OAuth metadata from the MCP server and return a login URL.
|
||||
|
||||
1. Discovers the protected-resource metadata (RFC 9728)
|
||||
2. Fetches the authorization server metadata (RFC 8414)
|
||||
3. Performs Dynamic Client Registration (RFC 7591) if available
|
||||
4. Returns the authorization URL for the frontend to open in a popup
|
||||
"""
|
||||
client = MCPClient(request.server_url, trusted_origins=[request.server_url])
|
||||
|
||||
# Step 1: Discover protected-resource metadata (RFC 9728)
|
||||
try:
|
||||
protected_resource = await client.discover_auth()
|
||||
except Exception as e:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Failed to discover OAuth metadata: {e}",
|
||||
)
|
||||
|
||||
if not protected_resource or "authorization_servers" not in protected_resource:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=400,
|
||||
detail="This MCP server does not advertise OAuth support. "
|
||||
"You may need to provide an auth token manually.",
|
||||
)
|
||||
|
||||
auth_server_url = protected_resource["authorization_servers"][0]
|
||||
resource_url = protected_resource.get("resource", request.server_url)
|
||||
|
||||
# Step 2: Discover auth-server metadata (RFC 8414)
|
||||
try:
|
||||
metadata = await client.discover_auth_server_metadata(auth_server_url)
|
||||
except Exception as e:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Failed to discover authorization server metadata: {e}",
|
||||
)
|
||||
|
||||
if not metadata or "authorization_endpoint" not in metadata:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=502,
|
||||
detail="Authorization server metadata is missing required endpoints.",
|
||||
)
|
||||
|
||||
authorize_url = metadata["authorization_endpoint"]
|
||||
token_url = metadata["token_endpoint"]
|
||||
registration_endpoint = metadata.get("registration_endpoint")
|
||||
revoke_url = metadata.get("revocation_endpoint")
|
||||
|
||||
# Step 3: Dynamic Client Registration (RFC 7591) if available
|
||||
frontend_base_url = settings.config.frontend_base_url
|
||||
if not frontend_base_url:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=500,
|
||||
detail="Frontend base URL is not configured.",
|
||||
)
|
||||
redirect_uri = f"{frontend_base_url}/auth/integrations/mcp_callback"
|
||||
|
||||
client_id = ""
|
||||
client_secret = ""
|
||||
if registration_endpoint:
|
||||
reg_result = await _register_mcp_client(
|
||||
registration_endpoint, redirect_uri, request.server_url
|
||||
)
|
||||
if reg_result:
|
||||
client_id = reg_result.get("client_id", "")
|
||||
client_secret = reg_result.get("client_secret", "")
|
||||
|
||||
if not client_id:
|
||||
client_id = "autogpt-platform"
|
||||
|
||||
# Step 4: Store state token with OAuth metadata for the callback
|
||||
scopes = protected_resource.get("scopes_supported", [])
|
||||
state_token, code_challenge = await creds_manager.store.store_state_token(
|
||||
user_id,
|
||||
str(ProviderName.MCP),
|
||||
scopes,
|
||||
state_metadata={
|
||||
"authorize_url": authorize_url,
|
||||
"token_url": token_url,
|
||||
"revoke_url": revoke_url,
|
||||
"resource_url": resource_url,
|
||||
"server_url": request.server_url,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
# Step 5: Build and return the login URL
|
||||
handler = MCPOAuthHandler(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
authorize_url=authorize_url,
|
||||
token_url=token_url,
|
||||
resource_url=resource_url,
|
||||
)
|
||||
login_url = handler.get_login_url(
|
||||
scopes, state_token, code_challenge=code_challenge
|
||||
)
|
||||
|
||||
return MCPOAuthLoginResponse(login_url=login_url, state_token=state_token)
|
||||
|
||||
|
||||
class MCPOAuthCallbackRequest(BaseModel):
|
||||
"""Request to exchange an OAuth code for tokens."""
|
||||
|
||||
code: str = Field(description="Authorization code from OAuth callback")
|
||||
state_token: str = Field(description="State token for CSRF verification")
|
||||
|
||||
|
||||
class MCPOAuthCallbackResponse(BaseModel):
|
||||
"""Response after successfully storing OAuth credentials."""
|
||||
|
||||
credential_id: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/oauth/callback",
|
||||
summary="Exchange OAuth code for MCP tokens",
|
||||
)
|
||||
async def mcp_oauth_callback(
|
||||
request: MCPOAuthCallbackRequest,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> MCPOAuthCallbackResponse:
|
||||
"""
|
||||
Exchange the authorization code for tokens and store the credential.
|
||||
|
||||
The frontend calls this after receiving the OAuth code from the popup.
|
||||
On success, subsequent ``/discover-tools`` calls for the same server URL
|
||||
will automatically use the stored credential.
|
||||
"""
|
||||
valid_state = await creds_manager.store.verify_state_token(
|
||||
user_id, request.state_token, str(ProviderName.MCP)
|
||||
)
|
||||
if not valid_state:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid or expired state token.",
|
||||
)
|
||||
|
||||
meta = valid_state.state_metadata
|
||||
frontend_base_url = settings.config.frontend_base_url
|
||||
redirect_uri = f"{frontend_base_url}/auth/integrations/mcp_callback"
|
||||
|
||||
handler = MCPOAuthHandler(
|
||||
client_id=meta["client_id"],
|
||||
client_secret=meta.get("client_secret", ""),
|
||||
redirect_uri=redirect_uri,
|
||||
authorize_url=meta["authorize_url"],
|
||||
token_url=meta["token_url"],
|
||||
revoke_url=meta.get("revoke_url"),
|
||||
resource_url=meta.get("resource_url"),
|
||||
)
|
||||
|
||||
try:
|
||||
credentials = await handler.exchange_code_for_tokens(
|
||||
request.code, valid_state.scopes, valid_state.code_verifier
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("MCP OAuth token exchange failed")
|
||||
raise fastapi.HTTPException(
|
||||
status_code=400,
|
||||
detail=f"OAuth token exchange failed: {e}",
|
||||
)
|
||||
|
||||
# Enrich credential metadata for future lookup and token refresh
|
||||
if credentials.metadata is None:
|
||||
credentials.metadata = {}
|
||||
credentials.metadata["mcp_server_url"] = meta["server_url"]
|
||||
credentials.metadata["mcp_client_id"] = meta["client_id"]
|
||||
credentials.metadata["mcp_client_secret"] = meta.get("client_secret", "")
|
||||
|
||||
hostname = urlparse(meta["server_url"]).hostname or meta["server_url"]
|
||||
credentials.title = f"MCP: {hostname}"
|
||||
|
||||
await creds_manager.create(user_id, credentials)
|
||||
|
||||
return MCPOAuthCallbackResponse(credential_id=credentials.id)
|
||||
|
||||
|
||||
# ======================== Helpers ======================== #
|
||||
|
||||
|
||||
async def _register_mcp_client(
|
||||
registration_endpoint: str,
|
||||
redirect_uri: str,
|
||||
server_url: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Attempt Dynamic Client Registration (RFC 7591) with an MCP auth server."""
|
||||
try:
|
||||
response = await Requests(raise_for_status=True).post(
|
||||
registration_endpoint,
|
||||
json={
|
||||
"client_name": "AutoGPT Platform",
|
||||
"redirect_uris": [redirect_uri],
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
},
|
||||
)
|
||||
data = response.json()
|
||||
if isinstance(data, dict) and "client_id" in data:
|
||||
return data
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Dynamic client registration failed for {server_url}: {e}")
|
||||
return None
|
||||
385
autogpt_platform/backend/backend/api/features/mcp/test_routes.py
Normal file
385
autogpt_platform/backend/backend/api/features/mcp/test_routes.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""Tests for MCP API routes."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import fastapi
|
||||
import fastapi.testclient
|
||||
from autogpt_libs.auth import get_user_id
|
||||
|
||||
from backend.api.features.mcp.routes import router
|
||||
from backend.blocks.mcp.client import MCPClientError, MCPTool
|
||||
from backend.util.request import HTTPClientError
|
||||
|
||||
app = fastapi.FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_user_id] = lambda: "test-user-id"
|
||||
client = fastapi.testclient.TestClient(app)
|
||||
|
||||
|
||||
class TestDiscoverTools:
|
||||
def test_discover_tools_success(self):
|
||||
mock_tools = [
|
||||
MCPTool(
|
||||
name="get_weather",
|
||||
description="Get weather for a city",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
),
|
||||
MCPTool(
|
||||
name="add_numbers",
|
||||
description="Add two numbers",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {"type": "number"},
|
||||
"b": {"type": "number"},
|
||||
},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
with (patch("backend.api.features.mcp.routes.MCPClient") as MockClient,):
|
||||
instance = MockClient.return_value
|
||||
instance.initialize = AsyncMock(
|
||||
return_value={
|
||||
"protocolVersion": "2025-03-26",
|
||||
"serverInfo": {"name": "test-server"},
|
||||
}
|
||||
)
|
||||
instance.list_tools = AsyncMock(return_value=mock_tools)
|
||||
|
||||
response = client.post(
|
||||
"/discover-tools",
|
||||
json={"server_url": "https://mcp.example.com/mcp"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["tools"]) == 2
|
||||
assert data["tools"][0]["name"] == "get_weather"
|
||||
assert data["tools"][1]["name"] == "add_numbers"
|
||||
assert data["server_name"] == "test-server"
|
||||
assert data["protocol_version"] == "2025-03-26"
|
||||
|
||||
def test_discover_tools_with_auth_token(self):
|
||||
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.initialize = AsyncMock(
|
||||
return_value={"serverInfo": {}, "protocolVersion": "2025-03-26"}
|
||||
)
|
||||
instance.list_tools = AsyncMock(return_value=[])
|
||||
|
||||
response = client.post(
|
||||
"/discover-tools",
|
||||
json={
|
||||
"server_url": "https://mcp.example.com/mcp",
|
||||
"auth_token": "my-secret-token",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
MockClient.assert_called_once_with(
|
||||
"https://mcp.example.com/mcp",
|
||||
auth_token="my-secret-token",
|
||||
trusted_origins=["https://mcp.example.com/mcp"],
|
||||
)
|
||||
|
||||
def test_discover_tools_auto_uses_stored_credential(self):
|
||||
"""When no explicit token is given, stored MCP credentials are used."""
|
||||
from pydantic import SecretStr
|
||||
|
||||
from backend.data.model import OAuth2Credentials
|
||||
|
||||
stored_cred = OAuth2Credentials(
|
||||
provider="mcp",
|
||||
title="MCP: example.com",
|
||||
access_token=SecretStr("stored-token-123"),
|
||||
refresh_token=None,
|
||||
access_token_expires_at=None,
|
||||
refresh_token_expires_at=None,
|
||||
scopes=[],
|
||||
metadata={"mcp_server_url": "https://mcp.example.com/mcp"},
|
||||
)
|
||||
|
||||
with (
|
||||
patch("backend.api.features.mcp.routes.MCPClient") as MockClient,
|
||||
patch("backend.api.features.mcp.routes.creds_manager") as mock_cm,
|
||||
):
|
||||
mock_cm.store.get_creds_by_provider = AsyncMock(return_value=[stored_cred])
|
||||
instance = MockClient.return_value
|
||||
instance.initialize = AsyncMock(
|
||||
return_value={"serverInfo": {}, "protocolVersion": "2025-03-26"}
|
||||
)
|
||||
instance.list_tools = AsyncMock(return_value=[])
|
||||
|
||||
response = client.post(
|
||||
"/discover-tools",
|
||||
json={"server_url": "https://mcp.example.com/mcp"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
MockClient.assert_called_once_with(
|
||||
"https://mcp.example.com/mcp",
|
||||
auth_token="stored-token-123",
|
||||
trusted_origins=["https://mcp.example.com/mcp"],
|
||||
)
|
||||
|
||||
def test_discover_tools_mcp_error(self):
|
||||
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.initialize = AsyncMock(
|
||||
side_effect=MCPClientError("Connection refused")
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/discover-tools",
|
||||
json={"server_url": "https://bad-server.example.com/mcp"},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert "Connection refused" in response.json()["detail"]
|
||||
|
||||
def test_discover_tools_generic_error(self):
|
||||
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.initialize = AsyncMock(side_effect=Exception("Network timeout"))
|
||||
|
||||
response = client.post(
|
||||
"/discover-tools",
|
||||
json={"server_url": "https://timeout.example.com/mcp"},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert "Failed to connect" in response.json()["detail"]
|
||||
|
||||
def test_discover_tools_auth_required(self):
|
||||
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.initialize = AsyncMock(
|
||||
side_effect=HTTPClientError("HTTP 401 Error: Unauthorized", 401)
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/discover-tools",
|
||||
json={"server_url": "https://auth-server.example.com/mcp"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "requires authentication" in response.json()["detail"]
|
||||
|
||||
def test_discover_tools_forbidden(self):
|
||||
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.initialize = AsyncMock(
|
||||
side_effect=HTTPClientError("HTTP 403 Error: Forbidden", 403)
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/discover-tools",
|
||||
json={"server_url": "https://auth-server.example.com/mcp"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "requires authentication" in response.json()["detail"]
|
||||
|
||||
def test_discover_tools_missing_url(self):
|
||||
response = client.post("/discover-tools", json={})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
class TestOAuthLogin:
|
||||
def test_oauth_login_success(self):
|
||||
with (
|
||||
patch("backend.api.features.mcp.routes.MCPClient") as MockClient,
|
||||
patch("backend.api.features.mcp.routes.creds_manager") as mock_cm,
|
||||
patch("backend.api.features.mcp.routes.settings") as mock_settings,
|
||||
patch(
|
||||
"backend.api.features.mcp.routes._register_mcp_client"
|
||||
) as mock_register,
|
||||
):
|
||||
instance = MockClient.return_value
|
||||
instance.discover_auth = AsyncMock(
|
||||
return_value={
|
||||
"authorization_servers": ["https://auth.sentry.io"],
|
||||
"resource": "https://mcp.sentry.dev/mcp",
|
||||
"scopes_supported": ["openid"],
|
||||
}
|
||||
)
|
||||
instance.discover_auth_server_metadata = AsyncMock(
|
||||
return_value={
|
||||
"authorization_endpoint": "https://auth.sentry.io/authorize",
|
||||
"token_endpoint": "https://auth.sentry.io/token",
|
||||
"registration_endpoint": "https://auth.sentry.io/register",
|
||||
}
|
||||
)
|
||||
mock_register.return_value = {
|
||||
"client_id": "registered-client-id",
|
||||
"client_secret": "registered-secret",
|
||||
}
|
||||
mock_cm.store.store_state_token = AsyncMock(
|
||||
return_value=("state-token-123", "code-challenge-abc")
|
||||
)
|
||||
mock_settings.config.frontend_base_url = "http://localhost:3000"
|
||||
|
||||
response = client.post(
|
||||
"/oauth/login",
|
||||
json={"server_url": "https://mcp.sentry.dev/mcp"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "login_url" in data
|
||||
assert data["state_token"] == "state-token-123"
|
||||
assert "auth.sentry.io/authorize" in data["login_url"]
|
||||
assert "registered-client-id" in data["login_url"]
|
||||
|
||||
def test_oauth_login_no_oauth_support(self):
|
||||
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.discover_auth = AsyncMock(return_value=None)
|
||||
|
||||
response = client.post(
|
||||
"/oauth/login",
|
||||
json={"server_url": "https://simple-server.example.com/mcp"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "does not advertise OAuth" in response.json()["detail"]
|
||||
|
||||
def test_oauth_login_fallback_to_public_client(self):
|
||||
"""When DCR is unavailable, falls back to default public client ID."""
|
||||
with (
|
||||
patch("backend.api.features.mcp.routes.MCPClient") as MockClient,
|
||||
patch("backend.api.features.mcp.routes.creds_manager") as mock_cm,
|
||||
patch("backend.api.features.mcp.routes.settings") as mock_settings,
|
||||
):
|
||||
instance = MockClient.return_value
|
||||
instance.discover_auth = AsyncMock(
|
||||
return_value={
|
||||
"authorization_servers": ["https://auth.example.com"],
|
||||
"resource": "https://mcp.example.com/mcp",
|
||||
}
|
||||
)
|
||||
instance.discover_auth_server_metadata = AsyncMock(
|
||||
return_value={
|
||||
"authorization_endpoint": "https://auth.example.com/authorize",
|
||||
"token_endpoint": "https://auth.example.com/token",
|
||||
# No registration_endpoint
|
||||
}
|
||||
)
|
||||
mock_cm.store.store_state_token = AsyncMock(
|
||||
return_value=("state-abc", "challenge-xyz")
|
||||
)
|
||||
mock_settings.config.frontend_base_url = "http://localhost:3000"
|
||||
|
||||
response = client.post(
|
||||
"/oauth/login",
|
||||
json={"server_url": "https://mcp.example.com/mcp"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "autogpt-platform" in data["login_url"]
|
||||
|
||||
|
||||
class TestOAuthCallback:
|
||||
def test_oauth_callback_success(self):
|
||||
from pydantic import SecretStr
|
||||
|
||||
from backend.data.model import OAuth2Credentials
|
||||
|
||||
mock_creds = OAuth2Credentials(
|
||||
provider="mcp",
|
||||
title=None,
|
||||
access_token=SecretStr("access-token-xyz"),
|
||||
refresh_token=None,
|
||||
access_token_expires_at=None,
|
||||
refresh_token_expires_at=None,
|
||||
scopes=[],
|
||||
metadata={
|
||||
"mcp_token_url": "https://auth.sentry.io/token",
|
||||
"mcp_resource_url": "https://mcp.sentry.dev/mcp",
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch("backend.api.features.mcp.routes.creds_manager") as mock_cm,
|
||||
patch("backend.api.features.mcp.routes.settings") as mock_settings,
|
||||
patch("backend.api.features.mcp.routes.MCPOAuthHandler") as MockHandler,
|
||||
):
|
||||
mock_settings.config.frontend_base_url = "http://localhost:3000"
|
||||
|
||||
# Mock state verification
|
||||
mock_state = AsyncMock()
|
||||
mock_state.state_metadata = {
|
||||
"authorize_url": "https://auth.sentry.io/authorize",
|
||||
"token_url": "https://auth.sentry.io/token",
|
||||
"client_id": "test-client-id",
|
||||
"client_secret": "test-secret",
|
||||
"server_url": "https://mcp.sentry.dev/mcp",
|
||||
}
|
||||
mock_state.scopes = ["openid"]
|
||||
mock_state.code_verifier = "verifier-123"
|
||||
mock_cm.store.verify_state_token = AsyncMock(return_value=mock_state)
|
||||
mock_cm.create = AsyncMock()
|
||||
|
||||
handler_instance = MockHandler.return_value
|
||||
handler_instance.exchange_code_for_tokens = AsyncMock(
|
||||
return_value=mock_creds
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/oauth/callback",
|
||||
json={"code": "auth-code-abc", "state_token": "state-token-123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "credential_id" in data
|
||||
mock_cm.create.assert_called_once()
|
||||
|
||||
def test_oauth_callback_invalid_state(self):
|
||||
with patch("backend.api.features.mcp.routes.creds_manager") as mock_cm:
|
||||
mock_cm.store.verify_state_token = AsyncMock(return_value=None)
|
||||
|
||||
response = client.post(
|
||||
"/oauth/callback",
|
||||
json={"code": "auth-code", "state_token": "bad-state"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid or expired" in response.json()["detail"]
|
||||
|
||||
def test_oauth_callback_token_exchange_fails(self):
|
||||
with (
|
||||
patch("backend.api.features.mcp.routes.creds_manager") as mock_cm,
|
||||
patch("backend.api.features.mcp.routes.settings") as mock_settings,
|
||||
patch("backend.api.features.mcp.routes.MCPOAuthHandler") as MockHandler,
|
||||
):
|
||||
mock_settings.config.frontend_base_url = "http://localhost:3000"
|
||||
mock_state = AsyncMock()
|
||||
mock_state.state_metadata = {
|
||||
"authorize_url": "https://auth.example.com/authorize",
|
||||
"token_url": "https://auth.example.com/token",
|
||||
"client_id": "cid",
|
||||
"server_url": "https://mcp.example.com/mcp",
|
||||
}
|
||||
mock_state.scopes = []
|
||||
mock_state.code_verifier = "v"
|
||||
mock_cm.store.verify_state_token = AsyncMock(return_value=mock_state)
|
||||
|
||||
handler_instance = MockHandler.return_value
|
||||
handler_instance.exchange_code_for_tokens = AsyncMock(
|
||||
side_effect=RuntimeError("Token exchange failed")
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/oauth/callback",
|
||||
json={"code": "bad-code", "state_token": "state"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "token exchange failed" in response.json()["detail"].lower()
|
||||
@@ -26,6 +26,7 @@ import backend.api.features.executions.review.routes
|
||||
import backend.api.features.library.db
|
||||
import backend.api.features.library.model
|
||||
import backend.api.features.library.routes
|
||||
import backend.api.features.mcp.routes as mcp_routes
|
||||
import backend.api.features.oauth
|
||||
import backend.api.features.otto.routes
|
||||
import backend.api.features.postmark.postmark
|
||||
@@ -343,6 +344,11 @@ app.include_router(
|
||||
tags=["workspace"],
|
||||
prefix="/api/workspace",
|
||||
)
|
||||
app.include_router(
|
||||
mcp_routes.router,
|
||||
tags=["v2", "mcp"],
|
||||
prefix="/api/mcp",
|
||||
)
|
||||
app.include_router(
|
||||
backend.api.features.oauth.router,
|
||||
tags=["oauth"],
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Safely encode a value as JSON for embedding in a script tag.
|
||||
* Escapes characters that could break out of the script context to prevent XSS.
|
||||
*/
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/>/g, "\\u003e")
|
||||
.replace(/&/g, "\\u0026");
|
||||
}
|
||||
|
||||
// MCP-specific OAuth callback route.
|
||||
//
|
||||
// Unlike the generic oauth_callback which relies on window.opener.postMessage,
|
||||
// this route uses BroadcastChannel as the PRIMARY communication method.
|
||||
// This is critical because cross-origin OAuth flows (e.g. Sentry → localhost)
|
||||
// often lose window.opener due to COOP (Cross-Origin-Opener-Policy) headers.
|
||||
//
|
||||
// BroadcastChannel works across all same-origin tabs/popups regardless of opener.
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
|
||||
const success = Boolean(code && state);
|
||||
const message = success
|
||||
? { success: true, code, state }
|
||||
: {
|
||||
success: false,
|
||||
message: `Missing parameters: ${searchParams.toString()}`,
|
||||
};
|
||||
|
||||
return new NextResponse(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>MCP Sign-in</title></head>
|
||||
<body style="font-family: system-ui, -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f9fafb;">
|
||||
<div style="text-align: center; max-width: 400px; padding: 2rem;">
|
||||
<div id="spinner" style="margin: 0 auto 1rem; width: 32px; height: 32px; border: 3px solid #e5e7eb; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite;"></div>
|
||||
<p id="status" style="color: #374151; font-size: 16px;">Completing sign-in...</p>
|
||||
</div>
|
||||
<style>@keyframes spin { to { transform: rotate(360deg); } }</style>
|
||||
<script>
|
||||
(function() {
|
||||
var msg = ${safeJsonStringify(message)};
|
||||
var sent = false;
|
||||
|
||||
// Method 1: BroadcastChannel (reliable across tabs/popups, no opener needed)
|
||||
try {
|
||||
var bc = new BroadcastChannel("mcp_oauth");
|
||||
bc.postMessage({ type: "mcp_oauth_result", success: msg.success, code: msg.code, state: msg.state, message: msg.message });
|
||||
bc.close();
|
||||
sent = true;
|
||||
} catch(e) { console.warn("BroadcastChannel failed:", e); }
|
||||
|
||||
// Method 2: window.opener.postMessage (fallback for same-origin popups)
|
||||
try {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage(
|
||||
{ message_type: "mcp_oauth_result", success: msg.success, code: msg.code, state: msg.state, message: msg.message },
|
||||
window.location.origin
|
||||
);
|
||||
sent = true;
|
||||
}
|
||||
} catch(e) { console.warn("postMessage failed:", e); }
|
||||
|
||||
var statusEl = document.getElementById("status");
|
||||
var spinnerEl = document.getElementById("spinner");
|
||||
spinnerEl.style.display = "none";
|
||||
|
||||
if (msg.success && sent) {
|
||||
statusEl.textContent = "Sign-in complete! This window will close.";
|
||||
statusEl.style.color = "#059669";
|
||||
setTimeout(function() { window.close(); }, 1500);
|
||||
} else if (msg.success) {
|
||||
statusEl.textContent = "Sign-in successful! You can close this tab and return to the builder.";
|
||||
statusEl.style.color = "#059669";
|
||||
} else {
|
||||
statusEl.textContent = "Sign-in failed: " + (msg.message || "Unknown error");
|
||||
statusEl.style.color = "#dc2626";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`,
|
||||
{ headers: { "Content-Type": "text/html" } },
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,10 @@ export type CustomNode = XYNode<CustomNodeData, "custom">;
|
||||
|
||||
export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
({ data, id: nodeId, selected }) => {
|
||||
const { inputSchema, outputSchema } = useCustomNode({ data, nodeId });
|
||||
const { inputSchema, outputSchema, isMCPWithTool } = useCustomNode({
|
||||
data,
|
||||
nodeId,
|
||||
});
|
||||
|
||||
const isAgent = data.uiType === BlockUIType.AGENT;
|
||||
|
||||
@@ -98,6 +101,7 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
jsonSchema={preprocessInputSchema(inputSchema)}
|
||||
nodeId={nodeId}
|
||||
uiType={data.uiType}
|
||||
isMCPWithTool={isMCPWithTool}
|
||||
className={cn(
|
||||
"bg-white px-4",
|
||||
isWebhook && "pointer-events-none opacity-50",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { SpecialBlockID } from "@/lib/autogpt-server-api";
|
||||
import { beautifyString, cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { CustomNodeData } from "../CustomNode";
|
||||
@@ -20,8 +21,15 @@ type Props = {
|
||||
|
||||
export const NodeHeader = ({ data, nodeId }: Props) => {
|
||||
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
||||
const isMCPWithTool =
|
||||
data.block_id === SpecialBlockID.MCP_TOOL &&
|
||||
!!data.hardcodedValues?.selected_tool;
|
||||
|
||||
const title =
|
||||
(data.metadata?.customized_name as string) ||
|
||||
(isMCPWithTool
|
||||
? `${data.hardcodedValues.server_name || "MCP"}: ${beautifyString(data.hardcodedValues.selected_tool)}`
|
||||
: null) ||
|
||||
data.hardcodedValues?.agent_name ||
|
||||
data.title;
|
||||
|
||||
|
||||
@@ -3,6 +3,38 @@ import { CustomNodeData } from "./CustomNode";
|
||||
import { BlockUIType } from "../../../types";
|
||||
import { useMemo } from "react";
|
||||
import { mergeSchemaForResolution } from "./helpers";
|
||||
import { SpecialBlockID } from "@/lib/autogpt-server-api";
|
||||
|
||||
/**
|
||||
* Build a dynamic input schema for MCP blocks.
|
||||
*
|
||||
* When a tool has been selected (tool_input_schema is populated), the block
|
||||
* should render:
|
||||
* 1. The credentials field (from the static schema)
|
||||
* 2. The selected tool's input parameters (from tool_input_schema)
|
||||
*
|
||||
* Static fields like server_url, selected_tool, available_tools, and
|
||||
* tool_arguments are hidden because they're pre-configured from the dialog.
|
||||
*/
|
||||
function buildMCPInputSchema(
|
||||
staticSchema: Record<string, any>,
|
||||
toolInputSchema: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
const credentialsProp = staticSchema.properties?.credentials;
|
||||
const staticRequired = staticSchema.required ?? [];
|
||||
|
||||
return {
|
||||
type: "object",
|
||||
properties: {
|
||||
...(credentialsProp ? { credentials: credentialsProp } : {}),
|
||||
...(toolInputSchema.properties ?? {}),
|
||||
},
|
||||
required: [
|
||||
...staticRequired.filter((r: string) => r === "credentials"),
|
||||
...(toolInputSchema.required ?? []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const useCustomNode = ({
|
||||
data,
|
||||
@@ -19,10 +51,18 @@ export const useCustomNode = ({
|
||||
);
|
||||
|
||||
const isAgent = data.uiType === BlockUIType.AGENT;
|
||||
const isMCPWithTool =
|
||||
data.block_id === SpecialBlockID.MCP_TOOL &&
|
||||
!!data.hardcodedValues?.tool_input_schema?.properties;
|
||||
|
||||
const currentInputSchema = isAgent
|
||||
? (data.hardcodedValues.input_schema ?? {})
|
||||
: data.inputSchema;
|
||||
: isMCPWithTool
|
||||
? buildMCPInputSchema(
|
||||
data.inputSchema,
|
||||
data.hardcodedValues.tool_input_schema,
|
||||
)
|
||||
: data.inputSchema;
|
||||
const currentOutputSchema = isAgent
|
||||
? (data.hardcodedValues.output_schema ?? {})
|
||||
: data.outputSchema;
|
||||
@@ -54,5 +94,6 @@ export const useCustomNode = ({
|
||||
return {
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
isMCPWithTool,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,39 +9,71 @@ interface FormCreatorProps {
|
||||
jsonSchema: RJSFSchema;
|
||||
nodeId: string;
|
||||
uiType: BlockUIType;
|
||||
/** When true the block is an MCP Tool with a selected tool. */
|
||||
isMCPWithTool?: boolean;
|
||||
showHandles?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FormCreator: React.FC<FormCreatorProps> = React.memo(
|
||||
({ jsonSchema, nodeId, uiType, showHandles = true, className }) => {
|
||||
({
|
||||
jsonSchema,
|
||||
nodeId,
|
||||
uiType,
|
||||
isMCPWithTool = false,
|
||||
showHandles = true,
|
||||
className,
|
||||
}) => {
|
||||
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
||||
|
||||
const getHardCodedValues = useNodeStore(
|
||||
(state) => state.getHardCodedValues,
|
||||
);
|
||||
|
||||
const isAgent = uiType === BlockUIType.AGENT;
|
||||
|
||||
const handleChange = ({ formData }: any) => {
|
||||
if ("credentials" in formData && !formData.credentials?.id) {
|
||||
delete formData.credentials;
|
||||
}
|
||||
|
||||
const updatedValues =
|
||||
uiType === BlockUIType.AGENT
|
||||
? {
|
||||
...getHardCodedValues(nodeId),
|
||||
inputs: formData,
|
||||
}
|
||||
: formData;
|
||||
let updatedValues;
|
||||
if (isAgent) {
|
||||
updatedValues = {
|
||||
...getHardCodedValues(nodeId),
|
||||
inputs: formData,
|
||||
};
|
||||
} else if (isMCPWithTool) {
|
||||
// Separate credentials from tool arguments
|
||||
const { credentials, ...toolArgs } = formData;
|
||||
updatedValues = {
|
||||
...getHardCodedValues(nodeId),
|
||||
...(credentials ? { credentials } : {}),
|
||||
tool_arguments: toolArgs,
|
||||
};
|
||||
} else {
|
||||
updatedValues = formData;
|
||||
}
|
||||
|
||||
updateNodeData(nodeId, { hardcodedValues: updatedValues });
|
||||
};
|
||||
|
||||
const hardcodedValues = getHardCodedValues(nodeId);
|
||||
const initialValues =
|
||||
uiType === BlockUIType.AGENT
|
||||
? (hardcodedValues.inputs ?? {})
|
||||
: hardcodedValues;
|
||||
|
||||
let initialValues;
|
||||
if (isAgent) {
|
||||
initialValues = hardcodedValues.inputs ?? {};
|
||||
} else if (isMCPWithTool) {
|
||||
// Merge credentials + tool_arguments for the combined schema
|
||||
initialValues = {
|
||||
...(hardcodedValues.credentials
|
||||
? { credentials: hardcodedValues.credentials }
|
||||
: {}),
|
||||
...(hardcodedValues.tool_arguments ?? {}),
|
||||
};
|
||||
} else {
|
||||
initialValues = hardcodedValues;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { beautifyString, cn } from "@/lib/utils";
|
||||
import React, { ButtonHTMLAttributes } from "react";
|
||||
import React, { ButtonHTMLAttributes, useCallback, useState } from "react";
|
||||
import { highlightText } from "./helpers";
|
||||
import { PlusIcon } from "@phosphor-icons/react";
|
||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||
@@ -9,6 +9,12 @@ import { useControlPanelStore } from "../../../stores/controlPanelStore";
|
||||
import { blockDragPreviewStyle } from "./style";
|
||||
import { useReactFlow } from "@xyflow/react";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { SpecialBlockID } from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
MCPToolDialog,
|
||||
type MCPToolDialogResult,
|
||||
} from "@/app/(platform)/build/components/legacy-builder/MCPToolDialog";
|
||||
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
@@ -33,22 +39,51 @@ export const Block: BlockComponent = ({
|
||||
);
|
||||
const { setViewport } = useReactFlow();
|
||||
const { addBlock } = useNodeStore();
|
||||
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
|
||||
|
||||
const isMCPBlock = blockData.id === SpecialBlockID.MCP_TOOL;
|
||||
|
||||
const addBlockAndCenter = useCallback(
|
||||
(block: BlockInfo, hardcodedValues?: Record<string, any>) => {
|
||||
const customNode = addBlock(block, hardcodedValues);
|
||||
setTimeout(() => {
|
||||
setViewport(
|
||||
{
|
||||
x: -customNode.position.x * 0.8 + window.innerWidth / 2,
|
||||
y: -customNode.position.y * 0.8 + (window.innerHeight - 400) / 2,
|
||||
zoom: 0.8,
|
||||
},
|
||||
{ duration: 500 },
|
||||
);
|
||||
}, 50);
|
||||
},
|
||||
[addBlock, setViewport],
|
||||
);
|
||||
|
||||
const handleMCPToolConfirm = useCallback(
|
||||
(result: MCPToolDialogResult) => {
|
||||
addBlockAndCenter(blockData, {
|
||||
server_url: result.serverUrl,
|
||||
server_name: result.serverName,
|
||||
selected_tool: result.selectedTool,
|
||||
tool_input_schema: result.toolInputSchema,
|
||||
available_tools: result.availableTools,
|
||||
});
|
||||
setMcpDialogOpen(false);
|
||||
},
|
||||
[addBlockAndCenter, blockData],
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
const customNode = addBlock(blockData);
|
||||
setTimeout(() => {
|
||||
setViewport(
|
||||
{
|
||||
x: -customNode.position.x * 0.8 + window.innerWidth / 2,
|
||||
y: -customNode.position.y * 0.8 + (window.innerHeight - 400) / 2,
|
||||
zoom: 0.8,
|
||||
},
|
||||
{ duration: 500 },
|
||||
);
|
||||
}, 50);
|
||||
if (isMCPBlock) {
|
||||
setMcpDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
addBlockAndCenter(blockData);
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent<HTMLButtonElement>) => {
|
||||
if (isMCPBlock) return;
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("application/reactflow", JSON.stringify(blockData));
|
||||
|
||||
@@ -71,46 +106,56 @@ export const Block: BlockComponent = ({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Button
|
||||
draggable={true}
|
||||
data-id={blockDataId}
|
||||
className={cn(
|
||||
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start space-x-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
|
||||
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={handleClick}
|
||||
{...rest}
|
||||
>
|
||||
<div className="flex flex-1 flex-col items-start gap-0.5">
|
||||
{title && (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 group-disabled:text-zinc-400",
|
||||
)}
|
||||
>
|
||||
{highlightText(beautifyString(title), highlightedText)}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
|
||||
)}
|
||||
>
|
||||
{highlightText(description, highlightedText)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
<>
|
||||
<Button
|
||||
draggable={!isMCPBlock}
|
||||
data-id={blockDataId}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-[0.5rem] bg-zinc-700 group-disabled:bg-zinc-400",
|
||||
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start space-x-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
|
||||
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",
|
||||
isMCPBlock && "hover:cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={handleClick}
|
||||
{...rest}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5 text-zinc-50" />
|
||||
</div>
|
||||
</Button>
|
||||
<div className="flex flex-1 flex-col items-start gap-0.5">
|
||||
{title && (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 group-disabled:text-zinc-400",
|
||||
)}
|
||||
>
|
||||
{highlightText(beautifyString(title), highlightedText)}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
|
||||
)}
|
||||
>
|
||||
{highlightText(description, highlightedText)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-[0.5rem] bg-zinc-700 group-disabled:bg-zinc-400",
|
||||
)}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5 text-zinc-50" />
|
||||
</div>
|
||||
</Button>
|
||||
{isMCPBlock && (
|
||||
<MCPToolDialog
|
||||
open={mcpDialogOpen}
|
||||
onClose={() => setMcpDialogOpen(false)}
|
||||
onConfirm={handleMCPToolConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { GraphMeta } from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
MCPToolDialog,
|
||||
type MCPToolDialogResult,
|
||||
} from "@/app/(platform)/build/components/legacy-builder/MCPToolDialog";
|
||||
import jaro from "jaro-winkler";
|
||||
import { getV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
@@ -94,6 +98,7 @@ export function BlocksControl({
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
|
||||
|
||||
const blocks = useSearchableBlocks(_blocks);
|
||||
|
||||
@@ -186,11 +191,31 @@ export function BlocksControl({
|
||||
setSelectedCategory(null);
|
||||
}, []);
|
||||
|
||||
const handleMCPToolConfirm = useCallback(
|
||||
(result: MCPToolDialogResult) => {
|
||||
addBlock(SpecialBlockID.MCP_TOOL, "MCPToolBlock", {
|
||||
server_url: result.serverUrl,
|
||||
server_name: result.serverName,
|
||||
selected_tool: result.selectedTool,
|
||||
tool_input_schema: result.toolInputSchema,
|
||||
available_tools: result.availableTools,
|
||||
});
|
||||
setMcpDialogOpen(false);
|
||||
},
|
||||
[addBlock],
|
||||
);
|
||||
|
||||
// Handler to add a block, fetching graph data on-demand for agent blocks
|
||||
const handleAddBlock = useCallback(
|
||||
async (block: _Block & { notAvailable: string | null }) => {
|
||||
if (block.notAvailable) return;
|
||||
|
||||
// For MCP blocks, open the configuration dialog instead of placing directly
|
||||
if (block.id === SpecialBlockID.MCP_TOOL) {
|
||||
setMcpDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// For agent blocks, fetch the full graph to get schemas
|
||||
if (block.uiType === BlockUIType.AGENT && block.hardcodedValues) {
|
||||
const graphID = block.hardcodedValues.graph_id as string;
|
||||
@@ -230,162 +255,179 @@ export function BlocksControl({
|
||||
}, [blocks]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={pinBlocksPopover ? true : undefined}
|
||||
onOpenChange={(open) => open || resetFilters()}
|
||||
>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-id="blocks-control-popover-trigger"
|
||||
data-testid="blocks-control-blocks-button"
|
||||
name="Blocks"
|
||||
className="dark:hover:bg-slate-800"
|
||||
>
|
||||
<IconToyBrick />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Blocks</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
sideOffset={22}
|
||||
align="start"
|
||||
className="absolute -top-3 w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
|
||||
data-id="blocks-control-popover-content"
|
||||
<>
|
||||
<Popover
|
||||
open={pinBlocksPopover ? true : undefined}
|
||||
onOpenChange={(open) => open || resetFilters()}
|
||||
>
|
||||
<Card className="p-3 pb-0 dark:bg-slate-900">
|
||||
<CardHeader className="flex flex-col gap-x-8 gap-y-1 p-3 px-2">
|
||||
<div className="items-center justify-between">
|
||||
<Label
|
||||
htmlFor="search-blocks"
|
||||
className="whitespace-nowrap text-base font-bold text-black dark:text-white 2xl:text-xl"
|
||||
data-id="blocks-control-label"
|
||||
data-testid="blocks-control-blocks-label"
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-id="blocks-control-popover-trigger"
|
||||
data-testid="blocks-control-blocks-button"
|
||||
name="Blocks"
|
||||
className="dark:hover:bg-slate-800"
|
||||
>
|
||||
Blocks
|
||||
</Label>
|
||||
</div>
|
||||
<div className="relative flex items-center">
|
||||
<MagnifyingGlassIcon className="absolute m-2 h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
<Input
|
||||
id="search-blocks"
|
||||
type="text"
|
||||
placeholder="Search blocks"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="rounded-lg px-8 py-5 dark:bg-slate-800 dark:text-white"
|
||||
data-id="blocks-control-search-input"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-2"
|
||||
data-testid="blocks-categories-list"
|
||||
>
|
||||
{categories.map((category) => {
|
||||
const color = getPrimaryCategoryColor([
|
||||
{ category: category || "All", description: "" },
|
||||
]);
|
||||
const colorClass =
|
||||
selectedCategory === category ? `${color}` : "";
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
data-testid="blocks-category"
|
||||
role="button"
|
||||
className={`cursor-pointer rounded-xl border px-2 py-2 text-xs font-medium dark:border-slate-700 dark:text-white ${colorClass}`}
|
||||
onClick={() =>
|
||||
setSelectedCategory(
|
||||
selectedCategory === category ? null : category,
|
||||
)
|
||||
}
|
||||
>
|
||||
{beautifyString((category || "All").toLowerCase())}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-scroll border-t border-t-gray-200 p-0 dark:border-t-slate-700">
|
||||
<ScrollArea
|
||||
className="h-[60vh] w-full"
|
||||
data-id="blocks-control-scroll-area"
|
||||
>
|
||||
{filteredAvailableBlocks.map((block) => (
|
||||
<Card
|
||||
key={block.uiKey || block.id}
|
||||
className={`m-2 my-4 flex h-20 shadow-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 ${
|
||||
block.notAvailable
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "cursor-move hover:shadow-lg"
|
||||
}`}
|
||||
data-id={`block-card-${block.id}`}
|
||||
draggable={!block.notAvailable}
|
||||
onDragStart={(e) => {
|
||||
if (block.notAvailable) return;
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData(
|
||||
"application/reactflow",
|
||||
JSON.stringify({
|
||||
blockId: block.id,
|
||||
blockName: block.name,
|
||||
hardcodedValues: block?.hardcodedValues || {},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onClick={() => handleAddBlock(block)}
|
||||
title={block.notAvailable ?? undefined}
|
||||
<IconToyBrick />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Blocks</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
sideOffset={22}
|
||||
align="start"
|
||||
className="absolute -top-3 w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
|
||||
data-id="blocks-control-popover-content"
|
||||
>
|
||||
<Card className="p-3 pb-0 dark:bg-slate-900">
|
||||
<CardHeader className="flex flex-col gap-x-8 gap-y-1 p-3 px-2">
|
||||
<div className="items-center justify-between">
|
||||
<Label
|
||||
htmlFor="search-blocks"
|
||||
className="whitespace-nowrap text-base font-bold text-black dark:text-white 2xl:text-xl"
|
||||
data-id="blocks-control-label"
|
||||
data-testid="blocks-control-blocks-label"
|
||||
>
|
||||
<div
|
||||
className={`-ml-px h-full w-3 rounded-l-xl ${getPrimaryCategoryColor(block.categories)}`}
|
||||
></div>
|
||||
|
||||
<div className="mx-3 flex flex-1 items-center justify-between">
|
||||
<div className="mr-2 min-w-0">
|
||||
<span
|
||||
className="block truncate pb-1 text-sm font-semibold dark:text-white"
|
||||
data-id={`block-name-${block.id}`}
|
||||
data-type={block.uiType}
|
||||
data-testid={`block-name-${block.id}`}
|
||||
>
|
||||
<TextRenderer
|
||||
value={beautifyString(block.name).replace(
|
||||
/ Block$/,
|
||||
"",
|
||||
)}
|
||||
truncateLengthLimit={45}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="block break-all text-xs font-normal text-gray-500 dark:text-gray-400"
|
||||
data-testid={`block-description-${block.id}`}
|
||||
>
|
||||
<TextRenderer
|
||||
value={block.description}
|
||||
truncateLengthLimit={165}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
Blocks
|
||||
</Label>
|
||||
</div>
|
||||
<div className="relative flex items-center">
|
||||
<MagnifyingGlassIcon className="absolute m-2 h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
<Input
|
||||
id="search-blocks"
|
||||
type="text"
|
||||
placeholder="Search blocks"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="rounded-lg px-8 py-5 dark:bg-slate-800 dark:text-white"
|
||||
data-id="blocks-control-search-input"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-2"
|
||||
data-testid="blocks-categories-list"
|
||||
>
|
||||
{categories.map((category) => {
|
||||
const color = getPrimaryCategoryColor([
|
||||
{ category: category || "All", description: "" },
|
||||
]);
|
||||
const colorClass =
|
||||
selectedCategory === category ? `${color}` : "";
|
||||
return (
|
||||
<div
|
||||
className="flex flex-shrink-0 items-center gap-1"
|
||||
data-id={`block-tooltip-${block.id}`}
|
||||
data-testid={`block-add`}
|
||||
key={category}
|
||||
data-testid="blocks-category"
|
||||
role="button"
|
||||
className={`cursor-pointer rounded-xl border px-2 py-2 text-xs font-medium dark:border-slate-700 dark:text-white ${colorClass}`}
|
||||
onClick={() =>
|
||||
setSelectedCategory(
|
||||
selectedCategory === category ? null : category,
|
||||
)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-6 w-6 rounded-lg bg-gray-200 stroke-black stroke-[0.5px] p-1 dark:bg-gray-700 dark:stroke-white" />
|
||||
{beautifyString((category || "All").toLowerCase())}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-scroll border-t border-t-gray-200 p-0 dark:border-t-slate-700">
|
||||
<ScrollArea
|
||||
className="h-[60vh] w-full"
|
||||
data-id="blocks-control-scroll-area"
|
||||
>
|
||||
{filteredAvailableBlocks.map((block) => (
|
||||
<Card
|
||||
key={block.uiKey || block.id}
|
||||
className={`m-2 my-4 flex h-20 shadow-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 ${
|
||||
block.notAvailable
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: block.id === SpecialBlockID.MCP_TOOL
|
||||
? "cursor-pointer hover:shadow-lg"
|
||||
: "cursor-move hover:shadow-lg"
|
||||
}`}
|
||||
data-id={`block-card-${block.id}`}
|
||||
draggable={
|
||||
!block.notAvailable &&
|
||||
block.id !== SpecialBlockID.MCP_TOOL
|
||||
}
|
||||
onDragStart={(e) => {
|
||||
if (
|
||||
block.notAvailable ||
|
||||
block.id === SpecialBlockID.MCP_TOOL
|
||||
)
|
||||
return;
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData(
|
||||
"application/reactflow",
|
||||
JSON.stringify({
|
||||
blockId: block.id,
|
||||
blockName: block.name,
|
||||
hardcodedValues: block?.hardcodedValues || {},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onClick={() => handleAddBlock(block)}
|
||||
title={block.notAvailable ?? undefined}
|
||||
>
|
||||
<div
|
||||
className={`-ml-px h-full w-3 rounded-l-xl ${getPrimaryCategoryColor(block.categories)}`}
|
||||
></div>
|
||||
|
||||
<div className="mx-3 flex flex-1 items-center justify-between">
|
||||
<div className="mr-2 min-w-0">
|
||||
<span
|
||||
className="block truncate pb-1 text-sm font-semibold dark:text-white"
|
||||
data-id={`block-name-${block.id}`}
|
||||
data-type={block.uiType}
|
||||
data-testid={`block-name-${block.id}`}
|
||||
>
|
||||
<TextRenderer
|
||||
value={beautifyString(block.name).replace(
|
||||
/ Block$/,
|
||||
"",
|
||||
)}
|
||||
truncateLengthLimit={45}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="block break-all text-xs font-normal text-gray-500 dark:text-gray-400"
|
||||
data-testid={`block-description-${block.id}`}
|
||||
>
|
||||
<TextRenderer
|
||||
value={block.description}
|
||||
truncateLengthLimit={165}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-shrink-0 items-center gap-1"
|
||||
data-id={`block-tooltip-${block.id}`}
|
||||
data-testid={`block-add`}
|
||||
>
|
||||
<PlusIcon className="h-6 w-6 rounded-lg bg-gray-200 stroke-black stroke-[0.5px] p-1 dark:bg-gray-700 dark:stroke-white" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<MCPToolDialog
|
||||
open={mcpDialogOpen}
|
||||
onClose={() => setMcpDialogOpen(false)}
|
||||
onConfirm={handleMCPToolConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
GraphInputSchema,
|
||||
GraphOutputSchema,
|
||||
NodeExecutionResult,
|
||||
SpecialBlockID,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
beautifyString,
|
||||
@@ -215,6 +216,29 @@ export const CustomNode = React.memo(
|
||||
}
|
||||
}
|
||||
|
||||
// MCP Tool block: display the selected tool's dynamic schema
|
||||
const isMCPWithTool =
|
||||
data.block_id === SpecialBlockID.MCP_TOOL &&
|
||||
!!data.hardcodedValues?.tool_input_schema?.properties;
|
||||
|
||||
if (isMCPWithTool) {
|
||||
const credentialsProp = data.inputSchema?.properties?.credentials;
|
||||
const toolSchema = data.hardcodedValues.tool_input_schema;
|
||||
const staticRequired = data.inputSchema?.required ?? [];
|
||||
|
||||
data.inputSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
...(credentialsProp ? { credentials: credentialsProp } : {}),
|
||||
...(toolSchema.properties ?? {}),
|
||||
},
|
||||
required: [
|
||||
...staticRequired.filter((r: string) => r === "credentials"),
|
||||
...(toolSchema.required ?? []),
|
||||
],
|
||||
} as BlockIORootSchema;
|
||||
}
|
||||
|
||||
const setHardcodedValues = useCallback(
|
||||
(values: any) => {
|
||||
updateNodeData(id, { hardcodedValues: values });
|
||||
@@ -375,7 +399,9 @@ export const CustomNode = React.memo(
|
||||
|
||||
const displayTitle =
|
||||
customTitle ||
|
||||
beautifyString(data.blockType?.replace(/Block$/, "") || data.title);
|
||||
(isMCPWithTool
|
||||
? `${data.hardcodedValues.server_name || "MCP"}: ${beautifyString(data.hardcodedValues.selected_tool || "")}`
|
||||
: beautifyString(data.blockType?.replace(/Block$/, "") || data.title));
|
||||
|
||||
useEffect(() => {
|
||||
isInitialSetup.current = false;
|
||||
@@ -389,6 +415,15 @@ export const CustomNode = React.memo(
|
||||
data.inputSchema,
|
||||
),
|
||||
});
|
||||
} else if (isMCPWithTool) {
|
||||
// MCP dialog already configured server_url, selected_tool, etc.
|
||||
// Just ensure tool_arguments is initialized.
|
||||
if (!data.hardcodedValues.tool_arguments) {
|
||||
setHardcodedValues({
|
||||
...data.hardcodedValues,
|
||||
tool_arguments: {},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setHardcodedValues(
|
||||
fillObjectDefaultsFromSchema(data.hardcodedValues, data.inputSchema),
|
||||
@@ -525,8 +560,12 @@ export const CustomNode = React.memo(
|
||||
);
|
||||
|
||||
default:
|
||||
const getInputPropKey = (key: string) =>
|
||||
nodeType == BlockUIType.AGENT ? `inputs.${key}` : key;
|
||||
const getInputPropKey = (key: string) => {
|
||||
if (nodeType == BlockUIType.AGENT) return `inputs.${key}`;
|
||||
if (isMCPWithTool && key !== "credentials")
|
||||
return `tool_arguments.${key}`;
|
||||
return key;
|
||||
};
|
||||
|
||||
return keys.map(([propKey, propSchema]) => {
|
||||
const isRequired = data.inputSchema.required?.includes(propKey);
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/__legacy__/ui/dialog";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import { Input } from "@/components/__legacy__/ui/input";
|
||||
import { Label } from "@/components/__legacy__/ui/label";
|
||||
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
||||
import { Badge } from "@/components/__legacy__/ui/badge";
|
||||
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import type { MCPTool } from "@/lib/autogpt-server-api";
|
||||
import { CaretDown } from "@phosphor-icons/react";
|
||||
|
||||
export type MCPToolDialogResult = {
|
||||
serverUrl: string;
|
||||
serverName: string | null;
|
||||
selectedTool: string;
|
||||
toolInputSchema: Record<string, any>;
|
||||
availableTools: Record<string, any>;
|
||||
};
|
||||
|
||||
interface MCPToolDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (result: MCPToolDialogResult) => void;
|
||||
}
|
||||
|
||||
type DialogStep = "url" | "tool";
|
||||
|
||||
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const STORAGE_KEY = "mcp_last_server_url";
|
||||
|
||||
export function MCPToolDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: MCPToolDialogProps) {
|
||||
const api = useBackendAPI();
|
||||
|
||||
const [step, setStep] = useState<DialogStep>("url");
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [tools, setTools] = useState<MCPTool[]>([]);
|
||||
const [serverName, setServerName] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
const [showManualToken, setShowManualToken] = useState(false);
|
||||
const [manualToken, setManualToken] = useState("");
|
||||
const [selectedTool, setSelectedTool] = useState<MCPTool | null>(null);
|
||||
|
||||
const oauthLoadingRef = useRef(false);
|
||||
const stateTokenRef = useRef<string | null>(null);
|
||||
const broadcastChannelRef = useRef<BroadcastChannel | null>(null);
|
||||
const messageHandlerRef = useRef<((event: MessageEvent) => void) | null>(
|
||||
null,
|
||||
);
|
||||
const oauthHandledRef = useRef(false);
|
||||
const autoConnectAttemptedRef = useRef(false);
|
||||
|
||||
// Attempt auto-connect when dialog opens with a stored server URL
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
autoConnectAttemptedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastUrl = localStorage.getItem(STORAGE_KEY);
|
||||
if (!lastUrl || autoConnectAttemptedRef.current) return;
|
||||
autoConnectAttemptedRef.current = true;
|
||||
|
||||
setServerUrl(lastUrl);
|
||||
setLoading(true);
|
||||
api
|
||||
.mcpDiscoverTools(lastUrl)
|
||||
.then((result) => {
|
||||
setTools(result.tools);
|
||||
setServerName(result.server_name);
|
||||
setStep("tool");
|
||||
})
|
||||
.catch(() => {
|
||||
// Stored credential expired or server changed — stay on URL step
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [open, api]);
|
||||
|
||||
// Clean up listeners on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (messageHandlerRef.current) {
|
||||
window.removeEventListener("message", messageHandlerRef.current);
|
||||
}
|
||||
if (broadcastChannelRef.current) {
|
||||
broadcastChannelRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleanupOAuthListeners = useCallback(() => {
|
||||
if (messageHandlerRef.current) {
|
||||
window.removeEventListener("message", messageHandlerRef.current);
|
||||
messageHandlerRef.current = null;
|
||||
}
|
||||
if (broadcastChannelRef.current) {
|
||||
broadcastChannelRef.current.close();
|
||||
broadcastChannelRef.current = null;
|
||||
}
|
||||
setOauthLoading(false);
|
||||
oauthLoadingRef.current = false;
|
||||
oauthHandledRef.current = false;
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
cleanupOAuthListeners();
|
||||
setStep("url");
|
||||
setServerUrl("");
|
||||
setManualToken("");
|
||||
setTools([]);
|
||||
setServerName(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setAuthRequired(false);
|
||||
setShowManualToken(false);
|
||||
setSelectedTool(null);
|
||||
stateTokenRef.current = null;
|
||||
}, [cleanupOAuthListeners]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
reset();
|
||||
onClose();
|
||||
}, [reset, onClose]);
|
||||
|
||||
const discoverTools = useCallback(
|
||||
async (url: string, authToken?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.mcpDiscoverTools(url, authToken);
|
||||
localStorage.setItem(STORAGE_KEY, url);
|
||||
setTools(result.tools);
|
||||
setServerName(result.server_name);
|
||||
setAuthRequired(false);
|
||||
setShowManualToken(false);
|
||||
setStep("tool");
|
||||
} catch (e: any) {
|
||||
if (e?.status === 401 || e?.status === 403) {
|
||||
setAuthRequired(true);
|
||||
setError(null);
|
||||
} else {
|
||||
const message =
|
||||
e?.message || e?.detail || "Failed to connect to MCP server";
|
||||
setError(
|
||||
typeof message === "string" ? message : JSON.stringify(message),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
const handleDiscoverTools = useCallback(() => {
|
||||
if (!serverUrl.trim()) return;
|
||||
discoverTools(serverUrl.trim(), manualToken.trim() || undefined);
|
||||
}, [serverUrl, manualToken, discoverTools]);
|
||||
|
||||
const handleOAuthResult = useCallback(
|
||||
async (data: {
|
||||
success: boolean;
|
||||
code?: string;
|
||||
state?: string;
|
||||
message?: string;
|
||||
}) => {
|
||||
// Prevent double-handling (BroadcastChannel + postMessage may both fire)
|
||||
if (oauthHandledRef.current) return;
|
||||
oauthHandledRef.current = true;
|
||||
|
||||
if (!data.success) {
|
||||
setError(data.message || "OAuth authentication failed.");
|
||||
cleanupOAuthListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupOAuthListeners();
|
||||
setAuthRequired(false);
|
||||
|
||||
// Exchange code for tokens (stored server-side)
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.mcpOAuthCallback(data.code!, stateTokenRef.current!);
|
||||
// Retry discovery — backend auto-uses stored credential
|
||||
const result = await api.mcpDiscoverTools(serverUrl.trim());
|
||||
localStorage.setItem(STORAGE_KEY, serverUrl.trim());
|
||||
setTools(result.tools);
|
||||
setServerName(result.server_name);
|
||||
setStep("tool");
|
||||
} catch (e: any) {
|
||||
const message = e?.message || e?.detail || "Failed to complete sign-in";
|
||||
setError(
|
||||
typeof message === "string" ? message : JSON.stringify(message),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[api, serverUrl, cleanupOAuthListeners],
|
||||
);
|
||||
|
||||
const handleOAuthSignIn = useCallback(async () => {
|
||||
if (!serverUrl.trim()) return;
|
||||
setError(null);
|
||||
oauthHandledRef.current = false;
|
||||
|
||||
// Open popup SYNCHRONOUSLY (before async call) to avoid browser popup blockers
|
||||
const width = 500;
|
||||
const height = 700;
|
||||
const left = window.screenX + (window.outerWidth - width) / 2;
|
||||
const top = window.screenY + (window.outerHeight - height) / 2;
|
||||
const popup = window.open(
|
||||
"about:blank",
|
||||
"mcp_oauth",
|
||||
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes`,
|
||||
);
|
||||
|
||||
setOauthLoading(true);
|
||||
oauthLoadingRef.current = true;
|
||||
|
||||
try {
|
||||
const { login_url, state_token } = await api.mcpOAuthLogin(
|
||||
serverUrl.trim(),
|
||||
);
|
||||
stateTokenRef.current = state_token;
|
||||
|
||||
if (popup && !popup.closed) {
|
||||
popup.location.href = login_url;
|
||||
} else {
|
||||
// Popup was blocked — open in new tab as fallback
|
||||
window.open(login_url, "_blank");
|
||||
}
|
||||
|
||||
// Listener 1: BroadcastChannel (works even when window.opener is null)
|
||||
const bc = new BroadcastChannel("mcp_oauth");
|
||||
bc.onmessage = (event) => {
|
||||
if (event.data?.type === "mcp_oauth_result") {
|
||||
handleOAuthResult(event.data);
|
||||
}
|
||||
};
|
||||
broadcastChannelRef.current = bc;
|
||||
|
||||
// Listener 2: window.postMessage (fallback)
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.message_type === "mcp_oauth_result") {
|
||||
handleOAuthResult(event.data);
|
||||
}
|
||||
};
|
||||
messageHandlerRef.current = handleMessage;
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
// Timeout
|
||||
setTimeout(() => {
|
||||
if (oauthLoadingRef.current) {
|
||||
cleanupOAuthListeners();
|
||||
setError("OAuth sign-in timed out. Please try again.");
|
||||
}
|
||||
}, OAUTH_TIMEOUT_MS);
|
||||
} catch (e: any) {
|
||||
if (popup && !popup.closed) popup.close();
|
||||
|
||||
// If server doesn't support OAuth → show manual token entry
|
||||
if (e?.status === 400) {
|
||||
setShowManualToken(true);
|
||||
setError(
|
||||
"This server does not support OAuth sign-in. Please enter a token manually.",
|
||||
);
|
||||
} else {
|
||||
const message = e?.message || "Failed to initiate sign-in";
|
||||
setError(
|
||||
typeof message === "string" ? message : JSON.stringify(message),
|
||||
);
|
||||
}
|
||||
cleanupOAuthListeners();
|
||||
}
|
||||
}, [api, serverUrl, handleOAuthResult, cleanupOAuthListeners]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!selectedTool) return;
|
||||
|
||||
const availableTools: Record<string, any> = {};
|
||||
for (const t of tools) {
|
||||
availableTools[t.name] = {
|
||||
description: t.description,
|
||||
input_schema: t.input_schema,
|
||||
};
|
||||
}
|
||||
|
||||
onConfirm({
|
||||
serverUrl: serverUrl.trim(),
|
||||
serverName,
|
||||
selectedTool: selectedTool.name,
|
||||
toolInputSchema: selectedTool.input_schema,
|
||||
availableTools,
|
||||
});
|
||||
reset();
|
||||
}, [selectedTool, tools, serverUrl, onConfirm, reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === "url"
|
||||
? "Connect to MCP Server"
|
||||
: `Select a Tool${serverName ? ` — ${serverName}` : ""}`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "url"
|
||||
? "Enter the URL of an MCP server to discover its available tools."
|
||||
: `Found ${tools.length} tool${tools.length !== 1 ? "s" : ""}. Select one to add to your agent.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "url" && (
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mcp-server-url">Server URL</Label>
|
||||
<Input
|
||||
id="mcp-server-url"
|
||||
type="url"
|
||||
placeholder="https://mcp.example.com/mcp"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleDiscoverTools()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auth required: show sign-in panel */}
|
||||
{authRequired && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
This server requires authentication
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleOAuthSignIn}
|
||||
disabled={oauthLoading || loading}
|
||||
className="w-full"
|
||||
>
|
||||
{oauthLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<LoadingSpinner className="size-4" />
|
||||
Waiting for sign-in...
|
||||
</span>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
{!showManualToken && (
|
||||
<button
|
||||
onClick={() => setShowManualToken(true)}
|
||||
className="text-xs text-gray-500 underline hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
or enter a token manually
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual token entry — only visible when expanded */}
|
||||
{showManualToken && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mcp-auth-token" className="text-sm">
|
||||
Bearer Token
|
||||
</Label>
|
||||
<Input
|
||||
id="mcp-auth-token"
|
||||
type="password"
|
||||
placeholder="Paste your auth token here"
|
||||
value={manualToken}
|
||||
onChange={(e) => setManualToken(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleDiscoverTools()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "tool" && (
|
||||
<ScrollArea className="max-h-[50vh] py-2">
|
||||
<div className="flex flex-col gap-2 pr-3">
|
||||
{tools.map((tool) => (
|
||||
<MCPToolCard
|
||||
key={tool.name}
|
||||
tool={tool}
|
||||
selected={selectedTool?.name === tool.name}
|
||||
onSelect={() => setSelectedTool(tool)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{step === "tool" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep("url");
|
||||
setSelectedTool(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{step === "url" && (
|
||||
<Button
|
||||
onClick={handleDiscoverTools}
|
||||
disabled={!serverUrl.trim() || loading || oauthLoading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<LoadingSpinner className="size-4" />
|
||||
Connecting...
|
||||
</span>
|
||||
) : (
|
||||
"Discover Tools"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{step === "tool" && (
|
||||
<Button onClick={handleConfirm} disabled={!selectedTool}>
|
||||
Add Block
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------- Tool Card Component --------------- //
|
||||
|
||||
/** Truncate a description to a reasonable length for the collapsed view. */
|
||||
function truncateDescription(text: string, maxLen = 120): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen).trimEnd() + "…";
|
||||
}
|
||||
|
||||
/** Pretty-print a JSON Schema type for a parameter. */
|
||||
function schemaTypeLabel(schema: Record<string, any>): string {
|
||||
if (schema.type) return schema.type;
|
||||
if (schema.anyOf)
|
||||
return schema.anyOf.map((s: any) => s.type ?? "any").join(" | ");
|
||||
if (schema.oneOf)
|
||||
return schema.oneOf.map((s: any) => s.type ?? "any").join(" | ");
|
||||
return "any";
|
||||
}
|
||||
|
||||
function MCPToolCard({
|
||||
tool,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
tool: MCPTool;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const properties = tool.input_schema?.properties ?? {};
|
||||
const required = new Set<string>(tool.input_schema?.required ?? []);
|
||||
const paramNames = Object.keys(properties);
|
||||
|
||||
// Strip XML-like tags and hints from description for cleaner display
|
||||
const cleanDescription = (tool.description ?? "")
|
||||
.replace(/<[^>]+>[^<]*<\/[^>]+>/g, "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={`group flex flex-col rounded-lg border text-left transition-colors ${
|
||||
selected
|
||||
? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950"
|
||||
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50 dark:border-slate-700 dark:hover:border-slate-600 dark:hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 pb-1 pt-3">
|
||||
<span className="flex-1 text-sm font-semibold dark:text-white">
|
||||
{tool.name}
|
||||
</span>
|
||||
{paramNames.length > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{paramNames.length} param{paramNames.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description (collapsed: truncated) */}
|
||||
{cleanDescription && (
|
||||
<p className="px-3 pb-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
|
||||
{expanded ? cleanDescription : truncateDescription(cleanDescription)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Parameter badges (collapsed view) */}
|
||||
{!expanded && paramNames.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 px-3 pb-2">
|
||||
{paramNames.slice(0, 6).map((name) => (
|
||||
<Badge
|
||||
key={name}
|
||||
variant="outline"
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{name}
|
||||
{required.has(name) && (
|
||||
<span className="ml-0.5 text-red-400">*</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
{paramNames.length > 6 && (
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
+{paramNames.length - 6} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded: full parameter details */}
|
||||
{expanded && paramNames.length > 0 && (
|
||||
<div className="mx-3 mb-2 rounded border border-gray-100 bg-gray-50/50 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-slate-700">
|
||||
<th className="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||
Parameter
|
||||
</th>
|
||||
<th className="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paramNames.map((name) => {
|
||||
const prop = properties[name] ?? {};
|
||||
return (
|
||||
<tr
|
||||
key={name}
|
||||
className="border-b border-gray-50 last:border-0 dark:border-slate-700/50"
|
||||
>
|
||||
<td className="px-2 py-1 font-mono text-[11px] text-gray-700 dark:text-gray-300">
|
||||
{name}
|
||||
{required.has(name) && (
|
||||
<span className="ml-0.5 text-red-400">*</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-gray-500 dark:text-gray-400">
|
||||
{schemaTypeLabel(prop)}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-2 py-1 text-gray-500 dark:text-gray-400">
|
||||
{prop.description ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle details */}
|
||||
{(paramNames.length > 0 || cleanDescription.length > 120) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((prev) => !prev);
|
||||
}}
|
||||
className="flex w-full items-center justify-center gap-1 border-t border-gray-100 py-1.5 text-[10px] text-gray-400 hover:text-gray-600 dark:border-slate-700 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
{expanded ? "Hide details" : "Show details"}
|
||||
<CaretDown
|
||||
className={`h-3 w-3 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -4237,6 +4237,128 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/mcp/discover-tools": {
|
||||
"post": {
|
||||
"tags": ["v2", "mcp", "mcp"],
|
||||
"summary": "Discover available tools on an MCP server",
|
||||
"description": "Connect to an MCP server and return its available tools.\n\nIf the user has a stored MCP credential for this server URL, it will be\nused automatically — no need to pass an explicit auth token.",
|
||||
"operationId": "postV2Discover available tools on an mcp server",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/DiscoverToolsRequest" }
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DiscoverToolsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/mcp/oauth/callback": {
|
||||
"post": {
|
||||
"tags": ["v2", "mcp", "mcp"],
|
||||
"summary": "Exchange OAuth code for MCP tokens",
|
||||
"description": "Exchange the authorization code for tokens and store the credential.\n\nThe frontend calls this after receiving the OAuth code from the popup.\nOn success, subsequent ``/discover-tools`` calls for the same server URL\nwill automatically use the stored credential.",
|
||||
"operationId": "postV2Exchange oauth code for mcp tokens",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MCPOAuthCallbackRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MCPOAuthCallbackResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/mcp/oauth/login": {
|
||||
"post": {
|
||||
"tags": ["v2", "mcp", "mcp"],
|
||||
"summary": "Initiate OAuth login for an MCP server",
|
||||
"description": "Discover OAuth metadata from the MCP server and return a login URL.\n\n1. Discovers the protected-resource metadata (RFC 9728)\n2. Fetches the authorization server metadata (RFC 8414)\n3. Performs Dynamic Client Registration (RFC 7591) if available\n4. Returns the authorization URL for the frontend to open in a popup",
|
||||
"operationId": "postV2Initiate oauth login for an mcp server",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/MCPOAuthLoginRequest" }
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MCPOAuthLoginResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/oauth/app/{client_id}": {
|
||||
"get": {
|
||||
"tags": ["oauth"],
|
||||
@@ -7195,6 +7317,45 @@
|
||||
"required": ["version_counts"],
|
||||
"title": "DeleteGraphResponse"
|
||||
},
|
||||
"DiscoverToolsRequest": {
|
||||
"properties": {
|
||||
"server_url": {
|
||||
"type": "string",
|
||||
"title": "Server Url",
|
||||
"description": "URL of the MCP server"
|
||||
},
|
||||
"auth_token": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Auth Token",
|
||||
"description": "Optional Bearer token for authenticated MCP servers"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["server_url"],
|
||||
"title": "DiscoverToolsRequest",
|
||||
"description": "Request to discover tools on an MCP server."
|
||||
},
|
||||
"DiscoverToolsResponse": {
|
||||
"properties": {
|
||||
"tools": {
|
||||
"items": { "$ref": "#/components/schemas/MCPToolResponse" },
|
||||
"type": "array",
|
||||
"title": "Tools"
|
||||
},
|
||||
"server_name": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Server Name"
|
||||
},
|
||||
"protocol_version": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Protocol Version"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["tools"],
|
||||
"title": "DiscoverToolsResponse",
|
||||
"description": "Response containing the list of tools available on an MCP server."
|
||||
},
|
||||
"Document": {
|
||||
"properties": {
|
||||
"url": { "type": "string", "title": "Url" },
|
||||
@@ -8562,6 +8723,71 @@
|
||||
"required": ["login_url", "state_token"],
|
||||
"title": "LoginResponse"
|
||||
},
|
||||
"MCPOAuthCallbackRequest": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"title": "Code",
|
||||
"description": "Authorization code from OAuth callback"
|
||||
},
|
||||
"state_token": {
|
||||
"type": "string",
|
||||
"title": "State Token",
|
||||
"description": "State token for CSRF verification"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["code", "state_token"],
|
||||
"title": "MCPOAuthCallbackRequest",
|
||||
"description": "Request to exchange an OAuth code for tokens."
|
||||
},
|
||||
"MCPOAuthCallbackResponse": {
|
||||
"properties": {
|
||||
"credential_id": { "type": "string", "title": "Credential Id" }
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["credential_id"],
|
||||
"title": "MCPOAuthCallbackResponse",
|
||||
"description": "Response after successfully storing OAuth credentials."
|
||||
},
|
||||
"MCPOAuthLoginRequest": {
|
||||
"properties": {
|
||||
"server_url": {
|
||||
"type": "string",
|
||||
"title": "Server Url",
|
||||
"description": "URL of the MCP server that requires OAuth"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["server_url"],
|
||||
"title": "MCPOAuthLoginRequest",
|
||||
"description": "Request to start an OAuth flow for an MCP server."
|
||||
},
|
||||
"MCPOAuthLoginResponse": {
|
||||
"properties": {
|
||||
"login_url": { "type": "string", "title": "Login Url" },
|
||||
"state_token": { "type": "string", "title": "State Token" }
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["login_url", "state_token"],
|
||||
"title": "MCPOAuthLoginResponse",
|
||||
"description": "Response with the OAuth login URL for the user to authenticate."
|
||||
},
|
||||
"MCPToolResponse": {
|
||||
"properties": {
|
||||
"name": { "type": "string", "title": "Name" },
|
||||
"description": { "type": "string", "title": "Description" },
|
||||
"input_schema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object",
|
||||
"title": "Input Schema"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["name", "description", "input_schema"],
|
||||
"title": "MCPToolResponse",
|
||||
"description": "A single MCP tool returned by discovery."
|
||||
},
|
||||
"MarketplaceListing": {
|
||||
"properties": {
|
||||
"id": { "type": "string", "title": "Id" },
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
GraphMeta,
|
||||
GraphUpdateable,
|
||||
HostScopedCredentials,
|
||||
MCPDiscoverToolsResponse,
|
||||
LibraryAgent,
|
||||
LibraryAgentID,
|
||||
LibraryAgentPreset,
|
||||
@@ -792,6 +793,38 @@ export default class BackendAPI {
|
||||
return this._request("POST", "/otto/ask", query);
|
||||
}
|
||||
|
||||
////////////////////////////////////////
|
||||
///////////// MCP FUNCTIONS ////////////
|
||||
////////////////////////////////////////
|
||||
|
||||
async mcpDiscoverTools(
|
||||
serverUrl: string,
|
||||
authToken?: string,
|
||||
): Promise<MCPDiscoverToolsResponse> {
|
||||
return this._request("POST", "/mcp/discover-tools", {
|
||||
server_url: serverUrl,
|
||||
auth_token: authToken || null,
|
||||
});
|
||||
}
|
||||
|
||||
async mcpOAuthLogin(
|
||||
serverUrl: string,
|
||||
): Promise<{ login_url: string; state_token: string }> {
|
||||
return this._request("POST", "/mcp/oauth/login", {
|
||||
server_url: serverUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async mcpOAuthCallback(
|
||||
code: string,
|
||||
stateToken: string,
|
||||
): Promise<{ credential_id: string }> {
|
||||
return this._request("POST", "/mcp/oauth/callback", {
|
||||
code,
|
||||
state_token: stateToken,
|
||||
});
|
||||
}
|
||||
|
||||
////////////////////////////////////////
|
||||
////////// INTERNAL FUNCTIONS //////////
|
||||
////////////////////////////////////////
|
||||
|
||||
@@ -753,10 +753,23 @@ export enum BlockUIType {
|
||||
|
||||
export enum SpecialBlockID {
|
||||
AGENT = "e189baac-8c20-45a1-94a7-55177ea42565",
|
||||
MCP_TOOL = "a0a4b1c2-d3e4-4f56-a7b8-c9d0e1f2a3b4",
|
||||
SMART_DECISION = "3b191d9f-356f-482d-8238-ba04b6d18381",
|
||||
OUTPUT = "363ae599-353e-4804-937e-b2ee3cef3da4",
|
||||
}
|
||||
|
||||
export type MCPTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: Record<string, any>;
|
||||
};
|
||||
|
||||
export type MCPDiscoverToolsResponse = {
|
||||
tools: MCPTool[];
|
||||
server_name: string | null;
|
||||
protocol_version: string | null;
|
||||
};
|
||||
|
||||
export type AnalyticsMetrics = {
|
||||
metric_name: string;
|
||||
metric_value: number;
|
||||
|
||||
Reference in New Issue
Block a user