Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
91ae792
feat(copilot): add run_mcp_tool for MCP server discovery and execution
majdyz Feb 26, 2026
93e324a
fix(copilot): add mcp_tools_discovered and mcp_tool_output to openapi…
majdyz Feb 26, 2026
5d38185
fix(copilot): address CodeRabbit review — SSRF protection, error sani…
majdyz Feb 26, 2026
5514cde
fix(copilot): escape {key: value} format placeholder in DEFAULT_SYSTE…
majdyz Feb 27, 2026
02e7483
fix(copilot): improve MCP auth retry flow and run_mcp_tool vs web_fet…
majdyz Feb 27, 2026
e75ed96
fix(copilot): prevent run_mcp_tool hallucination when explicitly requ…
majdyz Feb 27, 2026
abf400c
fix(copilot): address CodeRabbit review — validation, credential safe…
majdyz Feb 27, 2026
7c190b0
fix(copilot): expose MCPToolsDiscoveredResponse and MCPToolOutputResp…
majdyz Feb 27, 2026
2995944
test(copilot): add unit tests for run_mcp_tool
majdyz Feb 27, 2026
3280435
test(copilot): add vitest unit tests for RunMCPTool helpers
majdyz Feb 27, 2026
85b4b4d
fix(copilot): address CodeRabbit review — log redaction and input_sch…
majdyz Feb 27, 2026
29abd90
fix(copilot): reject credential-bearing server_url before processing
majdyz Feb 28, 2026
e71bcf6
fix(copilot): redact server_url in _build_setup_requirements logger
majdyz Feb 28, 2026
b2ee88a
feat(copilot): address autogpt-reviewer should-fix items
majdyz Feb 28, 2026
0bcd95a
fix(copilot): MCP local test fixes — OAuth flow, error messages, cont…
majdyz Feb 28, 2026
37db3ea
fix(copilot): replace registry web_fetch with web_search for finding …
majdyz Feb 28, 2026
44d972c
fix(copilot): add correct registry API endpoint to system prompt
majdyz Feb 28, 2026
c39f642
feat(copilot): show tool argument preview in MCP tool animation + fix…
majdyz Feb 28, 2026
b01a626
fix(copilot): escape {service} and {query} in system prompt — KeyErro…
majdyz Feb 28, 2026
d277e50
fix(copilot): store manual MCP token as credential, fix image max-height
majdyz Feb 28, 2026
6af8781
fix(copilot): address reviewer should-fix items — token safety, arg p…
majdyz Mar 1, 2026
4fcc19c
chore: update openapi.json with POST /v2/mcp/token endpoint
majdyz Mar 1, 2026
5962131
fix(mcp): normalize server_url and return hostname in token endpoint
majdyz Mar 1, 2026
4cc6cd1
fix(copilot): address CodeRabbit comments on MCPSetupCard and openapi…
majdyz Mar 1, 2026
f0f0758
fix(copilot): normalize server_url in run_mcp_tool to match stored cr…
majdyz Mar 1, 2026
b3867d4
fix(copilot): reject query/fragment in server_url, loosen 2xx check, …
majdyz Mar 1, 2026
b3873c1
fix(mcp): normalize server_url in OAuth login to match token endpoint
majdyz Mar 1, 2026
46e8eac
chore: fix openapi.json token field ordering to match backend codegen
majdyz Mar 1, 2026
770b13c
fix(copilot): null-guard setup_info, keep original URL for MCPClient
majdyz Mar 1, 2026
f059ffe
refactor(mcp): extract shared helpers, add SSRF validation and tests
majdyz Mar 1, 2026
641050f
feat(copilot): use generated API client in MCPSetupCard, add tests
majdyz Mar 1, 2026
6ba41ca
fix(mcp): patch auto_lookup_mcp_credential in route tests
majdyz Mar 1, 2026
4f5c966
fix(mcp): use >= tiebreaker in credential lookup, fix ReDoS in parse_url
majdyz Mar 1, 2026
91403ac
fix: replace regex with string check in parse_url to resolve CodeQL R…
majdyz Mar 1, 2026
2fd34c5
fix(copilot): use 2xx range check for OAuth status codes in MCPSetupCard
majdyz Mar 1, 2026
5dd0506
fix: resolve merge conflict with dev (keep both MCP + browser models)
Otto-AGPT Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
ErrorResponse,
ExecutionStartedResponse,
InputValidationErrorResponse,
MCPToolOutputResponse,
MCPToolsDiscoveredResponse,
NeedLoginResponse,
NoResultsResponse,
SetupRequirementsResponse,
Expand Down Expand Up @@ -854,6 +856,8 @@ async def health_check() -> dict:
| BlockOutputResponse
| DocSearchResultsResponse
| DocPageResponse
| MCPToolsDiscoveredResponse
| MCPToolOutputResponse
)


