diff --git a/autogpt_platform/backend/backend/api/features/mcp/__init__.py b/autogpt_platform/backend/backend/api/features/mcp/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/autogpt_platform/backend/backend/api/features/mcp/routes.py b/autogpt_platform/backend/backend/api/features/mcp/routes.py
new file mode 100644
index 0000000000..855b9d8d9e
--- /dev/null
+++ b/autogpt_platform/backend/backend/api/features/mcp/routes.py
@@ -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
diff --git a/autogpt_platform/backend/backend/api/features/mcp/test_routes.py b/autogpt_platform/backend/backend/api/features/mcp/test_routes.py
new file mode 100644
index 0000000000..51642d8955
--- /dev/null
+++ b/autogpt_platform/backend/backend/api/features/mcp/test_routes.py
@@ -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()
diff --git a/autogpt_platform/backend/backend/api/rest_api.py b/autogpt_platform/backend/backend/api/rest_api.py
index 0eef76193e..aed348755b 100644
--- a/autogpt_platform/backend/backend/api/rest_api.py
+++ b/autogpt_platform/backend/backend/api/rest_api.py
@@ -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"],
diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/mcp_callback/route.ts b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/mcp_callback/route.ts
new file mode 100644
index 0000000000..a368843df5
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/mcp_callback/route.ts
@@ -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, "\\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(
+ `
+
+
MCP Sign-in
+
+
+
+
Completing sign-in...
+
+
+
+
+`,
+ { headers: { "Content-Type": "text/html" } },
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx
index d4aa26480d..62e796b748 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx
@@ -47,7 +47,10 @@ export type CustomNode = XYNode;
export const CustomNode: React.FC> = 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> = React.memo(
jsonSchema={preprocessInputSchema(inputSchema)}
nodeId={nodeId}
uiType={data.uiType}
+ isMCPWithTool={isMCPWithTool}
className={cn(
"bg-white px-4",
isWebhook && "pointer-events-none opacity-50",
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx
index c4659b8dcf..817fe39bfe 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx
@@ -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;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode.tsx
index e58d0ab12b..cddfbabc52 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode.tsx
@@ -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,
+ toolInputSchema: Record,
+): Record {
+ 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,
};
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx
index d6a3fabffa..ab834a0d04 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx
@@ -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 = 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 (
{
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
) => {
+ 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) => {
+ 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 (
-
+
+ {title && (
+
+ {highlightText(beautifyString(title), highlightedText)}
+
+ )}
+ {description && (
+
+ {highlightText(description, highlightedText)}
+
+ )}
+
+
+
+ {isMCPBlock && (
+ setMcpDialogOpen(false)}
+ onConfirm={handleMCPToolConfirm}
+ />
+ )}
+ >
);
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx
index 99b66fe1dc..d70ee68b9c 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx
@@ -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(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 (
- open || resetFilters()}
- >
-
-
-
-
-
-
- Blocks
-
-
+ open || resetFilters()}
>
-
-
-
-
-
- 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"
- />
-
-
- {categories.map((category) => {
- const color = getPrimaryCategoryColor([
- { category: category || "All", description: "" },
- ]);
- const colorClass =
- selectedCategory === category ? `${color}` : "";
- return (
-
- setSelectedCategory(
- selectedCategory === category ? null : category,
- )
- }
- >
- {beautifyString((category || "All").toLowerCase())}
-
- );
- })}
-
-
-
-
- {filteredAvailableBlocks.map((block) => (
- {
- 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}
+
+
+
+
+ Blocks
+
+
+
+
+
+
+
+
+
+ {filteredAvailableBlocks.map((block) => (
+ {
+ 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}
+ >
+
+
+
+
+ ))}
+
+
+
+
+
+
+ setMcpDialogOpen(false)}
+ onConfirm={handleMCPToolConfirm}
+ />
+ >
);
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx
index 834603cc4a..3f47c35d68 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx
@@ -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);
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/MCPToolDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/MCPToolDialog.tsx
new file mode 100644
index 0000000000..2a9f1331e8
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/MCPToolDialog.tsx
@@ -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;
+ availableTools: Record;
+};
+
+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("url");
+ const [serverUrl, setServerUrl] = useState("");
+ const [tools, setTools] = useState([]);
+ const [serverName, setServerName] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [authRequired, setAuthRequired] = useState(false);
+ const [oauthLoading, setOauthLoading] = useState(false);
+ const [showManualToken, setShowManualToken] = useState(false);
+ const [manualToken, setManualToken] = useState("");
+ const [selectedTool, setSelectedTool] = useState(null);
+
+ const oauthLoadingRef = useRef(false);
+ const stateTokenRef = useRef(null);
+ const broadcastChannelRef = useRef(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 = {};
+ 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 (
+
+ );
+}
+
+// --------------- 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 {
+ 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(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 (
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json
index ccf5ad3e34..00056f6c8d 100644
--- a/autogpt_platform/frontend/src/app/api/openapi.json
+++ b/autogpt_platform/frontend/src/app/api/openapi.json
@@ -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" },
diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts
index e58b5f6020..bc3b874046 100644
--- a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts
+++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts
@@ -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 {
+ 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 //////////
////////////////////////////////////////
diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts
index 44fb25dbfc..a08aeec0a9 100644
--- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts
+++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts
@@ -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;
+};
+
+export type MCPDiscoverToolsResponse = {
+ tools: MCPTool[];
+ server_name: string | null;
+ protocol_version: string | null;
+};
+
export type AnalyticsMetrics = {
metric_name: string;
metric_value: number;