1
0
forked from rsp2k/mcp-mailu

🔄 Refactor API endpoints to use MCP resources vs tools (v0.3.0)

ARCHITECTURAL IMPROVEMENT: Proper separation of concerns in MCP design

📖 RESOURCES (Read-only data retrieval):
- mailu://users - All users
- mailu://domains - All domains
- mailu://aliases - All aliases
- mailu://alternative-domains - All alternative domains
- mailu://relays - All relays
- mailu://user/{email} - Specific user details
- mailu://domain/{domain} - Specific domain details
- mailu://alias/{alias} - Specific alias details
- mailu://alternative-domain/{alt} - Specific alternative domain
- mailu://relay/{name} - Specific relay details
- mailu://domain/{domain}/users - Users in specific domain
- mailu://domain/{domain}/managers - Managers for specific domain
- mailu://domain/{domain}/manager/{email} - Specific manager details
- mailu://alias/destination/{domain} - Aliases by destination domain

 TOOLS (Actions and modifications):
- create_user, update_user, delete_user
- create_domain, update_domain, delete_domain, generate_dkim_keys
- create_domain_manager, delete_domain_manager
- create_alias, update_alias, delete_alias
- create_alternative_domain, delete_alternative_domain
- create_relay, update_relay, delete_relay

 BENEFITS:
- Better UX: Resources are discoverable and browsable
- Cleaner separation: Read vs Write operations
- Structured data: Resources return properly formatted JSON
- URI-based access: RESTful resource addressing
- Improved performance: Resources can be cached by clients

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ryan Malloy 2025-07-16 13:16:44 -06:00
parent 4f17a4e3bc
commit 3fe5bb02ad
3 changed files with 165 additions and 144 deletions

View File