Expand Down
181 changes: 144 additions & 37 deletions autogpt_platform/backend/backend/api/features/mcp/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,24 @@

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 pydantic import BaseModel, Field, SecretStr

from backend.api.features.integrations.router import CredentialsMetaResponse
from backend.blocks.mcp.client import MCPClient, MCPClientError
from backend.blocks.mcp.helpers import (
auto_lookup_mcp_credential,
normalize_mcp_url,
server_host,
)
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.request import HTTPClientError, Requests, validate_url
from backend.util.settings import Settings

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -74,32 +78,20 @@ async def discover_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.
"""
# Validate URL to prevent SSRF — blocks loopback and private IP ranges.
try:
await validate_url(request.server_url, trusted_origins=[])
except ValueError as e:
raise fastapi.HTTPException(status_code=400, detail=f"Invalid server URL: {e}")

auth_token = request.auth_token

# Auto-use stored MCP credential when no explicit token is provided.
if not auth_token:
mcp_creds = await creds_manager.store.get_creds_by_provider(
user_id, ProviderName.MCP.value
best_cred = await auto_lookup_mcp_credential(
user_id, normalize_mcp_url(request.server_url)
)
# Find the freshest credential for this server URL
best_cred: OAuth2Credentials | None = None
for cred in mcp_creds:
if (
isinstance(cred, OAuth2Credentials)
and (cred.metadata or {}).get("mcp_server_url") == request.server_url
):
if best_cred is None or (
(cred.access_token_expires_at or 0)
> (best_cred.access_token_expires_at or 0)
):
best_cred = cred
if best_cred:
# Refresh the token if expired before using it
best_cred = await creds_manager.refresh_if_needed(user_id, best_cred)
logger.info(
f"Using MCP credential {best_cred.id} for {request.server_url}, "
f"expires_at={best_cred.access_token_expires_at}"
)
auth_token = best_cred.access_token.get_secret_value()

client = MCPClient(request.server_url, auth_token=auth_token)
Expand Down Expand Up @@ -134,7 +126,7 @@ async def discover_tools(
],
server_name=(
init_result.get("serverInfo", {}).get("name")
or urlparse(request.server_url).hostname
or server_host(request.server_url)
or "MCP"
),
protocol_version=init_result.get("protocolVersion"),
Expand Down Expand Up @@ -173,7 +165,16 @@ async def mcp_oauth_login(
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)
# Validate URL to prevent SSRF — blocks loopback and private IP ranges.
try:
await validate_url(request.server_url, trusted_origins=[])
except ValueError as e:
raise fastapi.HTTPException(status_code=400, detail=f"Invalid server URL: {e}")

# Normalize the URL so that credentials stored here are matched consistently
# by auto_lookup_mcp_credential (which also uses normalized URLs).
server_url = normalize_mcp_url(request.server_url)
client = MCPClient(server_url)

# Step 1: Discover protected-resource metadata (RFC 9728)
protected_resource = await client.discover_auth()
Expand All @@ -182,7 +183,16 @@ async def mcp_oauth_login(

if protected_resource and protected_resource.get("authorization_servers"):
auth_server_url = protected_resource["authorization_servers"][0]
resource_url = protected_resource.get("resource", request.server_url)
resource_url = protected_resource.get("resource", server_url)

# Validate the auth server URL from metadata to prevent SSRF.
try:
await validate_url(auth_server_url, trusted_origins=[])
except ValueError as e:
raise fastapi.HTTPException(
status_code=400,
detail=f"Invalid authorization server URL in metadata: {e}",
)

# Step 2a: Discover auth-server metadata (RFC 8414)
metadata = await client.discover_auth_server_metadata(auth_server_url)
Expand All @@ -192,7 +202,7 @@ async def mcp_oauth_login(
# Don't assume a resource_url — omitting it lets the auth server choose
# the correct audience for the token (RFC 8707 resource is optional).
resource_url = None
metadata = await client.discover_auth_server_metadata(request.server_url)
metadata = await client.discover_auth_server_metadata(server_url)

if (
not metadata
Expand Down Expand Up @@ -222,12 +232,18 @@ async def mcp_oauth_login(
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", "")
# Validate the registration endpoint to prevent SSRF via metadata.
try:
await validate_url(registration_endpoint, trusted_origins=[])
except ValueError:
pass # Skip registration, fall back to default client_id
else:
reg_result = await _register_mcp_client(
registration_endpoint, redirect_uri, 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"
Expand All @@ -245,7 +261,7 @@ async def mcp_oauth_login(
"token_url": token_url,
"revoke_url": revoke_url,
"resource_url": resource_url,
"server_url": request.server_url,
"server_url": server_url,
"client_id": client_id,
"client_secret": client_secret,
},
Expand Down Expand Up @@ -342,7 +358,7 @@ async def mcp_oauth_callback(
credentials.metadata["mcp_token_url"] = meta["token_url"]
credentials.metadata["mcp_resource_url"] = meta.get("resource_url", "")

hostname = urlparse(meta["server_url"]).hostname or meta["server_url"]
hostname = server_host(meta["server_url"])
credentials.title = f"MCP: {hostname}"

# Remove old MCP credentials for the same server to prevent stale token buildup.
Expand All @@ -357,7 +373,9 @@ async def mcp_oauth_callback(
):
await creds_manager.store.delete_creds_by_id(user_id, old.id)
logger.info(
f"Removed old MCP credential {old.id} for {meta['server_url']}"
"Removed old MCP credential %s for %s",
old.id,
server_host(meta["server_url"]),
)
except Exception:
logger.debug("Could not clean up old MCP credentials", exc_info=True)
Expand All @@ -375,6 +393,93 @@ async def mcp_oauth_callback(
)


# ======================== Bearer Token ======================== #


class MCPStoreTokenRequest(BaseModel):
"""Request to store a bearer token for an MCP server that doesn't support OAuth."""

