mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
## Summary
Enables Otto (the AutoGPT copilot) to connect to any MCP (Model Context
Protocol) server, discover its tools, and execute them — with the same
credential login UI used in the graph builder.
**Why a dedicated `run_mcp_tool` instead of reusing `run_block` +
MCPToolBlock?**
Two blockers make `run_block` unworkable for MCP:
1. **No discovery mode** — `MCPToolBlock` errors with "No tool selected"
when `selected_tool` is empty; the agent can't learn what tools exist
before picking one.
2. **Credential matching bug** — `find_matching_credential()` (the block
execution path) does NOT check MCP server URLs; it would match any
stored MCP OAuth credential regardless of server. The correct
`_credential_is_for_mcp_server()` helper only applies in the graph path.
## Changes
### Backend
- **New `run_mcp_tool` copilot tool** (`run_mcp_tool.py`) — two-stage
flow:
1. `run_mcp_tool(server_url)` → discovers available tools via
`MCPClient.list_tools()`
2. `run_mcp_tool(server_url, tool_name, tool_arguments)` → executes via
`MCPClient.call_tool()`
- Lazy auth: fast DB credential lookup first
(`MCPToolBlock._auto_lookup_credential`); on HTTP 401/403 with no stored
creds, returns `SetupRequirementsResponse` so the frontend renders the
existing CredentialsGroupedView OAuth login card
- **New response models** in `models.py`: `MCPToolsDiscoveredResponse`,
`MCPToolOutputResponse`, `MCPToolInfo`
- **Exclude MCPToolBlock** from `find_block` / `run_block`
(`COPILOT_EXCLUDED_BLOCK_TYPES`)
- **System prompt update** — MCP section with two-step flow,
`input_schema` guidance, auth-wait instruction, and registry URL
(`registry.modelcontextprotocol.io`)
### Frontend
- **`RunMCPToolComponent`** — routes between credential prompt (reuses
`SetupRequirementsCard` from RunBlock) and result card; discovery step
shows only a minimal in-progress animation (agent-internal, not
user-facing)
- **`MCPToolOutputCard`** — renders tool result as formatted JSON or
plain text
- **`helpers.tsx`** — type guards (`isMCPToolOutput`,
`isSetupRequirementsOutput`, `isErrorOutput`), output parsing, animation
text
- Registered `tool-run_mcp_tool` case in `ChatMessagesContainer`
## Test plan
- [ ] Call `run_mcp_tool(server_url)` with a public MCP server → see
discovery animation, agent gets tool list
- [ ] Call `run_mcp_tool(server_url, tool_name, tool_arguments)` → see
`MCPToolOutputCard` with result
- [ ] Call with an auth-required server and no stored creds →
`SetupRequirementsCard` renders with MCP OAuth button
- [ ] After connecting credentials, retry → executes successfully
- [ ] `find_block("MCP")` returns no results (MCPToolBlock excluded)
- [ ] Backend unit tests: mock `MCPClient` for discovery + execution +
auth error paths
---------
Co-authored-by: Otto (AGPT) <otto@agpt.co>
118 lines
4.2 KiB
Python
118 lines
4.2 KiB
Python
"""Shared MCP helpers used by blocks, copilot tools, and API routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any
|
|
from urllib.parse import urlparse
|
|
|
|
if TYPE_CHECKING:
|
|
from backend.data.model import OAuth2Credentials
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def normalize_mcp_url(url: str) -> str:
|
|
"""Normalize an MCP server URL for consistent credential matching.
|
|
|
|
Strips leading/trailing whitespace and a single trailing slash so that
|
|
``https://mcp.example.com/`` and ``https://mcp.example.com`` resolve to
|
|
the same stored credential.
|
|
"""
|
|
return url.strip().rstrip("/")
|
|
|
|
|
|
def server_host(server_url: str) -> str:
|
|
"""Extract the hostname from a server URL for display purposes.
|
|
|
|
Uses ``parsed.hostname`` (never ``netloc``) to strip any embedded
|
|
username/password before surfacing the value in UI messages.
|
|
"""
|
|
try:
|
|
parsed = urlparse(server_url)
|
|
return parsed.hostname or server_url
|
|
except Exception:
|
|
return server_url
|
|
|
|
|
|
def parse_mcp_content(content: list[dict[str, Any]]) -> Any:
|
|
"""Parse MCP tool response content into a plain Python value.
|
|
|
|
- text items: parsed as JSON when possible, kept as str otherwise
|
|
- image items: kept as ``{type, data, mimeType}`` dict for frontend rendering
|
|
- resource items: unwrapped to their resource payload dict
|
|
|
|
Single-item responses are unwrapped from the list; multiple items are
|
|
returned as a list; empty content returns ``None``.
|
|
"""
|
|
output_parts: list[Any] = []
|
|
for item in content:
|
|
item_type = item.get("type")
|
|
if item_type == "text":
|
|
text = item.get("text", "")
|
|
try:
|
|
output_parts.append(json.loads(text))
|
|
except (json.JSONDecodeError, ValueError):
|
|
output_parts.append(text)
|
|
elif item_type == "image":
|
|
output_parts.append(
|
|
{
|
|
"type": "image",
|
|
"data": item.get("data"),
|
|
"mimeType": item.get("mimeType"),
|
|
}
|
|
)
|
|
elif item_type == "resource":
|
|
output_parts.append(item.get("resource", {}))
|
|
|
|
if len(output_parts) == 1:
|
|
return output_parts[0]
|
|
return output_parts or None
|
|
|
|
|
|
async def auto_lookup_mcp_credential(
|
|
user_id: str, server_url: str
|
|
) -> OAuth2Credentials | None:
|
|
"""Look up the best stored MCP credential for *server_url*.
|
|
|
|
The caller should pass a **normalized** URL (via :func:`normalize_mcp_url`)
|
|
so the comparison with ``mcp_server_url`` in credential metadata matches.
|
|
|
|
Returns the credential with the latest ``access_token_expires_at``, refreshed
|
|
if needed, or ``None`` when no match is found.
|
|
"""
|
|
from backend.data.model import OAuth2Credentials
|
|
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
|
from backend.integrations.providers import ProviderName
|
|
|
|
try:
|
|
mgr = IntegrationCredentialsManager()
|
|
mcp_creds = await mgr.store.get_creds_by_provider(
|
|
user_id, ProviderName.MCP.value
|
|
)
|
|
# Collect all matching credentials and pick the best one.
|
|
# Primary sort: latest access_token_expires_at (tokens with expiry
|
|
# are preferred over non-expiring ones). Secondary sort: last in
|
|
# iteration order, which corresponds to the most recently created
|
|
# row — this acts as a tiebreaker when multiple bearer tokens have
|
|
# no expiry (e.g. after a failed old-credential cleanup).
|
|
best: OAuth2Credentials | None = None
|
|
for cred in mcp_creds:
|
|
if (
|
|
isinstance(cred, OAuth2Credentials)
|
|
and (cred.metadata or {}).get("mcp_server_url") == server_url
|
|
):
|
|
if best is None or (
|
|
(cred.access_token_expires_at or 0)
|
|
>= (best.access_token_expires_at or 0)
|
|
):
|
|
best = cred
|
|
if best:
|
|
best = await mgr.refresh_if_needed(user_id, best)
|
|
logger.info("Auto-resolved MCP credential %s for %s", best.id, server_url)
|
|
return best
|
|
except Exception:
|
|
logger.warning("Auto-lookup MCP credential failed", exc_info=True)
|
|
return None
|