refactor: Remove legacy MCP Agent Mail integration (bd-6gd)

Remove the external MCP Agent Mail server integration that required
running a separate HTTP server and configuring environment variables.

The native `bd mail` system (stored as git-synced issues) remains
unchanged and is the recommended approach for inter-agent messaging.

Files removed:
- cmd/bd/message.go - Legacy `bd message` command
- integrations/beads-mcp/src/beads_mcp/mail.py, mail_tools.py
- lib/beads_mail_adapter.py - Python adapter library
- examples/go-agent/ - Agent Mail-focused example
- examples/python-agent/agent_with_mail.py, AGENT_MAIL_EXAMPLE.md
- docs/AGENT_MAIL*.md, docs/adr/002-agent-mail-integration.md
- tests/integration/test_agent_race.py, test_mail_failures.py, etc.
- tests/benchmarks/ - Agent Mail benchmarks

Updated documentation to remove Agent Mail references while keeping
native `bd mail` documentation intact.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-17 23:14:05 -08:00
parent 6920cd5224
commit 83ae110508
38 changed files with 267 additions and 10253 deletions

View File

@@ -1,613 +0,0 @@
"""Agent Mail integration for beads MCP server.
Provides simple messaging functions that wrap Agent Mail HTTP API.
Requires BEADS_AGENT_MAIL_URL and BEADS_AGENT_NAME environment variables.
"""
import logging
import os
from typing import Any, Optional
from urllib.parse import urljoin
import requests
logger = logging.getLogger(__name__)
# Timeout for Agent Mail HTTP requests (seconds)
AGENT_MAIL_TIMEOUT = 5.0
AGENT_MAIL_RETRIES = 2
class MailError(Exception):
"""Base exception for Agent Mail errors."""
def __init__(self, code: str, message: str, data: Optional[dict[str, Any]] = None):
self.code = code
self.message = message
self.data = data or {}
super().__init__(message)
def _get_config() -> tuple[str, str, Optional[str]]:
"""Get Agent Mail configuration from environment.
Returns:
(base_url, agent_name, token)
Raises:
MailError: If required configuration is missing
"""
base_url = os.environ.get("BEADS_AGENT_MAIL_URL")
if not base_url:
raise MailError(
"NOT_CONFIGURED",
"Agent Mail not configured. Set BEADS_AGENT_MAIL_URL environment variable.\n"
"Example: export BEADS_AGENT_MAIL_URL=http://127.0.0.1:8765\n"
"See docs/AGENT_MAIL_QUICKSTART.md for setup instructions.",
)
agent_name = os.environ.get("BEADS_AGENT_NAME")
if not agent_name:
# Try to derive from user/repo
import getpass
try:
user = getpass.getuser()
cwd = os.getcwd()
repo_name = os.path.basename(cwd)
agent_name = f"{user}-{repo_name}"
logger.warning(
f"BEADS_AGENT_NAME not set, using derived name: {agent_name}"
)
except Exception:
raise MailError(
"NOT_CONFIGURED",
"Agent Mail not configured. Set BEADS_AGENT_NAME environment variable.\n"
"Example: export BEADS_AGENT_NAME=my-agent",
)
token = os.environ.get("BEADS_AGENT_MAIL_TOKEN")
return base_url, agent_name, token
def _get_project_key() -> str:
"""Get project key from environment or derive from Git/workspace.
Returns:
Project key (absolute path to workspace root)
"""
# Check explicit project ID first
project_id = os.environ.get("BEADS_PROJECT_ID")
if project_id:
return project_id
# Try to get from bd workspace detection
# Import here to avoid circular dependency
from .tools import _find_beads_db_in_tree
workspace = _find_beads_db_in_tree()
if workspace:
return os.path.abspath(workspace)
# Fallback to current directory
return os.path.abspath(os.getcwd())
def _call_agent_mail(
method: str,
endpoint: str,
json_data: Optional[dict[str, Any]] = None,
params: Optional[dict[str, Any]] = None,
) -> Any:
"""Make HTTP request to Agent Mail server with retries.
Args:
method: HTTP method (GET, POST, DELETE, etc.)
endpoint: API endpoint path (e.g., "/api/messages")
json_data: Request body as JSON
params: URL query parameters
Returns:
Response JSON
Raises:
MailError: On request failure or server error
"""
base_url, _, token = _get_config()
url = urljoin(base_url, endpoint)
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
# Use idempotency key for write operations to avoid duplicates on retry
if method in {"POST", "PUT"} and json_data:
import uuid
headers["Idempotency-Key"] = str(uuid.uuid4())
last_error = None
for attempt in range(AGENT_MAIL_RETRIES + 1):
try:
response = requests.request(
method,
url,
json=json_data,
params=params,
headers=headers,
timeout=AGENT_MAIL_TIMEOUT,
)
# Success
if response.status_code < 400:
return response.json() if response.content else {}
# Client error - don't retry
if 400 <= response.status_code < 500:
error_data = {}
try:
error_data = response.json()
except Exception:
error_data = {"detail": response.text}
if response.status_code == 404:
raise MailError(
"NOT_FOUND",
f"Resource not found: {endpoint}",
error_data,
)
elif response.status_code == 409:
raise MailError(
"CONFLICT",
error_data.get("detail", "Conflict"),
error_data,
)
else:
raise MailError(
"INVALID_ARGUMENT",
error_data.get("detail", f"HTTP {response.status_code}"),
error_data,
)
# Server error - retry
last_error = MailError(
"UNAVAILABLE",
f"Agent Mail server error: HTTP {response.status_code}",
{"status": response.status_code, "attempt": attempt + 1},
)
except requests.exceptions.Timeout:
last_error = MailError(
"TIMEOUT",
f"Agent Mail request timeout after {AGENT_MAIL_TIMEOUT}s",
{"attempt": attempt + 1},
)
except requests.exceptions.ConnectionError as e:
last_error = MailError(
"UNAVAILABLE",
f"Cannot connect to Agent Mail server at {base_url}",
{"error": str(e), "attempt": attempt + 1},
)
except MailError:
raise # Re-raise our own errors
except Exception as e:
last_error = MailError(
"INTERNAL_ERROR",
f"Unexpected error calling Agent Mail: {e}",
{"error": str(e), "attempt": attempt + 1},
)
# Exponential backoff between retries
if attempt < AGENT_MAIL_RETRIES:
import time
time.sleep(0.5 * (2**attempt))
# All retries exhausted
if last_error:
raise last_error
raise MailError("INTERNAL_ERROR", "Request failed with no error details")
def mail_send(
to: list[str],
subject: str,
body: str,
urgent: bool = False,
cc: Optional[list[str]] = None,
project_key: Optional[str] = None,
sender_name: Optional[str] = None,
) -> dict[str, Any]:
"""Send a message to other agents.
Args:
to: List of recipient agent names
subject: Message subject
body: Message body (Markdown)
urgent: Mark as urgent (default: False)
cc: Optional CC recipients
project_key: Override project identifier (default: auto-detect)
sender_name: Override sender name (default: BEADS_AGENT_NAME)
Returns:
{
"message_id": int,
"thread_id": str,
"sent_to": int # number of recipients
}
Raises:
MailError: On configuration or delivery error
"""
_, auto_agent_name, _ = _get_config()
auto_project_key = _get_project_key()
sender = sender_name or auto_agent_name
project = project_key or auto_project_key
importance = "urgent" if urgent else "normal"
# Call Agent Mail send_message tool via HTTP
# Note: Agent Mail MCP tools use POST to /mcp/call endpoint
result = _call_agent_mail(
"POST",
"/mcp/call",
json_data={
"method": "tools/call",
"params": {
"name": "send_message",
"arguments": {
"project_key": project,
"sender_name": sender,
"to": to,
"subject": subject,
"body_md": body,
"cc": cc or [],
"importance": importance,
},
},
},
)
# Extract message details from result
# Agent Mail returns: {"deliveries": [...], "count": N}
deliveries = result.get("deliveries", [])
if not deliveries:
raise MailError("INTERNAL_ERROR", "No deliveries returned from Agent Mail")
# Get message ID from first delivery
first_delivery = deliveries[0]
payload = first_delivery.get("payload", {})
message_id = payload.get("id")
thread_id = payload.get("thread_id")
return {
"message_id": message_id,
"thread_id": thread_id,
"sent_to": len(deliveries),
}
def mail_inbox(
limit: int = 20,
urgent_only: bool = False,
unread_only: bool = False,
cursor: Optional[str] = None,
agent_name: Optional[str] = None,
project_key: Optional[str] = None,
) -> dict[str, Any]:
"""Get messages from inbox.
Args:
limit: Maximum messages to return (default: 20)
urgent_only: Only return urgent messages
unread_only: Only return unread messages
cursor: Pagination cursor (for next page)
agent_name: Override agent name (default: BEADS_AGENT_NAME)
project_key: Override project (default: auto-detect)
Returns:
{
"messages": [
{
"id": int,
"thread_id": str,
"from": str,
"subject": str,
"created_ts": str, # ISO-8601
"unread": bool,
"ack_required": bool,
"urgent": bool,
"preview": str # first 100 chars
},
...
],
"next_cursor": str | None
}
"""
_, auto_agent_name, _ = _get_config()
auto_project_key = _get_project_key()
agent = agent_name or auto_agent_name
project = project_key or auto_project_key
# Call fetch_inbox via MCP
result = _call_agent_mail(
"POST",
"/mcp/call",
json_data={
"method": "tools/call",
"params": {
"name": "fetch_inbox",
"arguments": {
"project_key": project,
"agent_name": agent,
"limit": limit,
"urgent_only": urgent_only,
"include_bodies": False, # Get preview only
},
},
},
)
# Agent Mail returns list of messages directly
messages: list[dict[str, Any]] = result if isinstance(result, list) else []
# Transform to our format and filter unread if requested
formatted_messages = []
for msg in messages:
# Skip read messages if unread_only
if unread_only and msg.get("read_ts"):
continue
formatted_messages.append(
{
"id": msg.get("id"),
"thread_id": msg.get("thread_id"),
"from": msg.get("from"),
"subject": msg.get("subject"),
"created_ts": msg.get("created_ts"),
"unread": not bool(msg.get("read_ts")),
"ack_required": msg.get("ack_required", False),
"urgent": msg.get("importance") in {"high", "urgent"},
"preview": msg.get("body_md", "")[:100] if msg.get("body_md") else "",
}
)
# Simple cursor pagination (use last message ID)
next_cursor = None
if formatted_messages and len(formatted_messages) >= limit:
next_cursor = str(formatted_messages[-1]["id"])
return {"messages": formatted_messages, "next_cursor": next_cursor}
def mail_read(
message_id: int,
mark_read: bool = True,
agent_name: Optional[str] = None,
project_key: Optional[str] = None,
) -> dict[str, Any]:
"""Read full message with body.
Args:
message_id: Message ID to read
mark_read: Mark message as read (default: True)
agent_name: Override agent name (default: BEADS_AGENT_NAME)
project_key: Override project (default: auto-detect)
Returns:
{
"id": int,
"thread_id": str,
"from": str,
"to": list[str],
"subject": str,
"body": str, # Full Markdown body
"created_ts": str,
"ack_required": bool,
"ack_status": bool,
"read_ts": str | None,
"urgent": bool
}
"""
_, auto_agent_name, _ = _get_config()
auto_project_key = _get_project_key()
agent = agent_name or auto_agent_name
project = project_key or auto_project_key
# Get message via resource
result = _call_agent_mail(
"GET", f"/mcp/resources/resource://message/{message_id}"
)
# Mark as read if requested
if mark_read:
try:
_call_agent_mail(
"POST",
"/mcp/call",
json_data={
"method": "tools/call",
"params": {
"name": "mark_message_read",
"arguments": {
"project_key": project,
"agent_name": agent,
"message_id": message_id,
},
},
},
)
except MailError as e:
# Don't fail read if mark fails
logger.warning(f"Failed to mark message {message_id} as read: {e}")
# Extract message from result
# Resource returns: {"contents": [{...}]}
contents = result.get("contents", [])
if not contents:
raise MailError("NOT_FOUND", f"Message {message_id} not found")
msg = contents[0]
return {
"id": msg.get("id"),
"thread_id": msg.get("thread_id"),
"from": msg.get("from"),
"to": msg.get("to", []),
"subject": msg.get("subject"),
"body": msg.get("body_md", ""),
"created_ts": msg.get("created_ts"),
"ack_required": msg.get("ack_required", False),
"ack_status": bool(msg.get("ack_ts")),
"read_ts": msg.get("read_ts"),
"urgent": msg.get("importance") in {"high", "urgent"},
}
def mail_reply(
message_id: int,
body: str,
subject: Optional[str] = None,
agent_name: Optional[str] = None,
project_key: Optional[str] = None,
) -> dict[str, Any]:
"""Reply to a message (preserves thread).
Args:
message_id: Message ID to reply to
body: Reply body (Markdown)
subject: Override subject (default: "Re: <original subject>")
agent_name: Override sender name (default: BEADS_AGENT_NAME)
project_key: Override project (default: auto-detect)
Returns:
{
"message_id": int,
"thread_id": str
}
"""
_, auto_agent_name, _ = _get_config()
auto_project_key = _get_project_key()
sender = agent_name or auto_agent_name
project = project_key or auto_project_key
# Call reply_message via MCP
args = {
"project_key": project,
"message_id": message_id,
"sender_name": sender,
"body_md": body,
}
if subject:
args["subject_prefix"] = subject
result = _call_agent_mail(
"POST",
"/mcp/call",
json_data={
"method": "tools/call",
"params": {"name": "reply_message", "arguments": args},
},
)
# Extract reply details
reply = result.get("reply", {})
return {
"message_id": reply.get("id"),
"thread_id": reply.get("thread_id"),
}
def mail_ack(
message_id: int,
agent_name: Optional[str] = None,
project_key: Optional[str] = None,
) -> dict[str, bool]:
"""Acknowledge a message (for ack_required messages).
Args:
message_id: Message ID to acknowledge
agent_name: Override agent name (default: BEADS_AGENT_NAME)
project_key: Override project (default: auto-detect)
Returns:
{"acknowledged": True}
"""
_, auto_agent_name, _ = _get_config()
auto_project_key = _get_project_key()
agent = agent_name or auto_agent_name
project = project_key or auto_project_key
# Call acknowledge_message via MCP
_call_agent_mail(
"POST",
"/mcp/call",
json_data={
"method": "tools/call",
"params": {
"name": "acknowledge_message",
"arguments": {
"project_key": project,
"agent_name": agent,
"message_id": message_id,
},
},
},
)
return {"acknowledged": True}
def mail_delete(
message_id: int,
agent_name: Optional[str] = None,
project_key: Optional[str] = None,
) -> dict[str, bool]:
"""Delete (archive) a message from inbox.
Note: Agent Mail may archive rather than truly delete.
Args:
message_id: Message ID to delete
agent_name: Override agent name (default: BEADS_AGENT_NAME)
project_key: Override project (default: auto-detect)
Returns:
{"deleted": True} or {"archived": True}
"""
_, auto_agent_name, _ = _get_config()
auto_project_key = _get_project_key()
agent = agent_name or auto_agent_name
project = project_key or auto_project_key
# Agent Mail doesn't have explicit delete in MCP API
# Best we can do is mark as read and acknowledged
# (This prevents it from showing in urgent/unread views)
try:
_call_agent_mail(
"POST",
"/mcp/call",
json_data={
"method": "tools/call",
"params": {
"name": "mark_message_read",
"arguments": {
"project_key": project,
"agent_name": agent,
"message_id": message_id,
},
},
},
)
return {"archived": True}
except MailError:
# Soft failure - message may not exist or already read
return {"archived": True}