server_url: str = Field(
description="MCP server URL the token authenticates against"
)
token: SecretStr = Field(
min_length=1, description="Bearer token / API key for the MCP server"
)


@router.post(
"/token",
summary="Store a bearer token for an MCP server",
)
async def mcp_store_token(
request: MCPStoreTokenRequest,
user_id: Annotated[str, Security(get_user_id)],
) -> CredentialsMetaResponse:
"""
Store a manually provided bearer token as an MCP credential.

Used by the Copilot MCPSetupCard when the server doesn't support the MCP
OAuth discovery flow (returns 400 from /oauth/login). Subsequent
``run_mcp_tool`` calls will automatically pick up the token via
``_auto_lookup_credential``.
"""
token = request.token.get_secret_value().strip()
if not token:
raise fastapi.HTTPException(status_code=422, detail="Token must not be blank.")

# Validate URL to prevent SSRF — blocks loopback and private IP ranges.
try:
await validate_url(request.server_url, trusted_origins=[])
except ValueError as e:
raise fastapi.HTTPException(status_code=400, detail=f"Invalid server URL: {e}")

# Normalize URL so trailing-slash variants match existing credentials.
server_url = normalize_mcp_url(request.server_url)
hostname = server_host(server_url)

# Collect IDs of old credentials to clean up after successful create.
old_cred_ids: list[str] = []
try:
old_creds = await creds_manager.store.get_creds_by_provider(
user_id, ProviderName.MCP.value
)
old_cred_ids = [
old.id
for old in old_creds
if isinstance(old, OAuth2Credentials)
and normalize_mcp_url((old.metadata or {}).get("mcp_server_url", ""))
== server_url
]
except Exception:
logger.debug("Could not query old MCP token credentials", exc_info=True)

credentials = OAuth2Credentials(
provider=ProviderName.MCP.value,
title=f"MCP: {hostname}",
access_token=SecretStr(token),
scopes=[],
metadata={"mcp_server_url": server_url},
)
await creds_manager.create(user_id, credentials)

# Only delete old credentials after the new one is safely stored.
for old_id in old_cred_ids:
try:
await creds_manager.store.delete_creds_by_id(user_id, old_id)
except Exception:
logger.debug("Could not clean up old MCP token credential", exc_info=True)

return CredentialsMetaResponse(
id=credentials.id,
provider=credentials.provider,
type=credentials.type,
title=credentials.title,
scopes=credentials.scopes,
username=credentials.username,
host=hostname,
)


# ======================== Helpers ======================== #


Expand All @@ -400,5 +505,7 @@ async def _register_mcp_client(
return data
return None
except Exception as e:
logger.warning(f"Dynamic client registration failed for {server_url}: {e}")
logger.warning(
"Dynamic client registration failed for %s: %s", server_host(server_url), e
)
return None
Loading