From 64a4ab6be43308dd1022ffa160021f215eacf79d Mon Sep 17 00:00:00 2001 From: Fanyang Meng Date: Tue, 11 Feb 2025 22:57:35 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Support=20offer=20creating=20and=20?= =?UTF-8?q?updating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + src/ghost_mcp/server.py | 2 + src/ghost_mcp/tools.py | 6 +- src/ghost_mcp/tools/__init__.py | 4 +- src/ghost_mcp/tools/offers.py | 213 +++++++++++++++++++++++++++++++- 5 files changed, 224 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3357b70..17835d7 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ GHOST_API_URL=your_ghost_api_url GHOST_STAFF_API_KEY=your_staff_api_key npx @mod ### Offers Management - `list_offers`: List promotional offers with relevant details - `read_offer`: Get detailed information on a specific offer +- `create_offer`: Create a new promotional offer with specified details +- `update_offer`: Update an existing offer with new information ### Newsletters Management - `list_newsletters`: List all newsletters associated with the blog diff --git a/src/ghost_mcp/server.py b/src/ghost_mcp/server.py index acb45c6..cf5cfbf 100644 --- a/src/ghost_mcp/server.py +++ b/src/ghost_mcp/server.py @@ -57,6 +57,8 @@ def create_server() -> FastMCP: mcp.tool()(tools.update_tier) mcp.tool()(tools.list_offers) mcp.tool()(tools.read_offer) + mcp.tool()(tools.create_offer) + mcp.tool()(tools.update_offer) mcp.tool()(tools.list_newsletters) mcp.tool()(tools.read_newsletter) mcp.tool()(tools.create_newsletter) diff --git a/src/ghost_mcp/tools.py b/src/ghost_mcp/tools.py index 15dfb74..4b04812 100644 --- a/src/ghost_mcp/tools.py +++ b/src/ghost_mcp/tools.py @@ -25,7 +25,9 @@ from .tools.tiers import ( ) from .tools.offers import ( list_offers, - read_offer + read_offer, + create_offer, + update_offer ) from .tools.newsletters import ( list_newsletters, @@ -51,6 +53,8 @@ __all__ = [ 'update_tier', 'list_offers', 'read_offer', + 'create_offer', + 'update_offer', 'list_newsletters', 'read_newsletter', 'create_newsletter', diff --git a/src/ghost_mcp/tools/__init__.py b/src/ghost_mcp/tools/__init__.py index ec24d4b..f87df52 100644 --- a/src/ghost_mcp/tools/__init__.py +++ b/src/ghost_mcp/tools/__init__.py @@ -4,7 +4,7 @@ from .posts import search_posts_by_title, list_posts, read_post, create_post, up from .users import list_users, read_user from .members import list_members, read_member from .tiers import list_tiers, read_tier, create_tier, update_tier -from .offers import list_offers, read_offer +from .offers import list_offers, read_offer, create_offer, update_offer from .newsletters import list_newsletters, read_newsletter, create_newsletter, update_newsletter __all__ = [ @@ -24,6 +24,8 @@ __all__ = [ 'update_tier', 'list_offers', 'read_offer', + 'create_offer', + 'update_offer', 'list_newsletters', 'read_newsletter', 'create_newsletter', diff --git a/src/ghost_mcp/tools/offers.py b/src/ghost_mcp/tools/offers.py index f8b2d12..12520d9 100644 --- a/src/ghost_mcp/tools/offers.py +++ b/src/ghost_mcp/tools/offers.py @@ -63,7 +63,8 @@ Display Title: {offer.get('display_title', 'No display title')} Type: {offer.get('type', 'Unknown')} Amount: {offer.get('amount', 'Unknown')} Duration: {offer.get('duration', 'Unknown')} -Status: {offer.get('status', 'Unknown')} +Status: {offer.get('status', 'Unknown')} d +Redemption Count: {offer.get('redemption_count', 0)} Tier: {offer.get('tier', {}).get('name', 'Unknown')} ID: {offer.get('id', 'Unknown')} """ @@ -75,6 +76,216 @@ ID: {offer.get('id', 'Unknown')} ctx.error(f"Failed to list offers: {str(e)}") return str(e) +async def update_offer( + offer_id: str, + name: str = None, + code: str = None, + display_title: str = None, + display_description: str = None, + ctx: Context = None +) -> str: + """Update an existing offer in Ghost. + + Args: + offer_id: ID of the offer to update (required) + name: New internal name for the offer (optional) + code: New shortcode for the offer (optional) + display_title: New name displayed in the offer window (optional) + display_description: New text displayed in the offer window (optional) + ctx: Optional context for logging + + Returns: + String representation of the updated offer + + Raises: + GhostError: If the Ghost API request fails + ValueError: If no fields to update are provided + """ + # Check if at least one editable field is provided + if not any([name, code, display_title, display_description]): + raise ValueError("At least one of name, code, display_title, or display_description must be provided") + + if ctx: + ctx.info(f"Updating offer with ID: {offer_id}") + + # Construct update data with only provided fields + update_data = {"offers": [{}]} + offer_updates = update_data["offers"][0] + + if name is not None: + offer_updates["name"] = name + if code is not None: + offer_updates["code"] = code + if display_title is not None: + offer_updates["display_title"] = display_title + if display_description is not None: + offer_updates["display_description"] = display_description + + try: + if ctx: + ctx.debug("Getting auth headers") + headers = await get_auth_headers(STAFF_API_KEY) + + if ctx: + ctx.debug(f"Making API request to update offer {offer_id}") + response = await make_ghost_request( + f"offers/{offer_id}/", + headers, + ctx, + http_method="PUT", + json_data=update_data + ) + + if ctx: + ctx.debug("Processing updated offer response") + + offer = response.get("offers", [{}])[0] + + return f""" +Offer updated successfully: +Name: {offer.get('name')} +Code: {offer.get('code')} +Display Title: {offer.get('display_title', 'No display title')} +Display Description: {offer.get('display_description', 'No description')} +Type: {offer.get('type')} +Status: {offer.get('status', 'active')} +Cadence: {offer.get('cadence')} +Amount: {offer.get('amount')} +Duration: {offer.get('duration')} +Duration in Months: {offer.get('duration_in_months', 'N/A')} +Currency: {offer.get('currency', 'N/A')} +Tier: {offer.get('tier', {}).get('name', 'Unknown')} +ID: {offer.get('id')} +""" + except Exception as e: + if ctx: + ctx.error(f"Failed to update offer: {str(e)}") + raise + +async def create_offer( + name: str, + code: str, + type: str, + cadence: str, + amount: int, + tier_id: str, + duration: str, + display_title: str = None, + display_description: str = None, + currency: str = None, + duration_in_months: int = None, + ctx: Context = None +) -> str: + """Create a new offer in Ghost. + + Args: + name: Internal name for the offer (required) + code: Shortcode for the offer (required) + type: Either 'percent' or 'fixed' (required) + cadence: Either 'month' or 'year' (required) + amount: Discount amount - percentage or fixed value (required) + tier_id: ID of the tier to apply offer to (required) + duration: Either 'once', 'forever' or 'repeating' (required) + display_title: Name displayed in the offer window (optional) + display_description: Text displayed in the offer window (optional) + currency: Required when type is 'fixed', must match tier's currency (optional) + duration_in_months: Required when duration is 'repeating' (optional) + ctx: Optional context for logging + + Returns: + String representation of the created offer + + Raises: + GhostError: If the Ghost API request fails + ValueError: If required parameters are missing or invalid + """ + if not all([name, code, type, cadence, amount, tier_id, duration]): + raise ValueError("Missing required parameters") + + if type not in ['percent', 'fixed']: + raise ValueError("Type must be either 'percent' or 'fixed'") + + if cadence not in ['month', 'year']: + raise ValueError("Cadence must be either 'month' or 'year'") + + if duration not in ['once', 'forever', 'repeating']: + raise ValueError("Duration must be one of: 'once', 'forever', 'repeating'") + + if duration == 'repeating' and not duration_in_months: + raise ValueError("duration_in_months is required when duration is 'repeating'") + + if type == 'fixed' and not currency: + raise ValueError("Currency is required when type is 'fixed'") + + if ctx: + ctx.info(f"Creating new offer: {name}") + + # Construct offer data + offer_data = { + "offers": [{ + "name": name, + "code": code, + "type": type, + "cadence": cadence, + "amount": amount, + "duration": duration, + "tier": { + "id": tier_id + } + }] + } + + # Add optional fields if provided + if display_title: + offer_data["offers"][0]["display_title"] = display_title + if display_description: + offer_data["offers"][0]["display_description"] = display_description + if currency: + offer_data["offers"][0]["currency"] = currency + if duration_in_months: + offer_data["offers"][0]["duration_in_months"] = duration_in_months + + try: + if ctx: + ctx.debug("Getting auth headers") + headers = await get_auth_headers(STAFF_API_KEY) + + if ctx: + ctx.debug("Making API request to create offer") + response = await make_ghost_request( + "offers/", + headers, + ctx, + http_method="POST", + json_data=offer_data + ) + + if ctx: + ctx.debug("Processing created offer response") + + offer = response.get("offers", [{}])[0] + + return f""" +Offer created successfully: +Name: {offer.get('name')} +Code: {offer.get('code')} +Display Title: {offer.get('display_title', 'No display title')} +Display Description: {offer.get('display_description', 'No description')} +Type: {offer.get('type')} +Status: {offer.get('status', 'active')} +Cadence: {offer.get('cadence')} +Amount: {offer.get('amount')} +Duration: {offer.get('duration')} +Duration in Months: {offer.get('duration_in_months', 'N/A')} +Currency: {offer.get('currency', 'N/A')} +Tier: {offer.get('tier', {}).get('name', 'Unknown')} +ID: {offer.get('id')} +""" + except Exception as e: + if ctx: + ctx.error(f"Failed to create offer: {str(e)}") + raise + async def read_offer(offer_id: str, ctx: Context = None) -> str: """Get the details of a specific offer.