Add list_accounts tool and require account_id on all email tools
- New list_accounts MCP tool: cheap inbox discovery with no IMAP I/O - account_id is now required on all 11 per-account tools (no silent fallback to first configured account) - resolve_account: whitespace-tolerant, case-insensitive id matching, ambiguity detection on email-address fallback, clear errors that point agents at list_accounts - get_server_info: description clarifies it does live IMAP checks - Version bumped to 1.1.0 - README: tool count 12 -> 13, breaking-change note, account_id requirement called out
This commit is contained in:
@@ -4,7 +4,7 @@ An MCP server that bridges IMAP/SMTP email accounts to [Poke](https://poke.com).
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **12 MCP tools**: search, read, send, draft, archive, move, mark, list/create/rename/delete folders, server info
|
- **13 MCP tools**: list accounts, search, read, send, draft, archive, move, mark, list/create/rename/delete folders, server info
|
||||||
- **Send toggle**: Disable `send_email` globally or per account — agents use `create_draft` instead
|
- **Send toggle**: Disable `send_email` globally or per account — agents use `create_draft` instead
|
||||||
- **IMAP IDLE watcher**: Real-time monitoring of new emails, forwarded to Poke automatically
|
- **IMAP IDLE watcher**: Real-time monitoring of new emails, forwarded to Poke automatically
|
||||||
- **Multi-account support**: Configure multiple email accounts in a single config file
|
- **Multi-account support**: Configure multiple email accounts in a single config file
|
||||||
@@ -157,8 +157,11 @@ The server is mostly idle (IMAP IDLE + lightweight HTTP). Recommended limits for
|
|||||||
|
|
||||||
## MCP Tools
|
## MCP Tools
|
||||||
|
|
||||||
|
> **All email tools require `account_id`.** Call `list_accounts` first to discover available inboxes. `account_id` accepts either the configured `id` or the account's email address (`from_address`, `imap_username`, or `smtp_username`).
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
|
| `list_accounts` | List all configured inboxes (no IMAP connection — cheap discovery) |
|
||||||
| `search_emails` | Search by from, to, subject, date range |
|
| `search_emails` | Search by from, to, subject, date range |
|
||||||
| `read_email` | Read full email content by UID |
|
| `read_email` | Read full email content by UID |
|
||||||
| `send_email` | Send email with text/HTML, CC/BCC, reply threading (can be disabled) |
|
| `send_email` | Send email with text/HTML, CC/BCC, reply threading (can be disabled) |
|
||||||
@@ -170,7 +173,11 @@ The server is mostly idle (IMAP IDLE + lightweight HTTP). Recommended limits for
|
|||||||
| `create_folder` | Create a new folder |
|
| `create_folder` | Create a new folder |
|
||||||
| `rename_folder` | Rename a folder |
|
| `rename_folder` | Rename a folder |
|
||||||
| `delete_folder` | Delete a folder (protected folders blocked) |
|
| `delete_folder` | Delete a folder (protected folders blocked) |
|
||||||
| `get_server_info` | Server status and account connectivity |
|
| `get_server_info` | Server status and account connectivity (performs live IMAP check per account) |
|
||||||
|
|
||||||
|
### Breaking change in v1.1.0
|
||||||
|
|
||||||
|
`account_id` is now **required** on every per-account tool (`search_emails`, `read_email`, `send_email`, `create_draft`, `archive_email`, `move_email`, `mark_email`, `list_folders`, `create_folder`, `rename_folder`, `delete_folder`). Previously, omitting it silently fell back to the first configured account. Agents must now call `list_accounts` (or pass an email address) to specify which inbox to act on.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
|
|||||||
+70
-37
@@ -224,22 +224,39 @@ def parse_accounts(config: dict) -> list[dict]:
|
|||||||
return accounts
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
def resolve_account(accounts: list[dict], account_id: Optional[str] = None) -> dict:
|
def resolve_account(accounts: list[dict], account_id: str) -> dict:
|
||||||
if not account_id:
|
if not account_id or not account_id.strip():
|
||||||
return accounts[0]
|
raise ValueError(
|
||||||
|
f"account_id is required. Available accounts: {[a['id'] for a in accounts]}. "
|
||||||
|
f"Call list_accounts to see all inboxes."
|
||||||
|
)
|
||||||
|
needle = account_id.strip().lower()
|
||||||
|
# Exact id match (case-insensitive, whitespace-tolerant)
|
||||||
for acc in accounts:
|
for acc in accounts:
|
||||||
if acc["id"] == account_id:
|
if acc["id"].lower() == needle:
|
||||||
return acc
|
|
||||||
# Fallback: match by email address (from_address, imap_username, smtp_username)
|
|
||||||
for acc in accounts:
|
|
||||||
if account_id in (
|
|
||||||
acc.get("from_address"),
|
|
||||||
acc.get("imap_username"),
|
|
||||||
acc.get("smtp_username"),
|
|
||||||
):
|
|
||||||
return acc
|
return acc
|
||||||
|
# Fallback: match by email address (from_address, imap_username, smtp_username).
|
||||||
|
# Collect all matches so we can detect ambiguity instead of silently picking the first.
|
||||||
|
matches = [
|
||||||
|
acc
|
||||||
|
for acc in accounts
|
||||||
|
if needle
|
||||||
|
in {
|
||||||
|
(acc.get("from_address") or "").lower(),
|
||||||
|
(acc.get("imap_username") or "").lower(),
|
||||||
|
(acc.get("smtp_username") or "").lower(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"account_id '{account_id}' matches multiple accounts: "
|
||||||
|
f"{[a['id'] for a in matches]}. Pass the unique 'id' instead."
|
||||||
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unknown account_id: {account_id}. Available: {[a['id'] for a in accounts]}"
|
f"Unknown account_id '{account_id}'. Available: {[a['id'] for a in accounts]}. "
|
||||||
|
f"Call list_accounts for full details."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -641,12 +658,12 @@ async def health(request):
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
description="Search emails by criteria. Returns a list of matching emails with metadata."
|
description="Search emails by criteria. Returns a list of matching emails with metadata. Requires account_id — call list_accounts to discover valid IDs (id or email address accepted)."
|
||||||
)
|
)
|
||||||
async def search_emails(
|
async def search_emails(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
account_id: str,
|
||||||
folder: str = "INBOX",
|
folder: str = "INBOX",
|
||||||
account_id: Optional[str] = None,
|
|
||||||
from_addr: Optional[str] = None,
|
from_addr: Optional[str] = None,
|
||||||
to_addr: Optional[str] = None,
|
to_addr: Optional[str] = None,
|
||||||
subject: Optional[str] = None,
|
subject: Optional[str] = None,
|
||||||
@@ -712,13 +729,13 @@ async def search_emails(
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
description="Read a specific email by UID. Returns full email content including body and attachment metadata."
|
description="Read a specific email by UID. Returns full email content including body and attachment metadata. Requires account_id — call list_accounts to discover valid IDs (id or email address accepted)."
|
||||||
)
|
)
|
||||||
async def read_email(
|
async def read_email(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
account_id: str,
|
||||||
uid: int,
|
uid: int,
|
||||||
folder: str = "INBOX",
|
folder: str = "INBOX",
|
||||||
account_id: Optional[str] = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
accounts = ctx.lifespan_context["accounts"]
|
accounts = ctx.lifespan_context["accounts"]
|
||||||
acc = resolve_account(accounts, account_id)
|
acc = resolve_account(accounts, account_id)
|
||||||
@@ -738,14 +755,14 @@ async def read_email(
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
description="Send an email via SMTP. Supports plain text and HTML, CC/BCC, and reply threading."
|
description="Send an email via SMTP. Supports plain text and HTML, CC/BCC, and reply threading. Requires account_id — call list_accounts to discover valid IDs (id or email address accepted)."
|
||||||
)
|
)
|
||||||
async def send_email(
|
async def send_email(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
account_id: str,
|
||||||
to: str,
|
to: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
account_id: Optional[str] = None,
|
|
||||||
cc: Optional[str] = None,
|
cc: Optional[str] = None,
|
||||||
bcc: Optional[str] = None,
|
bcc: Optional[str] = None,
|
||||||
html: Optional[str] = None,
|
html: Optional[str] = None,
|
||||||
@@ -820,14 +837,14 @@ async def send_email(
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
description="Save an email as a draft for review before sending. The draft appears in the account's Drafts folder."
|
description="Save an email as a draft for review before sending. The draft appears in the account's Drafts folder. Requires account_id — call list_accounts to discover valid IDs (id or email address accepted)."
|
||||||
)
|
)
|
||||||
async def create_draft(
|
async def create_draft(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
account_id: str,
|
||||||
to: str,
|
to: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
account_id: Optional[str] = None,
|
|
||||||
cc: Optional[str] = None,
|
cc: Optional[str] = None,
|
||||||
bcc: Optional[str] = None,
|
bcc: Optional[str] = None,
|
||||||
html: Optional[str] = None,
|
html: Optional[str] = None,
|
||||||
@@ -863,13 +880,13 @@ async def create_draft(
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
description="Archive an email by moving it to the Archive folder instead of deleting."
|
description="Archive an email by moving it to the Archive folder instead of deleting. Requires account_id — call list_accounts to discover valid IDs (id or email address accepted)."
|
||||||
)
|
)
|
||||||
async def archive_email(
|
async def archive_email(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
account_id: str,
|
||||||
uid: int,
|
uid: int,
|
||||||
folder: str = "INBOX",
|
folder: str = "INBOX",
|
||||||
account_id: Optional[str] = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
accounts = ctx.lifespan_context["accounts"]
|
accounts = ctx.lifespan_context["accounts"]
|
||||||
acc = resolve_account(accounts, account_id)
|
acc = resolve_account(accounts, account_id)
|
||||||
@@ -892,13 +909,13 @@ async def archive_email(
|
|||||||
return await asyncio.to_thread(_archive)
|
return await asyncio.to_thread(_archive)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(description="Move an email from one folder to another.")
|
@mcp.tool(description="Move an email from one folder to another. Requires account_id — call list_accounts to discover valid IDs (id or email address accepted).")
|
||||||
async def move_email(
|
async def move_email(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
account_id: str,
|
||||||
uid: int,
|
uid: int,
|
||||||
to_folder: str,
|
to_folder: str,
|
||||||
from_folder: str = "INBOX",
|
from_folder: str = "INBOX",
|
||||||
account_id: Optional[str] = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
accounts = ctx.lifespan_context["accounts"]
|
accounts = ctx.lifespan_context["accounts"]
|
||||||
acc = resolve_account(accounts, account_id)
|
acc = resolve_account(accounts, account_id)
|
||||||
@@ -917,13 +934,13 @@ async def move_email(
|
|||||||
return await asyncio.to_thread(_move)
|
return await asyncio.to_thread(_move)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(description="Mark an email as read, unread, flagged, or unflagged.")
|
@mcp.tool(description="Mark an email as read, unread, flagged, or unflagged. Requires account_id — call list_accounts to discover valid IDs (id or email address accepted).")
|
||||||
async def mark_email(
|
async def mark_email(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
account_id: str,
|
||||||
uid: int,
|
uid: int,
|
||||||
action: str,
|
action: str,
|
||||||
folder: str = "INBOX",
|
folder: str = "INBOX",
|
||||||
account_id: Optional[str] = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
accounts = ctx.lifespan_context["accounts"]
|
accounts = ctx.lifespan_context["accounts"]
|
||||||
acc = resolve_account(accounts, account_id)
|
acc = resolve_account(accounts, account_id)
|
||||||
@@ -956,10 +973,10 @@ async def mark_email(
|
|||||||
return await asyncio.to_thread(_mark)
|
return await asyncio.to_thread(_mark)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(description="List all IMAP folders for an account.")
|
@mcp.tool(description="List all IMAP folders for an account. Requires account_id — call list_accounts to discover valid IDs (id or email address accepted).")
|
||||||
async def list_folders(
|
async def list_folders(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
account_id: Optional[str] = None,
|
account_id: str,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
accounts = ctx.lifespan_context["accounts"]
|
accounts = ctx.lifespan_context["accounts"]
|
||||||
acc = resolve_account(accounts, account_id)
|
acc = resolve_account(accounts, account_id)
|
||||||
@@ -982,11 +999,11 @@ async def list_folders(
|
|||||||
return await asyncio.to_thread(_list)
|
return await asyncio.to_thread(_list)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(description="Create a new IMAP folder.")
|
@mcp.tool(description="Create a new IMAP folder. Requires account_id — call list_accounts to discover valid IDs (id or email address accepted).")
|
||||||
async def create_folder(
|
async def create_folder(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
account_id: str,
|
||||||
name: str,
|
name: str,
|
||||||
account_id: Optional[str] = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
accounts = ctx.lifespan_context["accounts"]
|
accounts = ctx.lifespan_context["accounts"]
|
||||||
acc = resolve_account(accounts, account_id)
|
acc = resolve_account(accounts, account_id)
|
||||||
@@ -1002,12 +1019,12 @@ async def create_folder(
|
|||||||
return await asyncio.to_thread(_create)
|
return await asyncio.to_thread(_create)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(description="Rename an existing IMAP folder.")
|
@mcp.tool(description="Rename an existing IMAP folder. Requires account_id — call list_accounts to discover valid IDs (id or email address accepted).")
|
||||||
async def rename_folder(
|
async def rename_folder(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
account_id: str,
|
||||||
old_name: str,
|
old_name: str,
|
||||||
new_name: str,
|
new_name: str,
|
||||||
account_id: Optional[str] = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
accounts = ctx.lifespan_context["accounts"]
|
accounts = ctx.lifespan_context["accounts"]
|
||||||
acc = resolve_account(accounts, account_id)
|
acc = resolve_account(accounts, account_id)
|
||||||
@@ -1024,12 +1041,12 @@ async def rename_folder(
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
description="Delete an IMAP folder. Refuses to delete INBOX or system folders."
|
description="Delete an IMAP folder. Refuses to delete INBOX or system folders. Requires account_id — call list_accounts to discover valid IDs (id or email address accepted)."
|
||||||
)
|
)
|
||||||
async def delete_folder(
|
async def delete_folder(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
account_id: str,
|
||||||
name: str,
|
name: str,
|
||||||
account_id: Optional[str] = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
protected = {
|
protected = {
|
||||||
"INBOX",
|
"INBOX",
|
||||||
@@ -1057,7 +1074,23 @@ async def delete_folder(
|
|||||||
return await asyncio.to_thread(_delete)
|
return await asyncio.to_thread(_delete)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(description="Get server information and account connection status.")
|
@mcp.tool(description="List all configured email inboxes/accounts available on this server. Call this first to discover valid account_id values — every other email tool requires account_id. account_id also accepts the account's email address (from_address, imap_username, or smtp_username). This tool performs no IMAP connections and is safe to call frequently.")
|
||||||
|
async def list_accounts(ctx: Context) -> list[dict]:
|
||||||
|
accounts = ctx.lifespan_context["accounts"]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": acc["id"],
|
||||||
|
"from_address": acc.get("from_address"),
|
||||||
|
"imap_username": acc.get("imap_username"),
|
||||||
|
"imap_host": acc.get("imap_host"),
|
||||||
|
"smtp_host": acc.get("smtp_host"),
|
||||||
|
"watch_folders": acc.get("watch_folders", []),
|
||||||
|
}
|
||||||
|
for acc in accounts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(description="Get server name, version, webhook URL, and per-account connection status (performs a live IMAP check per account). For cheap inbox discovery without connecting, use list_accounts.")
|
||||||
async def get_server_info(ctx: Context) -> dict:
|
async def get_server_info(ctx: Context) -> dict:
|
||||||
accounts = ctx.lifespan_context["accounts"]
|
accounts = ctx.lifespan_context["accounts"]
|
||||||
webhook_url = ctx.lifespan_context["webhook_url"]
|
webhook_url = ctx.lifespan_context["webhook_url"]
|
||||||
@@ -1084,7 +1117,7 @@ async def get_server_info(ctx: Context) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"server_name": "poke-mail",
|
"server_name": "poke-mail",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"accounts": account_info,
|
"accounts": account_info,
|
||||||
"webhook_url": webhook_url,
|
"webhook_url": webhook_url,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user