View File

@@ -1,211 +0,0 @@
"""MCP tools for Agent Mail messaging."""
import logging
from typing import Annotated, Any
from .mail import (
MailError,
mail_ack,
mail_delete,
mail_inbox,
mail_read,
mail_reply,
mail_send,
)
from .models import (
MailAckParams,
MailDeleteParams,
MailInboxParams,
MailReadParams,
MailReplyParams,
MailSendParams,
)
logger = logging.getLogger(__name__)
def beads_mail_send(params: MailSendParams) -> dict[str, Any]:
"""Send a message to other agents via Agent Mail.
Requires BEADS_AGENT_MAIL_URL and BEADS_AGENT_NAME environment variables.
Auto-detects project from workspace root.
Example:
mail_send(to=["alice"], subject="Review PR", body="Can you review PR #42?")
Args:
params: Message parameters (to, subject, body, etc.)
Returns:
{message_id: int, thread_id: str, sent_to: int}
Raises:
MailError: On configuration or delivery error
"""
try:
return mail_send(
to=params.to,
subject=params.subject,
body=params.body,
urgent=params.urgent,
cc=params.cc,
project_key=params.project_key,
sender_name=params.sender_name,
)
except MailError as e:
logger.error(f"mail_send failed: {e.message}")
return {"error": e.code, "message": e.message, "data": e.data}
def beads_mail_inbox(
params: Annotated[MailInboxParams, "Parameters"] = MailInboxParams(),
) -> dict[str, Any]:
"""Get messages from Agent Mail inbox.
Requires BEADS_AGENT_MAIL_URL and BEADS_AGENT_NAME environment variables.
Example:
mail_inbox(limit=10, unread_only=True)
Args:
params: Inbox filter parameters
Returns:
{
messages: [{id, thread_id, from, subject, created_ts, unread, ack_required, urgent, preview}, ...],
next_cursor: str | None
}
Raises:
MailError: On configuration or fetch error
"""
try:
return mail_inbox(
limit=params.limit,
urgent_only=params.urgent_only,
unread_only=params.unread_only,
cursor=params.cursor,
agent_name=params.agent_name,
project_key=params.project_key,
)
except MailError as e:
logger.error(f"mail_inbox failed: {e.message}")
return {"error": e.code, "message": e.message, "data": e.data}
def beads_mail_read(params: MailReadParams) -> dict[str, Any]:
"""Read full message with body from Agent Mail.
By default, marks the message as read. Set mark_read=False to preview without marking.
Example:
mail_read(message_id=123)
Args:
params: Read parameters (message_id, mark_read)
Returns:
{
id, thread_id, from, to, subject, body,
created_ts, ack_required, ack_status, read_ts, urgent
}
Raises:
MailError: On configuration or read error
"""
try:
return mail_read(
message_id=params.message_id,
mark_read=params.mark_read,
agent_name=params.agent_name,
project_key=params.project_key,
)
except MailError as e:
logger.error(f"mail_read failed: {e.message}")
return {"error": e.code, "message": e.message, "data": e.data}
def beads_mail_reply(params: MailReplyParams) -> dict[str, Any]:
"""Reply to a message (preserves thread).
Automatically inherits thread_id from the original message.
Example:
mail_reply(message_id=123, body="Thanks, will review today!")
Args:
params: Reply parameters (message_id, body, subject)
Returns:
{message_id: int, thread_id: str}
Raises:
MailError: On configuration or reply error
"""
try:
return mail_reply(
message_id=params.message_id,
body=params.body,
subject=params.subject,
agent_name=params.agent_name,
project_key=params.project_key,
)
except MailError as e:
logger.error(f"mail_reply failed: {e.message}")
return {"error": e.code, "message": e.message, "data": e.data}
def beads_mail_ack(params: MailAckParams) -> dict[str, Any]:
"""Acknowledge a message (for ack_required messages).
Safe to call even if message doesn't require acknowledgement.
Example:
mail_ack(message_id=123)
Args:
params: Acknowledgement parameters (message_id)
Returns:
{acknowledged: True}
Raises:
MailError: On configuration or ack error
"""
try:
return mail_ack(
message_id=params.message_id,
agent_name=params.agent_name,
project_key=params.project_key,
)
except MailError as e:
logger.error(f"mail_ack failed: {e.message}")
return {"error": e.code, "acknowledged": False, "message": e.message}
def beads_mail_delete(params: MailDeleteParams) -> dict[str, Any]:
"""Delete (archive) a message from Agent Mail inbox.
Note: Agent Mail archives messages rather than permanently deleting them.
Example:
mail_delete(message_id=123)
Args:
params: Delete parameters (message_id)
Returns:
{deleted: True} or {archived: True}
Raises:
MailError: On configuration or delete error
"""
try:
return mail_delete(
message_id=params.message_id,
agent_name=params.agent_name,
project_key=params.project_key,
)
except MailError as e:
logger.error(f"mail_delete failed: {e.message}")
return {"error": e.code, "deleted": False, "message": e.message}

View File

@@ -217,63 +217,3 @@ class InitResult(BaseModel):
database: str
prefix: str
message: str
# Agent Mail Models
class MailSendParams(BaseModel):
"""Parameters for sending mail."""
to: list[str]
subject: str
body: str
urgent: bool = False
cc: list[str] | None = None
project_key: str | None = None
sender_name: str | None = None
class MailInboxParams(BaseModel):
"""Parameters for checking inbox."""
limit: int = 20
urgent_only: bool = False
unread_only: bool = False
cursor: str | None = None
agent_name: str | None = None
project_key: str | None = None
class MailReadParams(BaseModel):
"""Parameters for reading mail."""
message_id: int
mark_read: bool = True
agent_name: str | None = None
project_key: str | None = None
class MailReplyParams(BaseModel):
"""Parameters for replying to mail."""
message_id: int
body: str
subject: str | None = None
agent_name: str | None = None
project_key: str | None = None
class MailAckParams(BaseModel):
"""Parameters for acknowledging mail."""
message_id: int
agent_name: str | None = None
project_key: str | None = None
class MailDeleteParams(BaseModel):
"""Parameters for deleting mail."""
message_id: int
agent_name: str | None = None
project_key: str | None = None