Skip to content

MCP serverCrewAI

Give a CrewAI agent its own inbox

To give a CrewAI agent email, create a Shipmail mailbox on your domain and hand its tools to the agent. Connect the Shipmail MCP server through the agent's mcps field, or write four tools with @tool and the Shipmail Python SDK. Either way the agent lists its inbox, reads conversations, sends and replies, and a person approves each send.

Last updated

What you get

  • A real mailbox your team can open, so you see every message the agent handled.
  • Tools to list the inbox, read a conversation, send a new email and reply in a conversation.
  • Human approval on the send and reply tools with a CrewAI tool call hook.

Create the mailbox and a scoped key

You need a Shipmail account with your domain added. Unlike a Gmail account made for a bot, a Shipmail mailbox is a paid business mailbox on your own domain, and giving it to an agent is what it is for. The address stays yours if you change agents later.

  1. Create a mailbox for the agent

    Under Mailboxes, create an address like agent@yourdomain.com. You and your team can open it in Shipmail or any mail client and see every message the crew reads, drafts or sends.

  2. Create an API key

    Open Developer, then API keys, and create a key. Pick the Draft only preset. It grants mailboxes:read, messages:read, messages:write, drafts:write, drafts:compose, threads:read and suppressions:read, and no messages:send.

  3. Limit the key to that mailbox

    Choose specific resources and add only the agent's mailbox. The key then cannot read or write any other mailbox in your organization.

The key starts with sm_live_ and is shown once. The MCP server only lists the tools the key's scopes allow, so a Draft only key has no send tool at all. When the drafts are good, edit the key and switch to the Full agent preset, which adds messages:send and members:read. Scope details are on API authentication.

Settings

Environment variableValueUsed for
SHIPMAIL_API_KEYsm_live_...The scoped key from the step above. Both routes read it.
SHIPMAIL_MAILBOX_IDThe agent mailbox IDOnly the SDK tools need it. GET /mailboxes or client.mailboxes.list() returns it.
SHIPMAIL_FROMagent@yourdomain.comThe From address of new emails sent by the SDK tools.

CrewAI needs Python 3.10 to 3.13. The samples set no llm, so the agent uses CrewAI's default model and your provider key.

Route 1: the Shipmail MCP server

MCPServerHTTP from crewai.mcp connects to https://shipmail.to/api/mcp over Streamable HTTP and sends the API key as a bearer token. CrewAI needs the mcp package for this.

uv add crewai mcp
import os

from crewai import Agent, Crew, Task
from crewai.mcp import MCPServerHTTP
from crewai.mcp.filters import create_static_tool_filter

shipmail = MCPServerHTTP(
    url="https://shipmail.to/api/mcp",
    headers={"Authorization": f"Bearer {os.environ['SHIPMAIL_API_KEY']}"},
    streamable=True,
    # Read the inbox and write reply drafts. Nothing else.
    tool_filter=create_static_tool_filter(
        allowed_tool_names=[
            "shipmail_list_mailboxes",
            "shipmail_list_mailbox_inbox_messages",
            "shipmail_get_mailbox_inbox_thread",
            "shipmail_create_inbox_reply_draft",
        ]
    ),
)

inbox_agent = Agent(
    role="Inbox assistant for agent@yourdomain.com",
    goal="Answer customer email accurately and briefly",
    backstory="You read the whole conversation before you reply, and you never promise what you cannot check.",
    mcps=[shipmail],
)

triage = Task(
    description="List the unread mail in agent@yourdomain.com and draft a reply to each message that needs one.",
    expected_output="One line per message: the sender, the subject, and whether you drafted a reply.",
    agent=inbox_agent,
)

print(Crew(agents=[inbox_agent], tasks=[triage]).kickoff())

The tool filter keeps the agent to four Shipmail tools: find the mailbox, list its inbox, read a conversation and save a reply draft. With a Draft only key the server lists no send tool anyway. To let the crew send later, add shipmail_reply_to_inbox_thread to the list, switch the key to the Full agent preset and add an approval hook like the one in route 2. CrewAI puts the server name in front of each MCP tool name, so print the names the agent sees before you scope the hook. Every tool name is on Shipmail MCP tools.

Route 2: your own tools with the Python SDK

Four tools, each a call to the Python SDK. The @tool decorator from crewai.tools turns a typed function into a tool, and the docstring tells the model what it does. Save this as email_tools.py.

uv add crewai shipmail
import json
import os

from crewai.tools import tool
from shipmail import Shipmail

shipmail = Shipmail(os.environ["SHIPMAIL_API_KEY"])
MAILBOX_ID = os.environ["SHIPMAIL_MAILBOX_ID"]
FROM = os.environ["SHIPMAIL_FROM"]


def _sender(message: dict) -> str | None:
    senders = message["from"] or []
    return senders[0]["email"] if senders else None