@ -1,6 +1,6 @@
[project]
name = "mcp-mailu"
version = "0.2.0"
version = "0.3.0"
description = "FastMCP server for Mailu email server API integration"
authors = [
{name = "Ryan Malloy", email = "ryan@supported.systems"}

View File

@ -9,7 +9,7 @@ from typing import Any, Dict, Optional
import httpx
from dotenv import load_dotenv
from fastmcp import FastMCP
from fastmcp import FastMCP, Context
# Load environment variables from .env file
load_dotenv()
@ -1059,21 +1059,172 @@ def create_mcp_server() -> FastMCP:
logger.error(f"Failed to create OpenAPI-based server: {openapi_error}")
logger.info("Falling back to basic MCP server with manual tools")
# Create a comprehensive MCP server with manual tools (with error handling)
# Create a comprehensive MCP server with resources and tools
mcp = FastMCP("Mailu MCP Server")
# ===== USER ENDPOINTS =====
@mcp.tool()
async def list_users() -> str:
# ===== RESOURCES (READ-ONLY DATA) =====
# List resources - no parameters
@mcp.resource("mailu://users")
async def users_resource(ctx: Context) -> str:
"""List all users in the Mailu instance."""
try:
async with client as mailu_client:
response = await mailu_client.get("/user")
response.raise_for_status()
return f"Users: {response.json()}"
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error listing users: {e}"
@mcp.resource("mailu://domains")
async def domains_resource(ctx: Context) -> str:
"""List all domains in the Mailu instance."""
try:
async with client as mailu_client:
response = await mailu_client.get("/domain")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error listing domains: {e}"
@mcp.resource("mailu://aliases")
async def aliases_resource(ctx: Context) -> str:
"""List all aliases in the Mailu instance."""
try:
async with client as mailu_client:
response = await mailu_client.get("/alias")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error listing aliases: {e}"
@mcp.resource("mailu://alternative-domains")
async def alternative_domains_resource(ctx: Context) -> str:
"""List all alternative domains in the Mailu instance."""
try:
async with client as mailu_client:
response = await mailu_client.get("/alternative")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error listing alternative domains: {e}"
@mcp.resource("mailu://relays")
async def relays_resource(ctx: Context) -> str:
"""List all relays in the Mailu instance."""
try:
async with client as mailu_client:
response = await mailu_client.get("/relay")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error listing relays: {e}"
# Individual item resources - with parameters
@mcp.resource("mailu://user/{email}")
async def user_resource(email: str, ctx: Context) -> str:
"""Get details of a specific user."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/user/{email}")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error getting user {email}: {e}"
@mcp.resource("mailu://domain/{domain}")
async def domain_resource(domain: str, ctx: Context) -> str:
"""Get details of a specific domain."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/domain/{domain}")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error getting domain {domain}: {e}"
@mcp.resource("mailu://alias/{alias}")
async def alias_resource(alias: str, ctx: Context) -> str:
"""Get details of a specific alias."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/alias/{alias}")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error getting alias {alias}: {e}"
@mcp.resource("mailu://alternative-domain/{alt}")
async def alternative_domain_resource(alt: str, ctx: Context) -> str:
"""Get details of a specific alternative domain."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/alternative/{alt}")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error getting alternative domain {alt}: {e}"
@mcp.resource("mailu://relay/{name}")
async def relay_resource(name: str, ctx: Context) -> str:
"""Get details of a specific relay."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/relay/{name}")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error getting relay {name}: {e}"
# Domain-specific resources - with parameters
@mcp.resource("mailu://domain/{domain}/users")
async def domain_users_resource(domain: str, ctx: Context) -> str:
"""List all users in a specific domain."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/domain/{domain}/users")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error listing users for domain {domain}: {e}"
@mcp.resource("mailu://domain/{domain}/managers")
async def domain_managers_resource(domain: str, ctx: Context) -> str:
"""List all managers for a specific domain."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/domain/{domain}/manager")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error listing managers for domain {domain}: {e}"
@mcp.resource("mailu://domain/{domain}/manager/{email}")
async def domain_manager_resource(domain: str, email: str, ctx: Context) -> str:
"""Get details of a specific domain manager."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/domain/{domain}/manager/{email}")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error getting manager {email} for domain {domain}: {e}"
@mcp.resource("mailu://alias/destination/{domain}")
async def aliases_by_domain_resource(domain: str, ctx: Context) -> str:
"""Find aliases by destination domain."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/alias/destination/{domain}")
response.raise_for_status()
return json.dumps(response.json(), indent=2)
except Exception as e:
return f"Error finding aliases for domain {domain}: {e}"
# ===== TOOLS (ACTIONS/MODIFICATIONS) =====
# USER TOOLS
@mcp.tool()
async def create_user(email: str, raw_password: str, comment: str = "", quota_bytes: int = 0,
global_admin: bool = False, enabled: bool = True, enable_imap: bool = True,
@ -1111,16 +1262,6 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error creating user: {e}"
@mcp.tool()
async def get_user(email: str) -> str:
"""Get details of a specific user."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/user/{email}")
response.raise_for_status()
return f"User details: {response.json()}"
except Exception as e:
return f"Error getting user: {e}"
@mcp.tool()
async def update_user(email: str, raw_password: str = "", comment: str = "", quota_bytes: int = None,
@ -1169,17 +1310,7 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error deleting user: {e}"
# ===== DOMAIN ENDPOINTS =====
@mcp.tool()
async def list_domains() -> str:
"""List all domains in the Mailu instance."""
try:
async with client as mailu_client:
response = await mailu_client.get("/domain")
response.raise_for_status()
return f"Domains: {response.json()}"
except Exception as e:
return f"Error listing domains: {e}"
# ===== DOMAIN TOOLS =====
@mcp.tool()
async def create_domain(name: str, comment: str = "", max_users: int = -1, max_aliases: int = -1,
@ -1204,16 +1335,6 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error creating domain: {e}"
@mcp.tool()
async def get_domain(domain: str) -> str:
"""Get details of a specific domain."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/domain/{domain}")
response.raise_for_status()
return f"Domain details: {response.json()}"
except Exception as e:
return f"Error getting domain: {e}"
@mcp.tool()
async def update_domain(domain: str, comment: str = "", max_users: int = None, max_aliases: int = None,
@ -1257,28 +1378,8 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error generating DKIM keys: {e}"
@mcp.tool()
async def list_domain_users(domain: str) -> str:
"""List all users in a specific domain."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/domain/{domain}/users")
response.raise_for_status()
return f"Domain users: {response.json()}"
except Exception as e:
return f"Error listing domain users: {e}"
# ===== DOMAIN MANAGER ENDPOINTS =====
@mcp.tool()
async def list_domain_managers(domain: str) -> str:
"""List all managers for a specific domain."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/domain/{domain}/manager")
response.raise_for_status()
return f"Domain managers: {response.json()}"
except Exception as e:
return f"Error listing domain managers: {e}"
# ===== DOMAIN MANAGER TOOLS =====
@mcp.tool()
async def create_domain_manager(domain: str, user_email: str) -> str:
@ -1292,16 +1393,6 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error creating domain manager: {e}"
@mcp.tool()
async def get_domain_manager(domain: str, email: str) -> str:
"""Get details of a specific domain manager."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/domain/{domain}/manager/{email}")
response.raise_for_status()
return f"Domain manager details: {response.json()}"
except Exception as e:
return f"Error getting domain manager: {e}"
@mcp.tool()
async def delete_domain_manager(domain: str, email: str) -> str:
@ -1314,17 +1405,7 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error deleting domain manager: {e}"
# ===== ALIAS ENDPOINTS =====
@mcp.tool()
async def list_aliases() -> str:
"""List all aliases in the Mailu instance."""
try:
async with client as mailu_client:
response = await mailu_client.get("/alias")
response.raise_for_status()
return f"Aliases: {response.json()}"
except Exception as e:
return f"Error listing aliases: {e}"
# ===== ALIAS TOOLS =====
@mcp.tool()
async def create_alias(email: str, destination: str = "", comment: str = "", wildcard: bool = False) -> str:
@ -1343,16 +1424,6 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error creating alias: {e}"
@mcp.tool()
async def get_alias(alias: str) -> str:
"""Get details of a specific alias."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/alias/{alias}")
response.raise_for_status()
return f"Alias details: {response.json()}"
except Exception as e:
return f"Error getting alias: {e}"
@mcp.tool()
async def update_alias(alias: str, destination: str = "", comment: str = "", wildcard: bool = None) -> str:
@ -1381,28 +1452,8 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error deleting alias: {e}"
@mcp.tool()
async def find_aliases_by_domain(domain: str) -> str:
"""Find aliases by destination domain."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/alias/destination/{domain}")
response.raise_for_status()
return f"Aliases for domain: {response.json()}"
except Exception as e:
return f"Error finding aliases by domain: {e}"
# ===== ALTERNATIVE DOMAIN ENDPOINTS =====
@mcp.tool()
async def list_alternative_domains() -> str:
"""List all alternative domains."""
try:
async with client as mailu_client:
response = await mailu_client.get("/alternative")
response.raise_for_status()
return f"Alternative domains: {response.json()}"
except Exception as e:
return f"Error listing alternative domains: {e}"
# ===== ALTERNATIVE DOMAIN TOOLS =====
@mcp.tool()
async def create_alternative_domain(name: str, domain: str) -> str:
@ -1416,16 +1467,6 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error creating alternative domain: {e}"
@mcp.tool()
async def get_alternative_domain(alt: str) -> str:
"""Get details of a specific alternative domain."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/alternative/{alt}")
response.raise_for_status()
return f"Alternative domain details: {response.json()}"
except Exception as e:
return f"Error getting alternative domain: {e}"
@mcp.tool()
async def delete_alternative_domain(alt: str) -> str:
@ -1438,17 +1479,7 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error deleting alternative domain: {e}"
# ===== RELAY ENDPOINTS =====
@mcp.tool()
async def list_relays() -> str:
"""List all relays."""
try:
async with client as mailu_client:
response = await mailu_client.get("/relay")
response.raise_for_status()
return f"Relays: {response.json()}"
except Exception as e:
return f"Error listing relays: {e}"
# ===== RELAY TOOLS =====
@mcp.tool()
async def create_relay(name: str, smtp: str = "", comment: str = "") -> str:
@ -1462,16 +1493,6 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error creating relay: {e}"
@mcp.tool()
async def get_relay(name: str) -> str:
"""Get details of a specific relay."""
try:
async with client as mailu_client:
response = await mailu_client.get(f"/relay/{name}")
response.raise_for_status()
return f"Relay details: {response.json()}"
except Exception as e:
return f"Error getting relay: {e}"
@mcp.tool()
async def update_relay(name: str, smtp: str = "", comment: str = "") -> str:
@ -1499,7 +1520,7 @@ def create_mcp_server() -> FastMCP:
except Exception as e:
return f"Error deleting relay: {e}"
logger.info("Created comprehensive MCP server with all manual tools and error handling")
logger.info("Created comprehensive MCP server with resources (read-only) and tools (actions) and error handling")
return mcp

2
uv.lock generated
View File

@ -613,7 +613,7 @@ wheels = [
[[package]]
name = "mcp-mailu"
version = "0.1.0"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },