feat(frontend/mcp): Add MCP tool discovery UI, OAuth flow, and dynamic block schema

- Add MCPToolDialog with tool discovery, OAuth sign-in, and card-based tool selection
- Add OAuth callback route using BroadcastChannel API for popup communication
- Add API client methods for MCP discovery, OAuth login, and callback
- Register MCP API routes on the backend REST API
- Render dynamic input schema for MCP blocks (credentials + tool params)
  in both legacy and new builder CustomNode components
- Nest MCP tool argument values under tool_arguments in hardcodedValues
- Display tool name with server name prefix in block header
- Add backend route tests for discovery, OAuth login, and callback endpoints
This commit is contained in:
Zamil Majdy
2026-02-09 14:18:59 +04:00
parent 7c9e47ba76
commit df41d02fce
16 changed files with 2155 additions and 216 deletions

View File

@@ -0,0 +1,365 @@
"""
MCP (Model Context Protocol) API routes.
Provides endpoints for MCP tool discovery and OAuth authentication so the
frontend can list available tools on an MCP server before placing a block.
"""
import logging
from typing import Annotated, Any
from urllib.parse import urlparse
import fastapi
from autogpt_libs.auth import get_user_id
from fastapi import Security
from pydantic import BaseModel, Field
from backend.blocks.mcp.client import MCPClient, MCPClientError
from backend.blocks.mcp.oauth import MCPOAuthHandler
from backend.data.model import OAuth2Credentials
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.providers import ProviderName
from backend.util.request import HTTPClientError, Requests
from backend.util.settings import Settings
logger = logging.getLogger(__name__)
settings = Settings()
router = fastapi.APIRouter(tags=["mcp"])
creds_manager = IntegrationCredentialsManager()
# ====================== Tool Discovery ====================== #
class DiscoverToolsRequest(BaseModel):
"""Request to discover tools on an MCP server."""
server_url: str = Field(description="URL of the MCP server")
auth_token: str | None = Field(
default=None,
description="Optional Bearer token for authenticated MCP servers",
)
class MCPToolResponse(BaseModel):
"""A single MCP tool returned by discovery."""
name: str
description: str
input_schema: dict[str, Any]
class DiscoverToolsResponse(BaseModel):
"""Response containing the list of tools available on an MCP server."""
tools: list[MCPToolResponse]
server_name: str | None = None
protocol_version: str | None = None
@router.post(
"/discover-tools",
summary="Discover available tools on an MCP server",
response_model=DiscoverToolsResponse,
)
async def discover_tools(
request: DiscoverToolsRequest,
user_id: Annotated[str, Security(get_user_id)],
) -> DiscoverToolsResponse:
"""
Connect to an MCP server and return its available tools.
If the user has a stored MCP credential for this server URL, it will be
used automatically — no need to pass an explicit auth token.
"""
auth_token = request.auth_token
# Auto-use stored MCP credential when no explicit token is provided
if not auth_token:
try:
mcp_creds = await creds_manager.store.get_creds_by_provider(
user_id, str(ProviderName.MCP)
)
for cred in mcp_creds:
if (
isinstance(cred, OAuth2Credentials)
and cred.metadata.get("mcp_server_url") == request.server_url
):
auth_token = cred.access_token.get_secret_value()
break
except Exception:
logger.debug("Could not look up stored MCP credentials", exc_info=True)
try:
client = MCPClient(
request.server_url,
auth_token=auth_token,
trusted_origins=[request.server_url],
)
init_result = await client.initialize()
tools = await client.list_tools()
return DiscoverToolsResponse(
tools=[
MCPToolResponse(
name=t.name,
description=t.description,
input_schema=t.input_schema,
)
for t in tools
],
server_name=init_result.get("serverInfo", {}).get("name"),
protocol_version=init_result.get("protocolVersion"),
)
except HTTPClientError as e:
if e.status_code in (401, 403):
raise fastapi.HTTPException(
status_code=401,
detail="This MCP server requires authentication. "
"Please provide a valid auth token.",
)
raise fastapi.HTTPException(status_code=502, detail=str(e))
except MCPClientError as e:
raise fastapi.HTTPException(status_code=502, detail=str(e))
except Exception as e:
logger.exception("MCP tool discovery failed")
raise fastapi.HTTPException(
status_code=502,
detail=f"Failed to connect to MCP server: {str(e)}",
)
# ======================== OAuth Flow ======================== #
class MCPOAuthLoginRequest(BaseModel):
"""Request to start an OAuth flow for an MCP server."""
server_url: str = Field(description="URL of the MCP server that requires OAuth")
class MCPOAuthLoginResponse(BaseModel):
"""Response with the OAuth login URL for the user to authenticate."""
login_url: str
state_token: str
@router.post(
"/oauth/login",
summary="Initiate OAuth login for an MCP server",
)
async def mcp_oauth_login(
request: MCPOAuthLoginRequest,
user_id: Annotated[str, Security(get_user_id)],
) -> MCPOAuthLoginResponse:
"""
Discover OAuth metadata from the MCP server and return a login URL.
1. Discovers the protected-resource metadata (RFC 9728)
2. Fetches the authorization server metadata (RFC 8414)
3. Performs Dynamic Client Registration (RFC 7591) if available
4. Returns the authorization URL for the frontend to open in a popup
"""
client = MCPClient(request.server_url, trusted_origins=[request.server_url])
# Step 1: Discover protected-resource metadata (RFC 9728)
try:
protected_resource = await client.discover_auth()
except Exception as e:
raise fastapi.HTTPException(
status_code=502,
detail=f"Failed to discover OAuth metadata: {e}",
)
if not protected_resource or "authorization_servers" not in protected_resource:
raise fastapi.HTTPException(
status_code=400,
detail="This MCP server does not advertise OAuth support. "
"You may need to provide an auth token manually.",
)
auth_server_url = protected_resource["authorization_servers"][0]
resource_url = protected_resource.get("resource", request.server_url)
# Step 2: Discover auth-server metadata (RFC 8414)
try:
metadata = await client.discover_auth_server_metadata(auth_server_url)
except Exception as e:
raise fastapi.HTTPException(
status_code=502,
detail=f"Failed to discover authorization server metadata: {e}",
)
if not metadata or "authorization_endpoint" not in metadata:
raise fastapi.HTTPException(
status_code=502,
detail="Authorization server metadata is missing required endpoints.",
)
authorize_url = metadata["authorization_endpoint"]
token_url = metadata["token_endpoint"]
registration_endpoint = metadata.get("registration_endpoint")
revoke_url = metadata.get("revocation_endpoint")
# Step 3: Dynamic Client Registration (RFC 7591) if available
frontend_base_url = settings.config.frontend_base_url
if not frontend_base_url:
raise fastapi.HTTPException(
status_code=500,
detail="Frontend base URL is not configured.",
)
redirect_uri = f"{frontend_base_url}/auth/integrations/mcp_callback"
client_id = ""
client_secret = ""
if registration_endpoint:
reg_result = await _register_mcp_client(
registration_endpoint, redirect_uri, request.server_url
)
if reg_result:
client_id = reg_result.get("client_id", "")
client_secret = reg_result.get("client_secret", "")
if not client_id:
client_id = "autogpt-platform"
# Step 4: Store state token with OAuth metadata for the callback
scopes = protected_resource.get("scopes_supported", [])
state_token, code_challenge = await creds_manager.store.store_state_token(
user_id,
str(ProviderName.MCP),
scopes,
state_metadata={
"authorize_url": authorize_url,
"token_url": token_url,
"revoke_url": revoke_url,
"resource_url": resource_url,
"server_url": request.server_url,
"client_id": client_id,
"client_secret": client_secret,
},
)
# Step 5: Build and return the login URL
handler = MCPOAuthHandler(
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
authorize_url=authorize_url,
token_url=token_url,
resource_url=resource_url,
)
login_url = handler.get_login_url(
scopes, state_token, code_challenge=code_challenge
)
return MCPOAuthLoginResponse(login_url=login_url, state_token=state_token)
class MCPOAuthCallbackRequest(BaseModel):
"""Request to exchange an OAuth code for tokens."""
code: str = Field(description="Authorization code from OAuth callback")
state_token: str = Field(description="State token for CSRF verification")
class MCPOAuthCallbackResponse(BaseModel):
"""Response after successfully storing OAuth credentials."""
credential_id: str
@router.post(
"/oauth/callback",
summary="Exchange OAuth code for MCP tokens",
)
async def mcp_oauth_callback(
request: MCPOAuthCallbackRequest,
user_id: Annotated[str, Security(get_user_id)],
) -> MCPOAuthCallbackResponse:
"""
Exchange the authorization code for tokens and store the credential.
The frontend calls this after receiving the OAuth code from the popup.
On success, subsequent ``/discover-tools`` calls for the same server URL
will automatically use the stored credential.
"""
valid_state = await creds_manager.store.verify_state_token(
user_id, request.state_token, str(ProviderName.MCP)
)
if not valid_state:
raise fastapi.HTTPException(
status_code=400,
detail="Invalid or expired state token.",
)
meta = valid_state.state_metadata
frontend_base_url = settings.config.frontend_base_url
redirect_uri = f"{frontend_base_url}/auth/integrations/mcp_callback"
handler = MCPOAuthHandler(
client_id=meta["client_id"],
client_secret=meta.get("client_secret", ""),
redirect_uri=redirect_uri,
authorize_url=meta["authorize_url"],
token_url=meta["token_url"],
revoke_url=meta.get("revoke_url"),
resource_url=meta.get("resource_url"),
)
try:
credentials = await handler.exchange_code_for_tokens(
request.code, valid_state.scopes, valid_state.code_verifier
)
except Exception as e:
logger.exception("MCP OAuth token exchange failed")
raise fastapi.HTTPException(
status_code=400,
detail=f"OAuth token exchange failed: {e}",
)
# Enrich credential metadata for future lookup and token refresh
if credentials.metadata is None:
credentials.metadata = {}
credentials.metadata["mcp_server_url"] = meta["server_url"]
credentials.metadata["mcp_client_id"] = meta["client_id"]
credentials.metadata["mcp_client_secret"] = meta.get("client_secret", "")
hostname = urlparse(meta["server_url"]).hostname or meta["server_url"]
credentials.title = f"MCP: {hostname}"
await creds_manager.create(user_id, credentials)
return MCPOAuthCallbackResponse(credential_id=credentials.id)
# ======================== Helpers ======================== #
async def _register_mcp_client(
registration_endpoint: str,
redirect_uri: str,
server_url: str,
) -> dict[str, Any] | None:
"""Attempt Dynamic Client Registration (RFC 7591) with an MCP auth server."""
try:
response = await Requests(raise_for_status=True).post(
registration_endpoint,
json={
"client_name": "AutoGPT Platform",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post",
},
)
data = response.json()
if isinstance(data, dict) and "client_id" in data:
return data
return None
except Exception as e:
logger.warning(f"Dynamic client registration failed for {server_url}: {e}")
return None

View File

@@ -0,0 +1,385 @@
"""Tests for MCP API routes."""
from unittest.mock import AsyncMock, patch
import fastapi
import fastapi.testclient
from autogpt_libs.auth import get_user_id
from backend.api.features.mcp.routes import router
from backend.blocks.mcp.client import MCPClientError, MCPTool
from backend.util.request import HTTPClientError
app = fastapi.FastAPI()
app.include_router(router)
app.dependency_overrides[get_user_id] = lambda: "test-user-id"
client = fastapi.testclient.TestClient(app)
class TestDiscoverTools:
def test_discover_tools_success(self):
mock_tools = [
MCPTool(
name="get_weather",
description="Get weather for a city",
input_schema={
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
),
MCPTool(
name="add_numbers",
description="Add two numbers",
input_schema={
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "number"},
},
},
),
]
with (patch("backend.api.features.mcp.routes.MCPClient") as MockClient,):
instance = MockClient.return_value
instance.initialize = AsyncMock(
return_value={
"protocolVersion": "2025-03-26",
"serverInfo": {"name": "test-server"},
}
)
instance.list_tools = AsyncMock(return_value=mock_tools)
response = client.post(
"/discover-tools",
json={"server_url": "https://mcp.example.com/mcp"},
)
assert response.status_code == 200
data = response.json()
assert len(data["tools"]) == 2
assert data["tools"][0]["name"] == "get_weather"
assert data["tools"][1]["name"] == "add_numbers"
assert data["server_name"] == "test-server"
assert data["protocol_version"] == "2025-03-26"
def test_discover_tools_with_auth_token(self):
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
instance = MockClient.return_value
instance.initialize = AsyncMock(
return_value={"serverInfo": {}, "protocolVersion": "2025-03-26"}
)
instance.list_tools = AsyncMock(return_value=[])
response = client.post(
"/discover-tools",
json={
"server_url": "https://mcp.example.com/mcp",
"auth_token": "my-secret-token",
},
)
assert response.status_code == 200
MockClient.assert_called_once_with(
"https://mcp.example.com/mcp",
auth_token="my-secret-token",
trusted_origins=["https://mcp.example.com/mcp"],
)
def test_discover_tools_auto_uses_stored_credential(self):
"""When no explicit token is given, stored MCP credentials are used."""
from pydantic import SecretStr
from backend.data.model import OAuth2Credentials
stored_cred = OAuth2Credentials(
provider="mcp",
title="MCP: example.com",
access_token=SecretStr("stored-token-123"),
refresh_token=None,
access_token_expires_at=None,
refresh_token_expires_at=None,
scopes=[],
metadata={"mcp_server_url": "https://mcp.example.com/mcp"},
)
with (
patch("backend.api.features.mcp.routes.MCPClient") as MockClient,
patch("backend.api.features.mcp.routes.creds_manager") as mock_cm,
):
mock_cm.store.get_creds_by_provider = AsyncMock(return_value=[stored_cred])
instance = MockClient.return_value
instance.initialize = AsyncMock(
return_value={"serverInfo": {}, "protocolVersion": "2025-03-26"}
)
instance.list_tools = AsyncMock(return_value=[])
response = client.post(
"/discover-tools",
json={"server_url": "https://mcp.example.com/mcp"},
)
assert response.status_code == 200
MockClient.assert_called_once_with(
"https://mcp.example.com/mcp",
auth_token="stored-token-123",
trusted_origins=["https://mcp.example.com/mcp"],
)
def test_discover_tools_mcp_error(self):
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
instance = MockClient.return_value
instance.initialize = AsyncMock(
side_effect=MCPClientError("Connection refused")
)
response = client.post(
"/discover-tools",
json={"server_url": "https://bad-server.example.com/mcp"},
)
assert response.status_code == 502
assert "Connection refused" in response.json()["detail"]
def test_discover_tools_generic_error(self):
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
instance = MockClient.return_value
instance.initialize = AsyncMock(side_effect=Exception("Network timeout"))
response = client.post(
"/discover-tools",
json={"server_url": "https://timeout.example.com/mcp"},
)
assert response.status_code == 502
assert "Failed to connect" in response.json()["detail"]
def test_discover_tools_auth_required(self):
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
instance = MockClient.return_value
instance.initialize = AsyncMock(
side_effect=HTTPClientError("HTTP 401 Error: Unauthorized", 401)
)
response = client.post(
"/discover-tools",
json={"server_url": "https://auth-server.example.com/mcp"},
)
assert response.status_code == 401
assert "requires authentication" in response.json()["detail"]
def test_discover_tools_forbidden(self):
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
instance = MockClient.return_value
instance.initialize = AsyncMock(
side_effect=HTTPClientError("HTTP 403 Error: Forbidden", 403)
)
response = client.post(
"/discover-tools",
json={"server_url": "https://auth-server.example.com/mcp"},
)
assert response.status_code == 401
assert "requires authentication" in response.json()["detail"]
def test_discover_tools_missing_url(self):
response = client.post("/discover-tools", json={})
assert response.status_code == 422
class TestOAuthLogin:
def test_oauth_login_success(self):
with (
patch("backend.api.features.mcp.routes.MCPClient") as MockClient,
patch("backend.api.features.mcp.routes.creds_manager") as mock_cm,
patch("backend.api.features.mcp.routes.settings") as mock_settings,
patch(
"backend.api.features.mcp.routes._register_mcp_client"
) as mock_register,
):
instance = MockClient.return_value
instance.discover_auth = AsyncMock(
return_value={
"authorization_servers": ["https://auth.sentry.io"],
"resource": "https://mcp.sentry.dev/mcp",
"scopes_supported": ["openid"],
}
)
instance.discover_auth_server_metadata = AsyncMock(
return_value={
"authorization_endpoint": "https://auth.sentry.io/authorize",
"token_endpoint": "https://auth.sentry.io/token",
"registration_endpoint": "https://auth.sentry.io/register",
}
)
mock_register.return_value = {
"client_id": "registered-client-id",
"client_secret": "registered-secret",
}
mock_cm.store.store_state_token = AsyncMock(
return_value=("state-token-123", "code-challenge-abc")
)
mock_settings.config.frontend_base_url = "http://localhost:3000"
response = client.post(
"/oauth/login",
json={"server_url": "https://mcp.sentry.dev/mcp"},
)
assert response.status_code == 200
data = response.json()
assert "login_url" in data
assert data["state_token"] == "state-token-123"
assert "auth.sentry.io/authorize" in data["login_url"]
assert "registered-client-id" in data["login_url"]
def test_oauth_login_no_oauth_support(self):
with patch("backend.api.features.mcp.routes.MCPClient") as MockClient:
instance = MockClient.return_value
instance.discover_auth = AsyncMock(return_value=None)
response = client.post(
"/oauth/login",
json={"server_url": "https://simple-server.example.com/mcp"},
)
assert response.status_code == 400
assert "does not advertise OAuth" in response.json()["detail"]
def test_oauth_login_fallback_to_public_client(self):
"""When DCR is unavailable, falls back to default public client ID."""
with (
patch("backend.api.features.mcp.routes.MCPClient") as MockClient,
patch("backend.api.features.mcp.routes.creds_manager") as mock_cm,
patch("backend.api.features.mcp.routes.settings") as mock_settings,
):
instance = MockClient.return_value
instance.discover_auth = AsyncMock(
return_value={
"authorization_servers": ["https://auth.example.com"],
"resource": "https://mcp.example.com/mcp",
}
)
instance.discover_auth_server_metadata = AsyncMock(
return_value={
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
# No registration_endpoint
}
)
mock_cm.store.store_state_token = AsyncMock(
return_value=("state-abc", "challenge-xyz")
)
mock_settings.config.frontend_base_url = "http://localhost:3000"
response = client.post(
"/oauth/login",
json={"server_url": "https://mcp.example.com/mcp"},
)
assert response.status_code == 200
data = response.json()
assert "autogpt-platform" in data["login_url"]
class TestOAuthCallback:
def test_oauth_callback_success(self):
from pydantic import SecretStr
from backend.data.model import OAuth2Credentials
mock_creds = OAuth2Credentials(
provider="mcp",
title=None,
access_token=SecretStr("access-token-xyz"),
refresh_token=None,
access_token_expires_at=None,
refresh_token_expires_at=None,
scopes=[],
metadata={
"mcp_token_url": "https://auth.sentry.io/token",
"mcp_resource_url": "https://mcp.sentry.dev/mcp",
},
)
with (
patch("backend.api.features.mcp.routes.creds_manager") as mock_cm,
patch("backend.api.features.mcp.routes.settings") as mock_settings,
patch("backend.api.features.mcp.routes.MCPOAuthHandler") as MockHandler,
):
mock_settings.config.frontend_base_url = "http://localhost:3000"
# Mock state verification
mock_state = AsyncMock()
mock_state.state_metadata = {
"authorize_url": "https://auth.sentry.io/authorize",
"token_url": "https://auth.sentry.io/token",
"client_id": "test-client-id",
"client_secret": "test-secret",
"server_url": "https://mcp.sentry.dev/mcp",
}
mock_state.scopes = ["openid"]
mock_state.code_verifier = "verifier-123"
mock_cm.store.verify_state_token = AsyncMock(return_value=mock_state)
mock_cm.create = AsyncMock()
handler_instance = MockHandler.return_value
handler_instance.exchange_code_for_tokens = AsyncMock(
return_value=mock_creds
)
response = client.post(
"/oauth/callback",
json={"code": "auth-code-abc", "state_token": "state-token-123"},
)
assert response.status_code == 200
data = response.json()
assert "credential_id" in data
mock_cm.create.assert_called_once()
def test_oauth_callback_invalid_state(self):
with patch("backend.api.features.mcp.routes.creds_manager") as mock_cm:
mock_cm.store.verify_state_token = AsyncMock(return_value=None)
response = client.post(
"/oauth/callback",
json={"code": "auth-code", "state_token": "bad-state"},
)
assert response.status_code == 400
assert "Invalid or expired" in response.json()["detail"]
def test_oauth_callback_token_exchange_fails(self):
with (
patch("backend.api.features.mcp.routes.creds_manager") as mock_cm,
patch("backend.api.features.mcp.routes.settings") as mock_settings,
patch("backend.api.features.mcp.routes.MCPOAuthHandler") as MockHandler,
):
mock_settings.config.frontend_base_url = "http://localhost:3000"
mock_state = AsyncMock()
mock_state.state_metadata = {
"authorize_url": "https://auth.example.com/authorize",
"token_url": "https://auth.example.com/token",
"client_id": "cid",
"server_url": "https://mcp.example.com/mcp",
}
mock_state.scopes = []
mock_state.code_verifier = "v"
mock_cm.store.verify_state_token = AsyncMock(return_value=mock_state)
handler_instance = MockHandler.return_value
handler_instance.exchange_code_for_tokens = AsyncMock(
side_effect=RuntimeError("Token exchange failed")
)
response = client.post(
"/oauth/callback",
json={"code": "bad-code", "state_token": "state"},
)
assert response.status_code == 400
assert "token exchange failed" in response.json()["detail"].lower()

View File

@@ -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"],

View File

@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
/**
* Safely encode a value as JSON for embedding in a script tag.
* Escapes characters that could break out of the script context to prevent XSS.
*/
function safeJsonStringify(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026");
}
// MCP-specific OAuth callback route.
//
// Unlike the generic oauth_callback which relies on window.opener.postMessage,
// this route uses BroadcastChannel as the PRIMARY communication method.
// This is critical because cross-origin OAuth flows (e.g. Sentry → localhost)
// often lose window.opener due to COOP (Cross-Origin-Opener-Policy) headers.
//
// BroadcastChannel works across all same-origin tabs/popups regardless of opener.
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const success = Boolean(code && state);
const message = success
? { success: true, code, state }
: {
success: false,
message: `Missing parameters: ${searchParams.toString()}`,
};
return new NextResponse(
`<!DOCTYPE html>
<html>
<head><title>MCP Sign-in</title></head>
<body style="font-family: system-ui, -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f9fafb;">
<div style="text-align: center; max-width: 400px; padding: 2rem;">
<div id="spinner" style="margin: 0 auto 1rem; width: 32px; height: 32px; border: 3px solid #e5e7eb; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite;"></div>
<p id="status" style="color: #374151; font-size: 16px;">Completing sign-in...</p>
</div>
<style>@keyframes spin { to { transform: rotate(360deg); } }</style>
<script>
(function() {
var msg = ${safeJsonStringify(message)};
var sent = false;
// Method 1: BroadcastChannel (reliable across tabs/popups, no opener needed)
try {
var bc = new BroadcastChannel("mcp_oauth");
bc.postMessage({ type: "mcp_oauth_result", success: msg.success, code: msg.code, state: msg.state, message: msg.message });
bc.close();
sent = true;
} catch(e) { console.warn("BroadcastChannel failed:", e); }
// Method 2: window.opener.postMessage (fallback for same-origin popups)
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(
{ message_type: "mcp_oauth_result", success: msg.success, code: msg.code, state: msg.state, message: msg.message },
window.location.origin
);
sent = true;
}
} catch(e) { console.warn("postMessage failed:", e); }
var statusEl = document.getElementById("status");
var spinnerEl = document.getElementById("spinner");
spinnerEl.style.display = "none";
if (msg.success && sent) {
statusEl.textContent = "Sign-in complete! This window will close.";
statusEl.style.color = "#059669";
setTimeout(function() { window.close(); }, 1500);
} else if (msg.success) {
statusEl.textContent = "Sign-in successful! You can close this tab and return to the builder.";
statusEl.style.color = "#059669";
} else {
statusEl.textContent = "Sign-in failed: " + (msg.message || "Unknown error");
statusEl.style.color = "#dc2626";
}
})();
</script>
</body>
</html>`,
{ headers: { "Content-Type": "text/html" } },
);
}

View File

@@ -47,7 +47,10 @@ export type CustomNode = XYNode<CustomNodeData, "custom">;
export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
({ data, id: nodeId, selected }) => {
const { inputSchema, outputSchema } = useCustomNode({ data, nodeId });
const { inputSchema, outputSchema, isMCPWithTool } = useCustomNode({
data,
nodeId,
});
const isAgent = data.uiType === BlockUIType.AGENT;
@@ -98,6 +101,7 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
jsonSchema={preprocessInputSchema(inputSchema)}
nodeId={nodeId}
uiType={data.uiType}
isMCPWithTool={isMCPWithTool}
className={cn(
"bg-white px-4",
isWebhook && "pointer-events-none opacity-50",

View File

@@ -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;

View File

@@ -3,6 +3,38 @@ import { CustomNodeData } from "./CustomNode";
import { BlockUIType } from "../../../types";
import { useMemo } from "react";
import { mergeSchemaForResolution } from "./helpers";
import { SpecialBlockID } from "@/lib/autogpt-server-api";
/**
* Build a dynamic input schema for MCP blocks.
*
* When a tool has been selected (tool_input_schema is populated), the block
* should render:
* 1. The credentials field (from the static schema)
* 2. The selected tool's input parameters (from tool_input_schema)
*
* Static fields like server_url, selected_tool, available_tools, and
* tool_arguments are hidden because they're pre-configured from the dialog.
*/
function buildMCPInputSchema(
staticSchema: Record<string, any>,
toolInputSchema: Record<string, any>,
): Record<string, any> {
const credentialsProp = staticSchema.properties?.credentials;
const staticRequired = staticSchema.required ?? [];
return {
type: "object",
properties: {
...(credentialsProp ? { credentials: credentialsProp } : {}),
...(toolInputSchema.properties ?? {}),
},
required: [
...staticRequired.filter((r: string) => r === "credentials"),
...(toolInputSchema.required ?? []),
],
};
}
export const useCustomNode = ({
data,
@@ -19,10 +51,18 @@ export const useCustomNode = ({
);
const isAgent = data.uiType === BlockUIType.AGENT;
const isMCPWithTool =
data.block_id === SpecialBlockID.MCP_TOOL &&
!!data.hardcodedValues?.tool_input_schema?.properties;
const currentInputSchema = isAgent
? (data.hardcodedValues.input_schema ?? {})
: data.inputSchema;
: isMCPWithTool
? buildMCPInputSchema(
data.inputSchema,
data.hardcodedValues.tool_input_schema,
)
: data.inputSchema;
const currentOutputSchema = isAgent
? (data.hardcodedValues.output_schema ?? {})
: data.outputSchema;
@@ -54,5 +94,6 @@ export const useCustomNode = ({
return {
inputSchema,
outputSchema,
isMCPWithTool,
};
};

View File

@@ -9,39 +9,71 @@ interface FormCreatorProps {
jsonSchema: RJSFSchema;
nodeId: string;
uiType: BlockUIType;
/** When true the block is an MCP Tool with a selected tool. */
isMCPWithTool?: boolean;
showHandles?: boolean;
className?: string;
}
export const FormCreator: React.FC<FormCreatorProps> = React.memo(
({ jsonSchema, nodeId, uiType, showHandles = true, className }) => {
({
jsonSchema,
nodeId,
uiType,
isMCPWithTool = false,
showHandles = true,
className,
}) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData);
const getHardCodedValues = useNodeStore(
(state) => state.getHardCodedValues,
);
const isAgent = uiType === BlockUIType.AGENT;
const handleChange = ({ formData }: any) => {
if ("credentials" in formData && !formData.credentials?.id) {
delete formData.credentials;
}
const updatedValues =
uiType === BlockUIType.AGENT
? {
...getHardCodedValues(nodeId),
inputs: formData,
}
: formData;
let updatedValues;
if (isAgent) {
updatedValues = {
...getHardCodedValues(nodeId),
inputs: formData,
};
} else if (isMCPWithTool) {
// Separate credentials from tool arguments
const { credentials, ...toolArgs } = formData;
updatedValues = {
...getHardCodedValues(nodeId),
...(credentials ? { credentials } : {}),
tool_arguments: toolArgs,
};
} else {
updatedValues = formData;
}
updateNodeData(nodeId, { hardcodedValues: updatedValues });
};
const hardcodedValues = getHardCodedValues(nodeId);
const initialValues =
uiType === BlockUIType.AGENT
? (hardcodedValues.inputs ?? {})
: hardcodedValues;
let initialValues;
if (isAgent) {
initialValues = hardcodedValues.inputs ?? {};
} else if (isMCPWithTool) {
// Merge credentials + tool_arguments for the combined schema
initialValues = {
...(hardcodedValues.credentials
? { credentials: hardcodedValues.credentials }
: {}),
...(hardcodedValues.tool_arguments ?? {}),
};
} else {
initialValues = hardcodedValues;
}
return (
<div

View File

@@ -1,7 +1,7 @@
import { Button } from "@/components/__legacy__/ui/button";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { beautifyString, cn } from "@/lib/utils";
import React, { ButtonHTMLAttributes } from "react";
import React, { ButtonHTMLAttributes, useCallback, useState } from "react";
import { highlightText } from "./helpers";
import { PlusIcon } from "@phosphor-icons/react";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
@@ -9,6 +9,12 @@ import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { blockDragPreviewStyle } from "./style";
import { useReactFlow } from "@xyflow/react";
import { useNodeStore } from "../../../stores/nodeStore";
import { SpecialBlockID } from "@/lib/autogpt-server-api";
import {
MCPToolDialog,
type MCPToolDialogResult,
} from "@/app/(platform)/build/components/legacy-builder/MCPToolDialog";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
description?: string;
@@ -33,22 +39,51 @@ export const Block: BlockComponent = ({
);
const { setViewport } = useReactFlow();
const { addBlock } = useNodeStore();
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const isMCPBlock = blockData.id === SpecialBlockID.MCP_TOOL;
const addBlockAndCenter = useCallback(
(block: BlockInfo, hardcodedValues?: Record<string, any>) => {
const customNode = addBlock(block, hardcodedValues);
setTimeout(() => {
setViewport(
{
x: -customNode.position.x * 0.8 + window.innerWidth / 2,
y: -customNode.position.y * 0.8 + (window.innerHeight - 400) / 2,
zoom: 0.8,
},
{ duration: 500 },
);
}, 50);
},
[addBlock, setViewport],
);
const handleMCPToolConfirm = useCallback(
(result: MCPToolDialogResult) => {
addBlockAndCenter(blockData, {
server_url: result.serverUrl,
server_name: result.serverName,
selected_tool: result.selectedTool,
tool_input_schema: result.toolInputSchema,
available_tools: result.availableTools,
});
setMcpDialogOpen(false);
},
[addBlockAndCenter, blockData],
);
const handleClick = () => {
const customNode = addBlock(blockData);
setTimeout(() => {
setViewport(
{
x: -customNode.position.x * 0.8 + window.innerWidth / 2,
y: -customNode.position.y * 0.8 + (window.innerHeight - 400) / 2,
zoom: 0.8,
},
{ duration: 500 },
);
}, 50);
if (isMCPBlock) {
setMcpDialogOpen(true);
return;
}
addBlockAndCenter(blockData);
};
const handleDragStart = (e: React.DragEvent<HTMLButtonElement>) => {
if (isMCPBlock) return;
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/reactflow", JSON.stringify(blockData));
@@ -71,46 +106,56 @@ export const Block: BlockComponent = ({
: undefined;
return (
<Button
draggable={true}
data-id={blockDataId}
className={cn(
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start space-x-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",
className,
)}
onDragStart={handleDragStart}
onClick={handleClick}
{...rest}
>
<div className="flex flex-1 flex-col items-start gap-0.5">
{title && (
<span
className={cn(
"line-clamp-1 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 group-disabled:text-zinc-400",
)}
>
{highlightText(beautifyString(title), highlightedText)}
</span>
)}
{description && (
<span
className={cn(
"line-clamp-1 font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
)}
>
{highlightText(description, highlightedText)}
</span>
)}
</div>
<div
<>
<Button
draggable={!isMCPBlock}
data-id={blockDataId}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-[0.5rem] bg-zinc-700 group-disabled:bg-zinc-400",
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start space-x-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",
isMCPBlock && "hover:cursor-pointer",
className,
)}
onDragStart={handleDragStart}
onClick={handleClick}
{...rest}
>
<PlusIcon className="h-5 w-5 text-zinc-50" />
</div>
</Button>
<div className="flex flex-1 flex-col items-start gap-0.5">
{title && (
<span
className={cn(
"line-clamp-1 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 group-disabled:text-zinc-400",
)}
>
{highlightText(beautifyString(title), highlightedText)}
</span>
)}
{description && (
<span
className={cn(
"line-clamp-1 font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
)}
>
{highlightText(description, highlightedText)}
</span>
)}
</div>
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-[0.5rem] bg-zinc-700 group-disabled:bg-zinc-400",
)}
>
<PlusIcon className="h-5 w-5 text-zinc-50" />
</div>
</Button>
{isMCPBlock && (
<MCPToolDialog
open={mcpDialogOpen}
onClose={() => setMcpDialogOpen(false)}
onConfirm={handleMCPToolConfirm}
/>
)}
</>
);
};

View File

@@ -29,6 +29,10 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { GraphMeta } from "@/lib/autogpt-server-api";
import {
MCPToolDialog,
type MCPToolDialogResult,
} from "@/app/(platform)/build/components/legacy-builder/MCPToolDialog";
import jaro from "jaro-winkler";
import { getV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { okData } from "@/app/api/helpers";
@@ -94,6 +98,7 @@ export function BlocksControl({
const [searchQuery, setSearchQuery] = useState("");
const deferredSearchQuery = useDeferredValue(searchQuery);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const blocks = useSearchableBlocks(_blocks);
@@ -186,11 +191,31 @@ export function BlocksControl({
setSelectedCategory(null);
}, []);
const handleMCPToolConfirm = useCallback(
(result: MCPToolDialogResult) => {
addBlock(SpecialBlockID.MCP_TOOL, "MCPToolBlock", {
server_url: result.serverUrl,
server_name: result.serverName,
selected_tool: result.selectedTool,
tool_input_schema: result.toolInputSchema,
available_tools: result.availableTools,
});
setMcpDialogOpen(false);
},
[addBlock],
);
// Handler to add a block, fetching graph data on-demand for agent blocks
const handleAddBlock = useCallback(
async (block: _Block & { notAvailable: string | null }) => {
if (block.notAvailable) return;
// For MCP blocks, open the configuration dialog instead of placing directly
if (block.id === SpecialBlockID.MCP_TOOL) {
setMcpDialogOpen(true);
return;
}
// For agent blocks, fetch the full graph to get schemas
if (block.uiType === BlockUIType.AGENT && block.hardcodedValues) {
const graphID = block.hardcodedValues.graph_id as string;
@@ -230,162 +255,179 @@ export function BlocksControl({
}, [blocks]);
return (
<Popover
open={pinBlocksPopover ? true : undefined}
onOpenChange={(open) => open || resetFilters()}
>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
data-id="blocks-control-popover-trigger"
data-testid="blocks-control-blocks-button"
name="Blocks"
className="dark:hover:bg-slate-800"
>
<IconToyBrick />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right">Blocks</TooltipContent>
</Tooltip>
<PopoverContent
side="right"
sideOffset={22}
align="start"
className="absolute -top-3 w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
data-id="blocks-control-popover-content"
<>
<Popover
open={pinBlocksPopover ? true : undefined}
onOpenChange={(open) => open || resetFilters()}
>
<Card className="p-3 pb-0 dark:bg-slate-900">
<CardHeader className="flex flex-col gap-x-8 gap-y-1 p-3 px-2">
<div className="items-center justify-between">
<Label
htmlFor="search-blocks"
className="whitespace-nowrap text-base font-bold text-black dark:text-white 2xl:text-xl"
data-id="blocks-control-label"
data-testid="blocks-control-blocks-label"
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
data-id="blocks-control-popover-trigger"
data-testid="blocks-control-blocks-button"
name="Blocks"
className="dark:hover:bg-slate-800"
>
Blocks
</Label>
</div>
<div className="relative flex items-center">
<MagnifyingGlassIcon className="absolute m-2 h-5 w-5 text-gray-500 dark:text-gray-400" />
<Input
id="search-blocks"
type="text"
placeholder="Search blocks"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="rounded-lg px-8 py-5 dark:bg-slate-800 dark:text-white"
data-id="blocks-control-search-input"
autoComplete="off"
/>
</div>
<div
className="mt-2 flex flex-wrap gap-2"
data-testid="blocks-categories-list"
>
{categories.map((category) => {
const color = getPrimaryCategoryColor([
{ category: category || "All", description: "" },
]);
const colorClass =
selectedCategory === category ? `${color}` : "";
return (
<div
key={category}
data-testid="blocks-category"
role="button"
className={`cursor-pointer rounded-xl border px-2 py-2 text-xs font-medium dark:border-slate-700 dark:text-white ${colorClass}`}
onClick={() =>
setSelectedCategory(
selectedCategory === category ? null : category,
)
}
>
{beautifyString((category || "All").toLowerCase())}
</div>
);
})}
</div>
</CardHeader>
<CardContent className="overflow-scroll border-t border-t-gray-200 p-0 dark:border-t-slate-700">
<ScrollArea
className="h-[60vh] w-full"
data-id="blocks-control-scroll-area"
>
{filteredAvailableBlocks.map((block) => (
<Card
key={block.uiKey || block.id}
className={`m-2 my-4 flex h-20 shadow-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 ${
block.notAvailable
? "cursor-not-allowed opacity-50"
: "cursor-move hover:shadow-lg"
}`}
data-id={`block-card-${block.id}`}
draggable={!block.notAvailable}
onDragStart={(e) => {
if (block.notAvailable) return;
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData(
"application/reactflow",
JSON.stringify({
blockId: block.id,
blockName: block.name,
hardcodedValues: block?.hardcodedValues || {},
}),
);
}}
onClick={() => handleAddBlock(block)}
title={block.notAvailable ?? undefined}
<IconToyBrick />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right">Blocks</TooltipContent>
</Tooltip>
<PopoverContent
side="right"
sideOffset={22}
align="start"
className="absolute -top-3 w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
data-id="blocks-control-popover-content"
>
<Card className="p-3 pb-0 dark:bg-slate-900">
<CardHeader className="flex flex-col gap-x-8 gap-y-1 p-3 px-2">
<div className="items-center justify-between">
<Label
htmlFor="search-blocks"
className="whitespace-nowrap text-base font-bold text-black dark:text-white 2xl:text-xl"
data-id="blocks-control-label"
data-testid="blocks-control-blocks-label"
>
<div
className={`-ml-px h-full w-3 rounded-l-xl ${getPrimaryCategoryColor(block.categories)}`}
></div>
<div className="mx-3 flex flex-1 items-center justify-between">
<div className="mr-2 min-w-0">
<span
className="block truncate pb-1 text-sm font-semibold dark:text-white"
data-id={`block-name-${block.id}`}
data-type={block.uiType}
data-testid={`block-name-${block.id}`}
>
<TextRenderer
value={beautifyString(block.name).replace(
/ Block$/,
"",
)}
truncateLengthLimit={45}
/>
</span>
<span
className="block break-all text-xs font-normal text-gray-500 dark:text-gray-400"
data-testid={`block-description-${block.id}`}
>
<TextRenderer
value={block.description}
truncateLengthLimit={165}
/>
</span>
</div>
Blocks
</Label>
</div>
<div className="relative flex items-center">
<MagnifyingGlassIcon className="absolute m-2 h-5 w-5 text-gray-500 dark:text-gray-400" />
<Input
id="search-blocks"
type="text"
placeholder="Search blocks"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="rounded-lg px-8 py-5 dark:bg-slate-800 dark:text-white"
data-id="blocks-control-search-input"
autoComplete="off"
/>
</div>
<div
className="mt-2 flex flex-wrap gap-2"
data-testid="blocks-categories-list"
>
{categories.map((category) => {
const color = getPrimaryCategoryColor([
{ category: category || "All", description: "" },
]);
const colorClass =
selectedCategory === category ? `${color}` : "";
return (
<div
className="flex flex-shrink-0 items-center gap-1"
data-id={`block-tooltip-${block.id}`}
data-testid={`block-add`}
key={category}
data-testid="blocks-category"
role="button"
className={`cursor-pointer rounded-xl border px-2 py-2 text-xs font-medium dark:border-slate-700 dark:text-white ${colorClass}`}
onClick={() =>
setSelectedCategory(
selectedCategory === category ? null : category,
)
}
>
<PlusIcon className="h-6 w-6 rounded-lg bg-gray-200 stroke-black stroke-[0.5px] p-1 dark:bg-gray-700 dark:stroke-white" />
{beautifyString((category || "All").toLowerCase())}
</div>
</div>
</Card>
))}
</ScrollArea>
</CardContent>
</Card>
</PopoverContent>
</Popover>
);
})}
</div>
</CardHeader>
<CardContent className="overflow-scroll border-t border-t-gray-200 p-0 dark:border-t-slate-700">
<ScrollArea
className="h-[60vh] w-full"
data-id="blocks-control-scroll-area"
>
{filteredAvailableBlocks.map((block) => (
<Card
key={block.uiKey || block.id}
className={`m-2 my-4 flex h-20 shadow-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 ${
block.notAvailable
? "cursor-not-allowed opacity-50"
: block.id === SpecialBlockID.MCP_TOOL
? "cursor-pointer hover:shadow-lg"
: "cursor-move hover:shadow-lg"
}`}
data-id={`block-card-${block.id}`}
draggable={
!block.notAvailable &&
block.id !== SpecialBlockID.MCP_TOOL
}
onDragStart={(e) => {
if (
block.notAvailable ||
block.id === SpecialBlockID.MCP_TOOL
)
return;
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData(
"application/reactflow",
JSON.stringify({
blockId: block.id,
blockName: block.name,
hardcodedValues: block?.hardcodedValues || {},
}),
);
}}
onClick={() => handleAddBlock(block)}
title={block.notAvailable ?? undefined}
>
<div
className={`-ml-px h-full w-3 rounded-l-xl ${getPrimaryCategoryColor(block.categories)}`}
></div>
<div className="mx-3 flex flex-1 items-center justify-between">
<div className="mr-2 min-w-0">
<span
className="block truncate pb-1 text-sm font-semibold dark:text-white"
data-id={`block-name-${block.id}`}
data-type={block.uiType}
data-testid={`block-name-${block.id}`}
>
<TextRenderer
value={beautifyString(block.name).replace(
/ Block$/,
"",
)}
truncateLengthLimit={45}
/>
</span>
<span
className="block break-all text-xs font-normal text-gray-500 dark:text-gray-400"
data-testid={`block-description-${block.id}`}
>
<TextRenderer
value={block.description}
truncateLengthLimit={165}
/>
</span>
</div>
<div
className="flex flex-shrink-0 items-center gap-1"
data-id={`block-tooltip-${block.id}`}
data-testid={`block-add`}
>
<PlusIcon className="h-6 w-6 rounded-lg bg-gray-200 stroke-black stroke-[0.5px] p-1 dark:bg-gray-700 dark:stroke-white" />
</div>
</div>
</Card>
))}
</ScrollArea>
</CardContent>
</Card>
</PopoverContent>
</Popover>
<MCPToolDialog
open={mcpDialogOpen}
onClose={() => setMcpDialogOpen(false)}
onConfirm={handleMCPToolConfirm}
/>
</>
);
}

View File

@@ -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);

View File

@@ -0,0 +1,610 @@
"use client";
import React, { useState, useCallback, useRef, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/__legacy__/ui/dialog";
import { Button } from "@/components/__legacy__/ui/button";
import { Input } from "@/components/__legacy__/ui/input";
import { Label } from "@/components/__legacy__/ui/label";
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
import { Badge } from "@/components/__legacy__/ui/badge";
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import type { MCPTool } from "@/lib/autogpt-server-api";
import { CaretDown } from "@phosphor-icons/react";
export type MCPToolDialogResult = {
serverUrl: string;
serverName: string | null;
selectedTool: string;
toolInputSchema: Record<string, any>;
availableTools: Record<string, any>;
};
interface MCPToolDialogProps {
open: boolean;
onClose: () => void;
onConfirm: (result: MCPToolDialogResult) => void;
}
type DialogStep = "url" | "tool";
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const STORAGE_KEY = "mcp_last_server_url";
export function MCPToolDialog({
open,
onClose,
onConfirm,
}: MCPToolDialogProps) {
const api = useBackendAPI();
const [step, setStep] = useState<DialogStep>("url");
const [serverUrl, setServerUrl] = useState("");
const [tools, setTools] = useState<MCPTool[]>([]);
const [serverName, setServerName] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [authRequired, setAuthRequired] = useState(false);
const [oauthLoading, setOauthLoading] = useState(false);
const [showManualToken, setShowManualToken] = useState(false);
const [manualToken, setManualToken] = useState("");
const [selectedTool, setSelectedTool] = useState<MCPTool | null>(null);
const oauthLoadingRef = useRef(false);
const stateTokenRef = useRef<string | null>(null);
const broadcastChannelRef = useRef<BroadcastChannel | null>(null);
const messageHandlerRef = useRef<((event: MessageEvent) => void) | null>(
null,
);
const oauthHandledRef = useRef(false);
const autoConnectAttemptedRef = useRef(false);
// Attempt auto-connect when dialog opens with a stored server URL
useEffect(() => {
if (!open) {
autoConnectAttemptedRef.current = false;
return;
}
const lastUrl = localStorage.getItem(STORAGE_KEY);
if (!lastUrl || autoConnectAttemptedRef.current) return;
autoConnectAttemptedRef.current = true;
setServerUrl(lastUrl);
setLoading(true);
api
.mcpDiscoverTools(lastUrl)
.then((result) => {
setTools(result.tools);
setServerName(result.server_name);
setStep("tool");
})
.catch(() => {
// Stored credential expired or server changed — stay on URL step
})
.finally(() => {
setLoading(false);
});
}, [open, api]);
// Clean up listeners on unmount
useEffect(() => {
return () => {
if (messageHandlerRef.current) {
window.removeEventListener("message", messageHandlerRef.current);
}
if (broadcastChannelRef.current) {
broadcastChannelRef.current.close();
}
};
}, []);
const cleanupOAuthListeners = useCallback(() => {
if (messageHandlerRef.current) {
window.removeEventListener("message", messageHandlerRef.current);
messageHandlerRef.current = null;
}
if (broadcastChannelRef.current) {
broadcastChannelRef.current.close();
broadcastChannelRef.current = null;
}
setOauthLoading(false);
oauthLoadingRef.current = false;
oauthHandledRef.current = false;
}, []);
const reset = useCallback(() => {
cleanupOAuthListeners();
setStep("url");
setServerUrl("");
setManualToken("");
setTools([]);
setServerName(null);
setLoading(false);
setError(null);
setAuthRequired(false);
setShowManualToken(false);
setSelectedTool(null);
stateTokenRef.current = null;
}, [cleanupOAuthListeners]);
const handleClose = useCallback(() => {
reset();
onClose();
}, [reset, onClose]);
const discoverTools = useCallback(
async (url: string, authToken?: string) => {
setLoading(true);
setError(null);
try {
const result = await api.mcpDiscoverTools(url, authToken);
localStorage.setItem(STORAGE_KEY, url);
setTools(result.tools);
setServerName(result.server_name);
setAuthRequired(false);
setShowManualToken(false);
setStep("tool");
} catch (e: any) {
if (e?.status === 401 || e?.status === 403) {
setAuthRequired(true);
setError(null);
} else {
const message =
e?.message || e?.detail || "Failed to connect to MCP server";
setError(
typeof message === "string" ? message : JSON.stringify(message),
);
}
} finally {
setLoading(false);
}
},
[api],
);
const handleDiscoverTools = useCallback(() => {
if (!serverUrl.trim()) return;
discoverTools(serverUrl.trim(), manualToken.trim() || undefined);
}, [serverUrl, manualToken, discoverTools]);
const handleOAuthResult = useCallback(
async (data: {
success: boolean;
code?: string;
state?: string;
message?: string;
}) => {
// Prevent double-handling (BroadcastChannel + postMessage may both fire)
if (oauthHandledRef.current) return;
oauthHandledRef.current = true;
if (!data.success) {
setError(data.message || "OAuth authentication failed.");
cleanupOAuthListeners();
return;
}
cleanupOAuthListeners();
setAuthRequired(false);
// Exchange code for tokens (stored server-side)
setLoading(true);
try {
await api.mcpOAuthCallback(data.code!, stateTokenRef.current!);
// Retry discovery — backend auto-uses stored credential
const result = await api.mcpDiscoverTools(serverUrl.trim());
localStorage.setItem(STORAGE_KEY, serverUrl.trim());
setTools(result.tools);
setServerName(result.server_name);
setStep("tool");
} catch (e: any) {
const message = e?.message || e?.detail || "Failed to complete sign-in";
setError(
typeof message === "string" ? message : JSON.stringify(message),
);
} finally {
setLoading(false);
}
},
[api, serverUrl, cleanupOAuthListeners],
);
const handleOAuthSignIn = useCallback(async () => {
if (!serverUrl.trim()) return;
setError(null);
oauthHandledRef.current = false;
// Open popup SYNCHRONOUSLY (before async call) to avoid browser popup blockers
const width = 500;
const height = 700;
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
const popup = window.open(
"about:blank",
"mcp_oauth",
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes`,
);
setOauthLoading(true);
oauthLoadingRef.current = true;
try {
const { login_url, state_token } = await api.mcpOAuthLogin(
serverUrl.trim(),
);
stateTokenRef.current = state_token;
if (popup && !popup.closed) {
popup.location.href = login_url;
} else {
// Popup was blocked — open in new tab as fallback
window.open(login_url, "_blank");
}
// Listener 1: BroadcastChannel (works even when window.opener is null)
const bc = new BroadcastChannel("mcp_oauth");
bc.onmessage = (event) => {
if (event.data?.type === "mcp_oauth_result") {
handleOAuthResult(event.data);
}
};
broadcastChannelRef.current = bc;
// Listener 2: window.postMessage (fallback)
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.message_type === "mcp_oauth_result") {
handleOAuthResult(event.data);
}
};
messageHandlerRef.current = handleMessage;
window.addEventListener("message", handleMessage);
// Timeout
setTimeout(() => {
if (oauthLoadingRef.current) {
cleanupOAuthListeners();
setError("OAuth sign-in timed out. Please try again.");
}
}, OAUTH_TIMEOUT_MS);
} catch (e: any) {
if (popup && !popup.closed) popup.close();
// If server doesn't support OAuth → show manual token entry
if (e?.status === 400) {
setShowManualToken(true);
setError(
"This server does not support OAuth sign-in. Please enter a token manually.",
);
} else {
const message = e?.message || "Failed to initiate sign-in";
setError(
typeof message === "string" ? message : JSON.stringify(message),
);
}
cleanupOAuthListeners();
}
}, [api, serverUrl, handleOAuthResult, cleanupOAuthListeners]);
const handleConfirm = useCallback(() => {
if (!selectedTool) return;
const availableTools: Record<string, any> = {};
for (const t of tools) {
availableTools[t.name] = {
description: t.description,
input_schema: t.input_schema,
};
}
onConfirm({
serverUrl: serverUrl.trim(),
serverName,
selectedTool: selectedTool.name,
toolInputSchema: selectedTool.input_schema,
availableTools,
});
reset();
}, [selectedTool, tools, serverUrl, onConfirm, reset]);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{step === "url"
? "Connect to MCP Server"
: `Select a Tool${serverName ? `${serverName}` : ""}`}
</DialogTitle>
<DialogDescription>
{step === "url"
? "Enter the URL of an MCP server to discover its available tools."
: `Found ${tools.length} tool${tools.length !== 1 ? "s" : ""}. Select one to add to your agent.`}
</DialogDescription>
</DialogHeader>
{step === "url" && (
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label htmlFor="mcp-server-url">Server URL</Label>
<Input
id="mcp-server-url"
type="url"
placeholder="https://mcp.example.com/mcp"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDiscoverTools()}
autoFocus
/>
</div>
{/* Auth required: show sign-in panel */}
{authRequired && (
<div className="flex flex-col items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
This server requires authentication
</p>
<Button
onClick={handleOAuthSignIn}
disabled={oauthLoading || loading}
className="w-full"
>
{oauthLoading ? (
<span className="flex items-center gap-2">
<LoadingSpinner className="size-4" />
Waiting for sign-in...
</span>
) : (
"Sign in"
)}
</Button>
{!showManualToken && (
<button
onClick={() => setShowManualToken(true)}
className="text-xs text-gray-500 underline hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
or enter a token manually
</button>
)}
</div>
)}
{/* Manual token entry — only visible when expanded */}
{showManualToken && (
<div className="flex flex-col gap-2">
<Label htmlFor="mcp-auth-token" className="text-sm">
Bearer Token
</Label>
<Input
id="mcp-auth-token"
type="password"
placeholder="Paste your auth token here"
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDiscoverTools()}
autoFocus
/>
</div>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
)}
{step === "tool" && (
<ScrollArea className="max-h-[50vh] py-2">
<div className="flex flex-col gap-2 pr-3">
{tools.map((tool) => (
<MCPToolCard
key={tool.name}
tool={tool}
selected={selectedTool?.name === tool.name}
onSelect={() => setSelectedTool(tool)}
/>
))}
</div>
</ScrollArea>
)}
<DialogFooter>
{step === "tool" && (
<Button
variant="outline"
onClick={() => {
setStep("url");
setSelectedTool(null);
}}
>
Back
</Button>
)}
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
{step === "url" && (
<Button
onClick={handleDiscoverTools}
disabled={!serverUrl.trim() || loading || oauthLoading}
>
{loading ? (
<span className="flex items-center gap-2">
<LoadingSpinner className="size-4" />
Connecting...
</span>
) : (
"Discover Tools"
)}
</Button>
)}
{step === "tool" && (
<Button onClick={handleConfirm} disabled={!selectedTool}>
Add Block
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// --------------- Tool Card Component --------------- //
/** Truncate a description to a reasonable length for the collapsed view. */
function truncateDescription(text: string, maxLen = 120): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen).trimEnd() + "…";
}
/** Pretty-print a JSON Schema type for a parameter. */
function schemaTypeLabel(schema: Record<string, any>): string {
if (schema.type) return schema.type;
if (schema.anyOf)
return schema.anyOf.map((s: any) => s.type ?? "any").join(" | ");
if (schema.oneOf)
return schema.oneOf.map((s: any) => s.type ?? "any").join(" | ");
return "any";
}
function MCPToolCard({
tool,
selected,
onSelect,
}: {
tool: MCPTool;
selected: boolean;
onSelect: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const properties = tool.input_schema?.properties ?? {};
const required = new Set<string>(tool.input_schema?.required ?? []);
const paramNames = Object.keys(properties);
// Strip XML-like tags and hints from description for cleaner display
const cleanDescription = (tool.description ?? "")
.replace(/<[^>]+>[^<]*<\/[^>]+>/g, "")
.replace(/<[^>]+>/g, "")
.trim();
return (
<button
onClick={onSelect}
className={`group flex flex-col rounded-lg border text-left transition-colors ${
selected
? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50 dark:border-slate-700 dark:hover:border-slate-600 dark:hover:bg-slate-800"
}`}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 pb-1 pt-3">
<span className="flex-1 text-sm font-semibold dark:text-white">
{tool.name}
</span>
{paramNames.length > 0 && (
<Badge variant="secondary" className="text-[10px]">
{paramNames.length} param{paramNames.length !== 1 ? "s" : ""}
</Badge>
)}
</div>
{/* Description (collapsed: truncated) */}
{cleanDescription && (
<p className="px-3 pb-1 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
{expanded ? cleanDescription : truncateDescription(cleanDescription)}
</p>
)}
{/* Parameter badges (collapsed view) */}
{!expanded && paramNames.length > 0 && (
<div className="flex flex-wrap gap-1 px-3 pb-2">
{paramNames.slice(0, 6).map((name) => (
<Badge
key={name}
variant="outline"
className="text-[10px] font-normal"
>
{name}
{required.has(name) && (
<span className="ml-0.5 text-red-400">*</span>
)}
</Badge>
))}
{paramNames.length > 6 && (
<Badge variant="outline" className="text-[10px] font-normal">
+{paramNames.length - 6} more
</Badge>
)}
</div>
)}
{/* Expanded: full parameter details */}
{expanded && paramNames.length > 0 && (
<div className="mx-3 mb-2 rounded border border-gray-100 bg-gray-50/50 dark:border-slate-700 dark:bg-slate-800/50">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-100 dark:border-slate-700">
<th className="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">
Parameter
</th>
<th className="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">
Type
</th>
<th className="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">
Description
</th>
</tr>
</thead>
<tbody>
{paramNames.map((name) => {
const prop = properties[name] ?? {};
return (
<tr
key={name}
className="border-b border-gray-50 last:border-0 dark:border-slate-700/50"
>
<td className="px-2 py-1 font-mono text-[11px] text-gray-700 dark:text-gray-300">
{name}
{required.has(name) && (
<span className="ml-0.5 text-red-400">*</span>
)}
</td>
<td className="px-2 py-1 text-gray-500 dark:text-gray-400">
{schemaTypeLabel(prop)}
</td>
<td className="max-w-[200px] truncate px-2 py-1 text-gray-500 dark:text-gray-400">
{prop.description ?? "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Toggle details */}
{(paramNames.length > 0 || cleanDescription.length > 120) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setExpanded((prev) => !prev);
}}
className="flex w-full items-center justify-center gap-1 border-t border-gray-100 py-1.5 text-[10px] text-gray-400 hover:text-gray-600 dark:border-slate-700 dark:text-gray-500 dark:hover:text-gray-300"
>
{expanded ? "Hide details" : "Show details"}
<CaretDown
className={`h-3 w-3 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</button>
)}
</button>
);
}

View File

@@ -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" },

View File

@@ -33,6 +33,7 @@ import type {
GraphMeta,
GraphUpdateable,
HostScopedCredentials,
MCPDiscoverToolsResponse,
LibraryAgent,
LibraryAgentID,
LibraryAgentPreset,
@@ -792,6 +793,38 @@ export default class BackendAPI {
return this._request("POST", "/otto/ask", query);
}
////////////////////////////////////////
///////////// MCP FUNCTIONS ////////////
////////////////////////////////////////
async mcpDiscoverTools(
serverUrl: string,
authToken?: string,
): Promise<MCPDiscoverToolsResponse> {
return this._request("POST", "/mcp/discover-tools", {
server_url: serverUrl,
auth_token: authToken || null,
});
}
async mcpOAuthLogin(
serverUrl: string,
): Promise<{ login_url: string; state_token: string }> {
return this._request("POST", "/mcp/oauth/login", {
server_url: serverUrl,
});
}
async mcpOAuthCallback(
code: string,
stateToken: string,
): Promise<{ credential_id: string }> {
return this._request("POST", "/mcp/oauth/callback", {
code,
state_token: stateToken,
});
}
////////////////////////////////////////
////////// INTERNAL FUNCTIONS //////////
////////////////////////////////////////

View File

@@ -753,10 +753,23 @@ export enum BlockUIType {
export enum SpecialBlockID {
AGENT = "e189baac-8c20-45a1-94a7-55177ea42565",
MCP_TOOL = "a0a4b1c2-d3e4-4f56-a7b8-c9d0e1f2a3b4",
SMART_DECISION = "3b191d9f-356f-482d-8238-ba04b6d18381",
OUTPUT = "363ae599-353e-4804-937e-b2ee3cef3da4",
}
export type MCPTool = {
name: string;
description: string;
input_schema: Record<string, any>;
};
export type MCPDiscoverToolsResponse = {
tools: MCPTool[];
server_name: string | null;
protocol_version: string | null;
};
export type AnalyticsMetrics = {
metric_name: string;
metric_value: number;