@tool("list_inbox")
def list_inbox(unread_only: bool = True, limit: int = 10) -> str:
    """List recent messages in the agent's inbox with their id, conversation_id, sender, subject and preview."""
    params = {"folder_role": "inbox", "limit": limit}
    if unread_only:
        params["not_keyword"] = "$seen"
    page = shipmail.mailboxes.list_inbox_messages(MAILBOX_ID, params)
    return json.dumps(
        [
            {
                "id": m["id"],
                "conversation_id": m["conversation_id"],
                "from": _sender(m),
                "subject": m["subject"],
                "received_at": m["received_at"],
                "preview": m["preview"],
            }
            for m in page["data"]
        ]
    )


@tool("read_conversation")
def read_conversation(conversation_id: str) -> str:
    """Read every message in one conversation."""
    thread = shipmail.mailboxes.get_inbox_thread(MAILBOX_ID, conversation_id)
    return json.dumps(
        [
            {
                "from": _sender(m),
                "received_at": m["received_at"],
                "subject": m["subject"],
                # What the sender newly wrote, without the quoted history.
                "text": m["reply_text"] or m["preview"],
            }
            for m in thread["data"]
        ]
    )


@tool("send_email")
def send_email(to: list[str], subject: str, text: str) -> str:
    """Send a new email from the agent's address."""
    message = shipmail.messages.send({"from": FROM, "to": to, "subject": subject, "text": text})
    return json.dumps({"id": message["id"], "status": message["status"]})


@tool("reply")
def reply(conversation_id: str, to: list[str], text: str) -> str:
    """Reply in an existing conversation."""
    message = shipmail.mailboxes.reply_to_inbox_thread(
        MAILBOX_ID, conversation_id, {"to": to, "text": text}
    )
    return json.dumps({"id": message["id"], "status": message["status"]})

Approve before sending

A PRE_TOOL_CALL hook runs before the tool. Scoped to send_email and reply, it shows the input in the terminal and asks for approval. Raising HookAborted blocks the call: nothing reaches Shipmail, and the agent is told the call was blocked.

from crewai import Agent, Crew, Task
from crewai.hooks import HookAborted, InterceptionPoint, ToolCallHookContext, on

from email_tools import list_inbox, read_conversation, reply, send_email


# Runs before every send or reply. Anything but "yes" blocks the call.
@on(InterceptionPoint.PRE_TOOL_CALL, tools=["send_email", "reply"])
def approve_send(ctx: ToolCallHookContext) -> None:
    answer = ctx.request_human_input(
        prompt=f"Allow {ctx.tool_name}?",
        default_message=f"Input: {ctx.tool_input}\nType 'yes' to approve:",
    )
    if answer.strip().lower() != "yes":
        raise HookAborted(reason="denied by operator", source="approval-gate")


inbox_agent = Agent(
    role="Inbox assistant for agent@yourdomain.com",
    goal="Answer customer email accurately and briefly",
    backstory="You read the whole conversation before you reply, and you never promise what you cannot check.",
    tools=[list_inbox, read_conversation, send_email, reply],
)

answer_jane = Task(
    description="Reply to the latest email from jane@customer.com and confirm the call on Thursday.",
    expected_output="The reply you sent, or why you did not send one.",
    agent=inbox_agent,
)

print(Crew(agents=[inbox_agent], tasks=[answer_jane]).kickoff())

Test without delivering

Use an sm_test_ key while you build. Shipmail accepts every send and delivers nothing. See the sandbox.

Stay safe

  • Limit the key to the agent's mailbox, and start with Draft only.
  • Treat every email body as untrusted input. Do not give the same crew tools that move money or change accounts.
  • For a crew that reacts to new mail, check the sender before it runs. See receive email in an AI agent and MCP security.

Next steps

Frequently asked questions

Should I use mcps or my own tools?

Use mcps with MCPServerHTTP to get Shipmail's tools with no tool code to maintain, and a tool filter to keep the list short. Write your own tools with @tool and the Python SDK when you want your own descriptions or a mailbox your code picks instead of the model.

Can I use MCPServerAdapter from crewai-tools instead?

Yes. CrewAI documents MCPServerAdapter for cases where you manage the connection yourself. For most crews, its docs recommend the mcps field on the agent, which this guide uses.

Does human_input=True on the task approve each email?

No. human_input asks a person to review the task's final answer, after the tools have run. To stop an email before it leaves, use a PRE_TOOL_CALL hook on the send and reply tools, as shown above.

Why does the reply tool fail with a permission error?

Sending and replying need the messages:send scope, which the Draft only preset leaves out. Switch the key to the Full agent preset, or call client.mailboxes.create_inbox_reply_draft so replies wait in Drafts.

Which model does the crew use?

The samples set no llm, so CrewAI uses its default model. Set llm on the Agent to choose another one that supports tool calling.