From df41d02fce1c1fa19545b6bbaa1587e7c8fb8b2b Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Mon, 9 Feb 2026 14:18:59 +0400 Subject: [PATCH] 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 --- .../backend/api/features/mcp/__init__.py | 0 .../backend/api/features/mcp/routes.py | 365 +++++++++++ .../backend/api/features/mcp/test_routes.py | 385 +++++++++++ .../backend/backend/api/rest_api.py | 6 + .../auth/integrations/mcp_callback/route.ts | 90 +++ .../nodes/CustomNode/CustomNode.tsx | 6 +- .../CustomNode/components/NodeHeader.tsx | 8 + .../nodes/CustomNode/useCustomNode.tsx | 43 +- .../FlowEditor/nodes/FormCreator.tsx | 56 +- .../NewControlPanel/NewBlockMenu/Block.tsx | 143 ++-- .../legacy-builder/BlocksControl.tsx | 342 +++++----- .../legacy-builder/CustomNode/CustomNode.tsx | 45 +- .../legacy-builder/MCPToolDialog.tsx | 610 ++++++++++++++++++ .../frontend/src/app/api/openapi.json | 226 +++++++ .../src/lib/autogpt-server-api/client.ts | 33 + .../src/lib/autogpt-server-api/types.ts | 13 + 16 files changed, 2155 insertions(+), 216 deletions(-) create mode 100644 autogpt_platform/backend/backend/api/features/mcp/__init__.py create mode 100644 autogpt_platform/backend/backend/api/features/mcp/routes.py create mode 100644 autogpt_platform/backend/backend/api/features/mcp/test_routes.py create mode 100644 autogpt_platform/frontend/src/app/(platform)/auth/integrations/mcp_callback/route.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/MCPToolDialog.tsx 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 ( + !isOpen && handleClose()}> + + + + {step === "url" + ? "Connect to MCP Server" + : `Select a Tool${serverName ? ` — ${serverName}` : ""}`} + + + {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.`} + + + + {step === "url" && ( +
+
+ + setServerUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleDiscoverTools()} + autoFocus + /> +
+ + {/* Auth required: show sign-in panel */} + {authRequired && ( +
+

+ This server requires authentication +

+ + {!showManualToken && ( + + )} +
+ )} + + {/* Manual token entry — only visible when expanded */} + {showManualToken && ( +
+ + setManualToken(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleDiscoverTools()} + autoFocus + /> +
+ )} + + {error &&

{error}

} +
+ )} + + {step === "tool" && ( + +
+ {tools.map((tool) => ( + setSelectedTool(tool)} + /> + ))} +
+
+ )} + + + {step === "tool" && ( + + )} + + {step === "url" && ( + + )} + {step === "tool" && ( + + )} + +
+
+ ); +} + +// --------------- 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;