From 3fe5bb02adbff519212f5f0fc43cf9315136d96f Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 16 Jul 2025 13:16:44 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=84=20Refactor=20API=20endpoints=20to?= =?UTF-8?q?=20use=20MCP=20resources=20vs=20tools=20(v0.3.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pyproject.toml | 2 +- src/mcp_mailu/server.py | 305 +++++++++++++++++++++------------------- uv.lock | 2 +- 3 files changed, 165 insertions(+), 144 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8ee17c3..dd1118c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/src/mcp_mailu/server.py b/src/mcp_mailu/server.py index ba1a876..057f438 100644 --- a/src/mcp_mailu/server.py +++ b/src/mcp_mailu/server.py @@ -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 diff --git a/uv.lock b/uv.lock index 584e471..300053d 100644 --- a/uv.lock +++ b/uv.lock @@ -613,7 +613,7 @@ wheels = [ [[package]] name = "mcp-mailu" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "fastmcp" },