♻️ Refactor it into typescript

This commit is contained in:
Fanyang Meng
2025-04-20 15:26:03 -04:00
parent 97288a6398
commit 4ae189429b
47 changed files with 2946 additions and 4640 deletions

5
.gitignore vendored
View File

@@ -9,6 +9,7 @@ wheels/
# Virtual environments
.venv
.vscode
ghost-admin-api.md
mcp-python-sdk.md
.qodo
node_modules/
build/
knowledge/

View File

@@ -1 +0,0 @@
3.12

View File

@@ -1,44 +0,0 @@
# Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
# Use a Python image with uv pre-installed
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS uv
# Install the project into /app
WORKDIR /app
# Enable bytecode compilation
ENV UV_COMPILE_BYTECODE=1
# Copy from the cache instead of linking since it's a mounted volume
ENV UV_LINK_MODE=copy
# Install the project's dependencies using the lockfile and settings
RUN --mount=type=cache,target=/root/.cache/uv --mount=type=bind,source=uv.lock,target=uv.lock --mount=type=bind,source=pyproject.toml,target=pyproject.toml uv sync --frozen --no-install-project --no-dev --no-editable
# Then, add the rest of the project source code and install it
# Installing separately from its dependencies allows optimal layer caching
ADD . /app
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-editable
FROM python:3.12-slim-bookworm
# Create non-root user for security
RUN useradd --create-home app \
&& mkdir -p /app \
&& chown app:app /app
WORKDIR /app
COPY --from=uv /app/.venv /app/.venv
RUN chown -R app:app /app/.venv
USER app
# Place executables in the environment at the front of the path
ENV PATH="/app/.venv/bin:$PATH"
# Environment variables for Ghost CMS connection
ENV GHOST_API_URL=<YOUR_GHOST_API_URL>
ENV GHOST_STAFF_API_KEY=<YOUR_STAFF_API_KEY>
# when running the container, add --db-path and a bind mount to the host's db file
ENTRYPOINT ["uv", "--directory", "/app", "run", "src/main.py"]

221
README.md
View File

@@ -1,185 +1,122 @@
# Ghost MCP Server
[![smithery badge](https://smithery.ai/badge/@MFYDev/ghost-mcp)](https://smithery.ai/server/@MFYDev/ghost-mcp)
<a href="https://glama.ai/mcp/servers/vor63xn7ky"><img width="380" height="200" src="https://glama.ai/mcp/servers/vor63xn7ky/badge" alt="Ghost Server MCP server" /></a>
A Model Context Protocol (MCP) server for interacting with Ghost CMS through LLM interfaces like Claude. This server provides secure and comprehensive access to your Ghost blog, leveraging JWT authentication and a rich set of MCP tools for managing posts, users, members, tiers, offers, and newsletters.
![demo](./assets/ghost-mcp-demo.gif)
## Features
- Secure JWT Authentication for Ghost Admin API requests
- Secure Ghost Admin API requests with `@tryghost/admin-api`
- Comprehensive entity access including posts, users, members, tiers, offers, and newsletters
- Advanced search functionality with both fuzzy and exact matching options
- Detailed, human-readable output for Ghost entities
- Robust error handling using custom `GhostError` exceptions
- Integrated logging support via MCP context for enhanced troubleshooting
## Installation
### Installing via Smithery
To install Ghost MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@MFYDev/ghost-mcp):
```bash
npx -y @smithery/cli install @MFYDev/ghost-mcp --client claude
```
### Manual Installation
```bash
# Clone repository
git clone git@github.com/mfydev/ghost-mcp.git
cd ghost-mcp
# Create virtual environment and install
uv venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
uv pip install -e .
```
## Requirements
- Python ≥ 3.12
- Running Ghost instance with Admin API access (v5.x+ recommended)
- Node.js (for testing with MCP Inspector)
## Usage
### Environment Variables
```bash
GHOST_API_URL=https://yourblog.com # Your Ghost Admin API URL
GHOST_STAFF_API_KEY=your_staff_api_key # Your Ghost Staff API key
```
### Usage with MCP Clients
To use this with MCP clients, for instance, Claude Desktop, add the following to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"ghost": {
"command": "/Users/username/.local/bin/uv",
"args": [
"--directory",
"/path/to/ghost-mcp",
"run",
"src/main.py"
],
"env": {
"GHOST_API_URL": "your_ghost_api_url",
"GHOST_STAFF_API_KEY": "your_staff_api_key"
"ghost-mcp-ts": {
"command": "node",
"args": [
"ABSOLUTE_PATH_TO_GHOST_MCP_SERVER/build/server.js",
],
"env": {
"GHOST_API_URL": "https://yourblog.com",
"GHOST_ADMIN_API_KEY": "your_admin_api_key",
"GHOST_API_VERSION": "v5.0"
}
}
}
}
}
```
### Testing with MCP Inspector
## Available Resources
```bash
GHOST_API_URL=your_ghost_api_url GHOST_STAFF_API_KEY=your_staff_api_key npx @modelcontextprotocol/inspector uv --directory /path/to/ghost-mcp run src/main.py
```
The following Ghost CMS resources are available through this MCP server:
- **Posts**: Articles and content published on your Ghost site.
- **Members**: Registered users and subscribers of your site.
- **Newsletters**: Email newsletters managed and sent via Ghost.
- **Offers**: Promotional offers and discounts for members.
- **Invites**: Invitations for new users or staff to join your Ghost site.
- **Roles**: User roles and permissions within the Ghost admin.
- **Tags**: Organizational tags for posts and content.
- **Tiers**: Subscription tiers and plans for members.
- **Users**: Admin users and staff accounts.
- **Webhooks**: Automated event notifications to external services.
## Available Tools
Ghost MCP now provides a single unified tool that provides access to all Ghost CMS functionality:
This MCP server exposes a comprehensive set of tools for managing your Ghost CMS via the Model Context Protocol. Each resource provides a set of operations, typically including browsing, reading, creating, editing, and deleting entities. Below is a summary of the available tools:
### Main Tool
- `ghost`: Central tool for accessing all Ghost CMS functionality
### Posts
- **Browse Posts**: List posts with optional filters, pagination, and ordering.
- **Read Post**: Retrieve a post by ID or slug.
- **Add Post**: Create a new post with title, content, and status.
- **Edit Post**: Update an existing post by ID.
- **Delete Post**: Remove a post by ID.
### Using the Ghost Tool
### Members
- **Browse Members**: List members with filters and pagination.
- **Read Member**: Retrieve a member by ID or email.
- **Add Member**: Create a new member.
- **Edit Member**: Update member details.
- **Delete Member**: Remove a member.
The ghost tool accepts two main parameters:
1. `action`: The specific Ghost operation to perform
2. `params`: A dictionary of parameters for the specified action
### Newsletters
- **Browse Newsletters**: List newsletters.
- **Read Newsletter**: Retrieve a newsletter by ID.
- **Add Newsletter**: Create a new newsletter.
- **Edit Newsletter**: Update newsletter details.
- **Delete Newsletter**: Remove a newsletter.
Example usage:
```python
# List posts
ghost(action="list_posts", params={"format": "text", "page": 1, "limit": 15})
### Offers
- **Browse Offers**: List offers.
- **Read Offer**: Retrieve an offer by ID.
- **Add Offer**: Create a new offer.
- **Edit Offer**: Update offer details.
- **Delete Offer**: Remove an offer.
# Search posts by title
ghost(action="search_posts_by_title", params={"query": "Welcome", "exact": False})
### Invites
- **Browse Invites**: List invites.
- **Add Invite**: Create a new invite.
- **Delete Invite**: Remove an invite.
# Create a post
ghost(action="create_post", params={
"post_data": {
"title": "New Post via MCP",
"status": "draft",
"lexical": "{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"Hello World\",\"type\":\"text\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}"
}
})
```
### Roles
- **Browse Roles**: List roles.
- **Read Role**: Retrieve a role by ID.
### Available Actions
### Tags
- **Browse Tags**: List tags.
- **Read Tag**: Retrieve a tag by ID or slug.
- **Add Tag**: Create a new tag.
- **Edit Tag**: Update tag details.
- **Delete Tag**: Remove a tag.
The ghost tool supports all the same actions as before, but now through a unified interface:
### Tiers
- **Browse Tiers**: List tiers.
- **Read Tier**: Retrieve a tier by ID.
- **Add Tier**: Create a new tier.
- **Edit Tier**: Update tier details.
- **Delete Tier**: Remove a tier.
#### Posts Actions
- `list_posts`: List blog posts with pagination
- `search_posts_by_title`: Search for posts by title
- `read_post`: Retrieve full content of a specific post
- `create_post`: Create a new post
- `update_post`: Update a specific post
- `delete_post`: Delete a specific post
- `batchly_update_posts`: Update multiple posts in a single request
### Users
- **Browse Users**: List users.
- **Read User**: Retrieve a user by ID or slug.
- **Edit User**: Update user details.
- **Delete User**: Remove a user.
#### Tags Actions
- `browse_tags`: List all tags
- `read_tag`: Retrieve specific tag information
- `create_tag`: Create a new tag
- `update_tag`: Update an existing tag
- `delete_tag`: Delete a specific tag
### Webhooks
- **Browse Webhooks**: List webhooks.
- **Add Webhook**: Create a new webhook.
- **Delete Webhook**: Remove a webhook.
#### Users Actions
- `list_roles`: List all available roles
- `create_invite`: Create a new user invitation
- `list_users`: List all users
- `read_user`: Get details of a specific user
- `delete_user`: Delete a specific user
> Each tool is accessible via the MCP protocol and can be invoked from compatible clients. For detailed parameter schemas and usage, see the source code in `src/tools/`.
#### Members Actions
- `list_members`: List members
- `read_member`: Retrieve specific member information
- `create_member`: Create a new member
- `update_member`: Update an existing member
#### Tiers Actions
- `list_tiers`: List all membership tiers
- `read_tier`: Retrieve specific tier information
- `create_tier`: Create a new tier
- `update_tier`: Update an existing tier
#### Offers Actions
- `list_offers`: List promotional offers
- `read_offer`: Get specific offer information
- `create_offer`: Create a new offer
- `update_offer`: Update an existing offer
#### Newsletters Actions
- `list_newsletters`: List all newsletters
- `read_newsletter`: Retrieve specific newsletter information
- `create_newsletter`: Create a new newsletter
- `update_newsletter`: Update an existing newsletter
#### Webhooks Actions
- `create_webhook`: Create a new webhook
- `update_webhook`: Update an existing webhook
- `delete_webhook`: Delete a specific webhook
## Available Resources
All resources follow the URI pattern: `[type]://[id]`
- `user://{user_id}`: User profiles and roles
- `member://{member_id}`: Member details and subscriptions
- `tier://{tier_id}`: Tier configurations
- `offer://{offer_id}`: Offer details
- `newsletter://{newsletter_id}`: Newsletter settings
- `post://{post_id}`: Post content and metadata
- `blog://info`: General blog information
## Error Handling
@@ -194,4 +131,4 @@ Ghost MCP Server employs a custom `GhostError` exception to handle API communica
## License
MIT
MIT

1286
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@fanyangmeng/ghost-mcp",
"version": "0.0.1",
"description": "MCP server for using the Ghost API",
"main": "index.js",
"scripts": {
"build": "tsc",
"start": "node build/server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "Fanyang Meng (https://mfydev.link)",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.1",
"@tryghost/admin-api": "^1.13.13",
"axios": "^1.8.4",
"zod": "^3.24.3"
},
"devDependencies": {
"@types/axios": "^0.9.36",
"@types/node": "^22.14.1",
"typescript": "^5.8.3"
}
}

View File

@@ -1,23 +0,0 @@
[project]
name = "ghost-mcp"
version = "0.1.0"
description = "Ghost blog integration MCP server"
authors = [
{ name = "Fanyang Meng" }
]
dependencies = [
"httpx",
"pyjwt",
"mcp[cli]>=1.2.1",
"pytz"
]
requires-python = ">=3.12"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/ghost_mcp"]
[tool.hatch.metadata]
allow-direct-references = true

View File

@@ -1,21 +0,0 @@
# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
startCommand:
type: stdio
configSchema:
# JSON Schema defining the configuration options for the MCP.
type: object
required:
- ghostApiUrl
- ghostStaffApiKey
properties:
ghostApiUrl:
type: string
description: The URL of your Ghost Admin API
ghostStaffApiKey:
type: string
description: Your Ghost Staff API key
commandFunction:
# A function that produces the CLI command to start the MCP on stdio.
|-
config => ({ command: 'uv', args: ['--directory', '/app', 'run', 'src/main.py'], env: { GHOST_API_URL: config.ghostApiUrl, GHOST_STAFF_API_KEY: config.ghostStaffApiKey } })

15
src/config.ts Normal file
View File

@@ -0,0 +1,15 @@
// Read configuration values directly from process.env
export const GHOST_API_URL: string = process.env.GHOST_API_URL as string;
export const GHOST_ADMIN_API_KEY: string = process.env.GHOST_ADMIN_API_KEY as string;
export const GHOST_API_VERSION: string = process.env.GHOST_API_VERSION as string || 'v5.0'; // Default to v5.0
// Basic validation to ensure required environment variables are set
if (!GHOST_API_URL) {
console.error("Error: GHOST_API_URL environment variable is not set.");
process.exit(1);
}
if (!GHOST_ADMIN_API_KEY) {
console.error("Error: GHOST_ADMIN_API_KEY environment variable is not set.");
process.exit(1);
}

24
src/ghostApi.ts Normal file
View File

@@ -0,0 +1,24 @@
import GhostAdminAPI from '@tryghost/admin-api';
import { GHOST_API_URL, GHOST_ADMIN_API_KEY, GHOST_API_VERSION } from './config';
// Initialize and export the Ghost Admin API client instance.
// Configuration is loaded from src/config.ts.
export const ghostApiClient = new GhostAdminAPI({
url: GHOST_API_URL,
key: GHOST_ADMIN_API_KEY,
version: GHOST_API_VERSION
});
// You can add helper functions here to wrap API calls and handle errors
// For example:
/*
export async function getPostById(postId: string): Promise<any> {
try {
const post = await ghostApiClient.posts.read({ id: postId });
return post;
} catch (error) {
console.error(`Error fetching post ${postId}:`, error);
throw new Error(`Failed to fetch post ${postId}`);
}
}
*/

View File

@@ -1,3 +0,0 @@
from .server import create_server
__all__ = ['create_server']

View File

@@ -1,193 +0,0 @@
"""Ghost API interaction utilities.
This module provides functions for interacting with the Ghost Admin API, including
JWT token generation, authentication, and making HTTP requests with proper error handling.
"""
import datetime
from typing import Dict, Any, Optional, Union
import httpx
import jwt
from mcp.server.fastmcp import Context
from .config import API_URL
from .exceptions import GhostError
# HTTP Methods
GET = "GET"
POST = "POST"
PUT = "PUT"
DELETE = "DELETE"
VALID_HTTP_METHODS = {GET, POST, PUT, DELETE}
# Default Ghost API version
DEFAULT_API_VERSION = "v5.109"
async def generate_token(staff_api_key: str, audience: str = "/admin/") -> str:
"""Generate a JWT token for Ghost Admin API authentication.
Args:
staff_api_key: API key in 'id:secret' format (e.g. "1234:abcd5678")
audience: Token audience (default: "/admin/")
Returns:
JWT token string for use in Authorization header
Raises:
ValueError: If staff_api_key is not in correct 'id:secret' format
Example:
>>> token = await generate_token("1234:abcd5678")
>>> print(token)
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1...'
"""
try:
key_id, secret = staff_api_key.split(":")
except ValueError:
raise ValueError("STAFF_API_KEY must be in the format 'id:secret'")
if not all([key_id, secret]):
raise ValueError("Both key ID and secret are required")
try:
secret_bytes = bytes.fromhex(secret)
except ValueError:
raise ValueError("Invalid secret format - must be hexadecimal")
now = datetime.datetime.now(datetime.UTC)
exp = now + datetime.timedelta(minutes=5)
payload = {
"iat": now,
"exp": exp,
"aud": audience,
"sub": key_id, # Add subject claim
"typ": "ghost-admin" # Add token type
}
token = jwt.encode(payload, secret_bytes, algorithm="HS256", headers={"kid": key_id})
if isinstance(token, bytes):
token = token.decode("utf-8")
return token
async def get_auth_headers(
staff_api_key: str,
api_version: str = DEFAULT_API_VERSION
) -> Dict[str, str]:
"""Get authenticated headers for Ghost API requests.
Args:
staff_api_key: API key in 'id:secret' format
api_version: Ghost API version to use (default: v5.109)
Returns:
Dictionary of request headers including authorization and version
Example:
>>> headers = await get_auth_headers("1234:abcd5678")
>>> headers
{
'Authorization': 'Ghost eyJ0eXAiOiJKV1...',
'Accept-Version': 'v5.109'
}
"""
token = await generate_token(staff_api_key)
return {
"Authorization": f"Ghost {token}",
"Accept-Version": api_version,
"Content-Type": "application/json"
}
async def make_ghost_request(
endpoint: str,
headers: Dict[str, str],
ctx: Optional[Context] = None,
is_resource: bool = False,
http_method: str = GET,
json_data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Make an authenticated request to the Ghost API.
Args:
endpoint: API endpoint to call (e.g. "posts" or "users")
headers: Request headers from get_auth_headers()
ctx: Optional context for logging (not used for resources)
is_resource: Whether this request is for a resource
http_method: HTTP method to use (GET, POST, PUT, or DELETE)
json_data: Optional JSON data for POST/PUT requests
Returns:
Parsed JSON response from the Ghost API
Raises:
GhostError: For any Ghost API errors including:
- Network connectivity issues
- Invalid authentication
- Rate limiting
- Server errors
ValueError: For invalid HTTP methods
Example:
>>> headers = await get_auth_headers("1234:abcd5678")
>>> response = await make_ghost_request(
... "posts",
... headers,
... http_method=GET
... )
"""
# Validate HTTP method
http_method = http_method.upper()
if http_method not in VALID_HTTP_METHODS:
raise ValueError(f"Invalid HTTP method: {http_method}")
# Ensure clean URL construction
base_url = f"{API_URL.rstrip('/')}/ghost/api/admin"
endpoint = endpoint.strip('/')
url = f"{base_url}/{endpoint}"
async with httpx.AsyncClient(follow_redirects=True) as client:
try:
# Map HTTP methods to client methods
method_map = {
GET: client.get,
POST: client.post,
PUT: client.put,
DELETE: client.delete
}
method_func = method_map[http_method]
# Make the request
if http_method in (POST, PUT):
response = await method_func(url, headers=headers, json=json_data)
else:
response = await method_func(url, headers=headers)
# Handle specific status codes
if http_method == DELETE and response.status_code == 204:
return {}
response.raise_for_status()
# Log success if context provided and not a resource request
if not is_resource and ctx:
ctx.log("info", f"API Request to {url} successful")
return response.json()
except httpx.HTTPError as e:
error_msg = f"HTTP error accessing Ghost API: {str(e)}"
if response := getattr(e, 'response', None):
error_msg += f" (Status: {response.status_code})"
if not is_resource and ctx:
ctx.error(error_msg)
raise GhostError(error_msg)
except Exception as e:
error_msg = f"Error accessing Ghost API: {str(e)}"
if not is_resource and ctx:
ctx.error(error_msg)
raise GhostError(error_msg)

View File

@@ -1,18 +0,0 @@
"""Configuration settings for Ghost MCP server."""
import os
from .exceptions import GhostError
# Ghost API Configuration
API_URL = os.getenv("GHOST_API_URL")
if not API_URL:
raise GhostError("GHOST_API_URL environment variable is required")
STAFF_API_KEY = os.getenv("GHOST_STAFF_API_KEY")
if not STAFF_API_KEY:
raise GhostError("GHOST_STAFF_API_KEY environment variable is required")
# Server Configuration
SERVER_NAME = "ghost"
SERVER_DESCRIPTION = "Ghost blog integration providing access to posts, users, members, tiers, offers, newsletters and site information"
SERVER_DEPENDENCIES = ["httpx", "pyjwt"]

View File

@@ -1,5 +0,0 @@
"""Custom exceptions for Ghost MCP server."""
class GhostError(Exception):
"""Custom exception for Ghost API errors."""
pass

View File

@@ -1,100 +0,0 @@
"""Data models for Ghost MCP server."""
from typing import TypedDict, List, Optional
class Post(TypedDict):
"""Ghost blog post data model."""
id: str
title: str
status: str
url: str
created_at: str
html: Optional[str]
plaintext: Optional[str]
excerpt: Optional[str]
class User(TypedDict):
"""Ghost user data model."""
id: str
name: str
email: str
slug: str
status: str
location: Optional[str]
website: Optional[str]
bio: Optional[str]
profile_image: Optional[str]
cover_image: Optional[str]
created_at: str
last_seen: Optional[str]
roles: List[dict]
class Member(TypedDict):
"""Ghost member data model."""
id: str
name: str
email: str
status: str
created_at: str
note: Optional[str]
labels: List[dict]
email_count: int
email_opened_count: int
email_open_rate: float
last_seen_at: Optional[str]
newsletters: List[dict]
subscriptions: List[dict]
class Tier(TypedDict):
"""Ghost tier data model."""
id: str
name: str
description: Optional[str]
type: str
active: bool
welcome_page_url: Optional[str]
created_at: str
updated_at: str
monthly_price: Optional[dict]
yearly_price: Optional[dict]
currency: str
benefits: List[str]
class Offer(TypedDict):
"""Ghost offer data model."""
id: str
name: str
code: str
display_title: str
display_description: Optional[str]
type: str
status: str
cadence: str
amount: float
duration: str
currency: str
tier: dict
redemption_count: int
created_at: str
class Newsletter(TypedDict):
"""Ghost newsletter data model."""
id: str
name: str
description: Optional[str]
status: str
visibility: str
subscribe_on_signup: bool
sort_order: int
sender_name: str
sender_email: Optional[str]
sender_reply_to: Optional[str]
show_header_icon: bool
show_header_title: bool
show_header_name: bool
show_feature_image: bool
title_font_category: str
body_font_category: str
show_badge: bool
created_at: str
updated_at: str

View File

@@ -1,162 +0,0 @@
"""MCP resource handlers for Ghost API."""
import json
import datetime
from typing import Any
from .api import make_ghost_request, get_auth_headers
from .config import API_URL, STAFF_API_KEY
from .exceptions import GhostError
async def handle_user_resource(user_id: str) -> str:
"""Get a user as a resource.
Args:
user_id: The ID of the user to retrieve
Returns:
JSON string containing user data
"""
try:
headers = await get_auth_headers(STAFF_API_KEY)
data = await make_ghost_request(
f"users/{user_id}/?include=roles",
headers,
is_resource=True
)
user = data["users"][0]
return json.dumps(user, indent=2)
except GhostError as e:
return json.dumps({"error": str(e)}, indent=2)
async def handle_member_resource(member_id: str) -> str:
"""Get a member as a resource.
Args:
member_id: The ID of the member to retrieve
Returns:
JSON string containing member data
"""
try:
headers = await get_auth_headers(STAFF_API_KEY)
data = await make_ghost_request(
f"members/{member_id}/?include=newsletters,subscriptions",
headers,
is_resource=True
)
member = data["members"][0]
return json.dumps(member, indent=2)
except GhostError as e:
return json.dumps({"error": str(e)}, indent=2)
async def handle_tier_resource(tier_id: str) -> str:
"""Get a tier as a resource.
Args:
tier_id: The ID of the tier to retrieve
Returns:
JSON string containing tier data
"""
try:
headers = await get_auth_headers(STAFF_API_KEY)
data = await make_ghost_request(
f"tiers/{tier_id}/?include=monthly_price,yearly_price,benefits",
headers,
is_resource=True
)
tier = data["tiers"][0]
return json.dumps(tier, indent=2)
except GhostError as e:
return json.dumps({"error": str(e)}, indent=2)
async def handle_offer_resource(offer_id: str) -> str:
"""Get an offer as a resource.
Args:
offer_id: The ID of the offer to retrieve
Returns:
JSON string containing offer data
"""
try:
headers = await get_auth_headers(STAFF_API_KEY)
data = await make_ghost_request(
f"offers/{offer_id}/",
headers,
is_resource=True
)
offer = data["offers"][0]
return json.dumps(offer, indent=2)
except GhostError as e:
return json.dumps({"error": str(e)}, indent=2)
async def handle_newsletter_resource(newsletter_id: str) -> str:
"""Get a newsletter as a resource.
Args:
newsletter_id: The ID of the newsletter to retrieve
Returns:
JSON string containing newsletter data
"""
try:
headers = await get_auth_headers(STAFF_API_KEY)
data = await make_ghost_request(
f"newsletters/{newsletter_id}/",
headers,
is_resource=True
)
newsletter = data["newsletters"][0]
return json.dumps(newsletter, indent=2)
except GhostError as e:
return json.dumps({"error": str(e)}, indent=2)
async def handle_post_resource(post_id: str) -> str:
"""Get a blog post as a resource.
Args:
post_id: The ID of the post to retrieve
Returns:
JSON string containing post data
"""
try:
headers = await get_auth_headers(STAFF_API_KEY)
data = await make_ghost_request(
f"posts/{post_id}/?formats=html",
headers,
is_resource=True
)
post = data["posts"][0]
return json.dumps({
"title": post.get('title'),
"html": post.get('html'),
"excerpt": post.get('excerpt'),
"url": post.get('url'),
"created_at": post.get('created_at')
}, indent=2)
except GhostError as e:
return json.dumps({"error": str(e)}, indent=2)
async def handle_blog_info() -> str:
"""Get general blog information as a resource.
Returns:
JSON string containing blog information
"""
try:
headers = await get_auth_headers(STAFF_API_KEY)
data = await make_ghost_request("site", headers, is_resource=True)
site = data["site"]
return json.dumps({
"title": site.get('title'),
"description": site.get('description'),
"url": API_URL,
"last_updated": datetime.datetime.now(datetime.UTC).isoformat()
}, indent=2)
except GhostError as e:
return json.dumps({"error": str(e)}, indent=2)

View File

@@ -1,89 +0,0 @@
"""MCP server setup and initialization."""
from mcp.server.fastmcp import FastMCP, Context
import inspect
from . import tools, resources
from .config import (
SERVER_NAME,
SERVER_DEPENDENCIES,
SERVER_DESCRIPTION
)
from .exceptions import GhostError
def register_resources(mcp: FastMCP) -> None:
"""Register all resource handlers."""
resource_mappings = {
"user://{user_id}": resources.handle_user_resource,
"member://{member_id}": resources.handle_member_resource,
"tier://{tier_id}": resources.handle_tier_resource,
"offer://{offer_id}": resources.handle_offer_resource,
"newsletter://{newsletter_id}": resources.handle_newsletter_resource,
"post://{post_id}": resources.handle_post_resource,
"blog://info": resources.handle_blog_info
}
for uri_template, handler in resource_mappings.items():
mcp.resource(uri_template)(handler)
def register_tools(mcp: FastMCP) -> None:
"""Register only the main ghost tool (which provides access to all functionality)."""
# Register only the main ghost tool
mcp.tool()(tools.ghost)
def register_prompts(mcp: FastMCP) -> None:
"""Register all prompt templates."""
@mcp.prompt()
def search_blog() -> str:
"""Prompt template for searching blog posts"""
return """I want to help you search the blog posts. You can:
1. Search by title with: ghost(action="search_posts_by_title", params={"query": "your search term"})
2. List all posts with: ghost(action="list_posts")
3. Read a specific post with: ghost(action="read_post", params={"post_id": "post_id"})
What would you like to search for?"""
@mcp.prompt()
def create_summary(post_id: str) -> str:
"""Create a prompt to summarize a blog post"""
return f"""Please read the following blog post and provide a concise summary:
Resource: post://{post_id}
Alternatively, you can also get the post content with:
ghost(action="read_post", params={{"post_id": "{post_id}"}})
Key points to include in your summary:
1. Main topic/theme
2. Key arguments or insights
3. Important conclusions
4. Any actionable takeaways"""
def create_server() -> FastMCP:
"""Create and configure the Ghost MCP server.
Returns:
Configured FastMCP server instance
"""
# Initialize FastMCP server
mcp = FastMCP(
SERVER_NAME,
dependencies=SERVER_DEPENDENCIES,
description=SERVER_DESCRIPTION,
log_level="WARNING" # Set log level to reduce verbosity
)
# Set up error handler
async def handle_error(error: Exception) -> None:
if isinstance(error, GhostError):
mcp.log.error(f"Ghost API Error: {str(error)}")
else:
mcp.log.error(f"Server Error: {str(error)}")
mcp.on_error = handle_error
# Register all components
register_resources(mcp)
register_tools(mcp)
register_prompts(mcp)
return mcp

View File

@@ -1,81 +0,0 @@
from .invites import create_invite
from .members import list_members, update_member, read_member, create_member
from .newsletters import list_newsletters, read_newsletter, create_newsletter, update_newsletter
from .offers import list_offers, read_offer, create_offer, update_offer
from .posts import (
list_posts,
search_posts_by_title,
read_post,
create_post,
update_post,
delete_post,
batchly_update_posts,
)
from .roles import list_roles
from .tags import browse_tags, read_tag, create_tag, update_tag, delete_tag
from .tiers import list_tiers, read_tier, create_tier, update_tier
from .users import list_users, read_user, delete_user
from .webhooks import create_webhook, update_webhook, delete_webhook
from .ghost import ghost
# Hidden tools - these are accessible through the ghost meta-tool but not exposed directly
_all_tools = [
# Invites
"create_invite",
# Members
"list_members",
"read_member",
"create_member",
"update_member",
# Newsletters
"list_newsletters",
"read_newsletter",
"create_newsletter",
"update_newsletter",
# Offers
"list_offers",
"read_offer",
"create_offer",
"update_offer",
# Posts
"list_posts",
"search_posts_by_title",
"read_post",
"create_post",
"update_post",
"delete_post",
"batchly_update_posts",
# Roles
"list_roles",
# Tags
"browse_tags",
"read_tag",
"create_tag",
"update_tag",
"delete_tag",
# Tiers
"list_tiers",
"read_tier",
"create_tier",
"update_tier",
# Users
"list_users",
"read_user",
"delete_user",
# Webhooks
"create_webhook",
"update_webhook",
"delete_webhook",
]
# Only expose the ghost meta-tool publicly
__all__ = ["ghost"]

View File

@@ -1,91 +0,0 @@
"""Main Ghost meta-tool that provides access to all Ghost functionality."""
import inspect
from mcp.server.fastmcp import Context
from typing import Any, Dict, Optional, List
from .. import tools
from ..exceptions import GhostError
async def ghost(
action: str,
params: Optional[Dict[str, Any]] = None,
ctx: Optional[Context] = None
) -> str:
"""Central Ghost tool that provides access to all Ghost CMS functionality.
Args:
action: The specific Ghost action to perform.
Available actions:
- Posts: list_posts, search_posts_by_title, read_post, create_post, update_post, delete_post, batchly_update_posts
- Users: list_users, read_user, delete_user, list_roles
- Members: list_members, read_member, create_member, update_member
- Tags: browse_tags, read_tag, create_tag, update_tag, delete_tag
- Tiers: list_tiers, read_tier, create_tier, update_tier
- Offers: list_offers, read_offer, create_offer, update_offer
- Newsletters: list_newsletters, read_newsletter, create_newsletter, update_newsletter
- Webhooks: create_webhook, update_webhook, delete_webhook
- Invites: create_invite
params: Dictionary of parameters specific to the chosen action.
Required parameters vary by action.
ctx: Optional context for logging
Returns:
Response from the specified Ghost action
Raises:
GhostError: If there is an error processing the request
"""
if ctx:
ctx.info(f"Ghost tool called with action: {action}, params: {params}")
# Validate action
if action not in tools._all_tools:
valid_actions = ", ".join(tools._all_tools)
return f"Invalid action '{action}'. Valid actions are: {valid_actions}"
# Get the function for the specified action
tool_func = getattr(tools, action)
if not inspect.isfunction(tool_func):
return f"Invalid action '{action}'. This is not a valid function."
# Prepare parameters for the function call
if params is None:
params = {}
# Add context to params if the function expects it
sig = inspect.signature(tool_func)
call_params = params.copy()
if 'ctx' in sig.parameters:
call_params['ctx'] = ctx
try:
# Call the function with the appropriate parameters
result = await tool_func(**call_params)
return result
except GhostError as e:
if ctx:
ctx.error(f"Ghost tool error for action '{action}': {str(e)}")
return f"Error executing '{action}': {str(e)}"
except TypeError as e:
# This usually happens when the wrong parameters are provided
if ctx:
ctx.error(f"Parameter error for action '{action}': {str(e)}")
# Get the function parameters to provide better error messages
params_info = []
for name, param in sig.parameters.items():
if name == 'ctx':
continue
param_type = param.annotation.__name__ if param.annotation != inspect.Parameter.empty else "any"
default = f"(default: {param.default})" if param.default != inspect.Parameter.empty else "(required)"
params_info.append(f"- {name}: {param_type} {default}")
params_help = "\n".join(params_info)
return f"Error: {str(e)}\n\nExpected parameters for '{action}':\n{params_help}"
except Exception as e:
if ctx:
ctx.error(f"Unexpected error for action '{action}': {str(e)}")
return f"Unexpected error executing '{action}': {str(e)}"

View File

@@ -1,75 +0,0 @@
"""Invite-related MCP tools for Ghost API."""
import json
from mcp.server.fastmcp import Context
from ..api import make_ghost_request, get_auth_headers
from ..config import STAFF_API_KEY
from ..exceptions import GhostError
async def create_invite(
role_id: str,
email: str,
ctx: Context = None
) -> str:
"""Create a staff user invite in Ghost.
Args:
role_id: ID of the role to assign to the invited user (required)
email: Email address to send the invite to (required)
ctx: Optional context for logging
Returns:
String representation of the created invite
Raises:
GhostError: If the Ghost API request fails
ValueError: If required parameters are missing or invalid
"""
if not all([role_id, email]):
raise ValueError("Both role_id and email are required for creating an invite")
if ctx:
ctx.info(f"Creating invite for email: {email} with role: {role_id}")
# Construct invite data
invite_data = {
"invites": [{
"role_id": role_id,
"email": email
}]
}
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 invite")
response = await make_ghost_request(
"invites/",
headers,
ctx,
http_method="POST",
json_data=invite_data
)
if ctx:
ctx.debug("Processing created invite response")
invite = response.get("invites", [{}])[0]
return f"""
Invite created successfully:
Email: {invite.get('email')}
Role ID: {invite.get('role_id')}
Status: {invite.get('status', 'sent')}
Created: {invite.get('created_at', 'Unknown')}
Expires: {invite.get('expires', 'Unknown')}
ID: {invite.get('id')}
"""
except Exception as e:
if ctx:
ctx.error(f"Failed to create invite: {str(e)}")
raise

View File

@@ -1,342 +0,0 @@
"""Member-related MCP tools for Ghost API."""
import json
from mcp.server.fastmcp import Context
from ..api import make_ghost_request, get_auth_headers
from ..config import STAFF_API_KEY
from ..exceptions import GhostError
async def list_members(
format: str = "text",
page: int = 1,
limit: int = 15,
ctx: Context = None
) -> str:
"""Get the list of members from your Ghost blog.
Args:
format: Output format - either "text" or "json" (default: "text")
page: Page number for pagination (default: 1)
limit: Number of members per page (default: 15)
ctx: Optional context for logging
Returns:
Formatted string containing member information
"""
if ctx:
ctx.info(f"Listing members (page {page}, limit {limit}, format {format})")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
if ctx:
ctx.debug("Making API request to /members/ with pagination")
data = await make_ghost_request(
f"members/?page={page}&limit={limit}&include=newsletters,subscriptions",
headers,
ctx
)
if ctx:
ctx.debug("Processing members list response")
members = data.get("members", [])
if not members:
if ctx:
ctx.info("No members found in response")
return "No members found."
if format.lower() == "json":
if ctx:
ctx.debug("Returning JSON format")
return json.dumps(members, indent=2)
formatted_members = []
for member in members:
newsletters = [nl.get('name') for nl in member.get('newsletters', [])]
formatted_member = f"""
Name: {member.get('name', 'Unknown')}
Email: {member.get('email', 'Unknown')}
Status: {member.get('status', 'Unknown')}
Newsletters: {', '.join(newsletters) if newsletters else 'None'}
Created: {member.get('created_at', 'Unknown')}
ID: {member.get('id', 'Unknown')}
"""
formatted_members.append(formatted_member)
return "\n---\n".join(formatted_members)
except GhostError as e:
if ctx:
ctx.error(f"Failed to list members: {str(e)}")
return str(e)
async def update_member(
member_id: str,
email: str = None,
name: str = None,
note: str = None,
labels: list = None,
newsletter_ids: list = None,
ctx: Context = None
) -> str:
"""Update an existing member in Ghost.
Args:
member_id: ID of the member to update (required)
email: New email address for the member (optional)
name: New name for the member (optional)
note: New notes about the member (optional)
labels: New list of labels. Each label should be a dict with 'name' and 'slug' (optional)
newsletter_ids: New list of newsletter IDs to subscribe the member to (optional)
ctx: Optional context for logging
Returns:
String representation of the updated member
Raises:
GhostError: If the Ghost API request fails
ValueError: If no fields to update are provided
"""
# Check if at least one field to update is provided
if not any([email, name, note, labels, newsletter_ids]):
raise ValueError("At least one field must be provided to update")
if ctx:
ctx.info(f"Updating member with ID: {member_id}")
# Construct update data with only provided fields
update_data = {"members": [{}]}
member_updates = update_data["members"][0]
if email is not None:
member_updates["email"] = email
if name is not None:
member_updates["name"] = name
if note is not None:
member_updates["note"] = note
if labels is not None:
member_updates["labels"] = labels
if newsletter_ids is not None:
member_updates["newsletters"] = [
{"id": newsletter_id} for newsletter_id in newsletter_ids
]
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 member {member_id}")
response = await make_ghost_request(
f"members/{member_id}/",
headers,
ctx,
http_method="PUT",
json_data=update_data
)
if ctx:
ctx.debug("Processing updated member response")
member = response.get("members", [{}])[0]
newsletters = [nl.get('name') for nl in member.get('newsletters', [])]
subscriptions = member.get('subscriptions', [])
subscription_info = ""
if subscriptions:
for sub in subscriptions:
subscription_info += f"""
Subscription Details:
Status: {sub.get('status', 'Unknown')}
Start Date: {sub.get('start_date', 'Unknown')}
Current Period Ends: {sub.get('current_period_end', 'Unknown')}
Price: {sub.get('price', {}).get('nickname', 'Unknown')} ({sub.get('price', {}).get('amount', 0)} {sub.get('price', {}).get('currency', 'USD')})
"""
return f"""
Member updated successfully:
Name: {member.get('name', 'Unknown')}
Email: {member.get('email')}
Status: {member.get('status', 'free')}
Newsletters: {', '.join(newsletters) if newsletters else 'None'}
Created: {member.get('created_at', 'Unknown')}
Updated: {member.get('updated_at', 'Unknown')}
Note: {member.get('note', 'No notes')}
Labels: {', '.join(label.get('name', '') for label in member.get('labels', []))}
Email Count: {member.get('email_count', 0)}
Email Open Rate: {member.get('email_open_rate', 0)}%
Last Seen At: {member.get('last_seen_at', 'Never')}{subscription_info}
ID: {member.get('id')}
"""
except Exception as e:
if ctx:
ctx.error(f"Failed to update member: {str(e)}")
raise
async def create_member(
email: str,
name: str = None,
note: str = None,
labels: list = None,
newsletter_ids: list = None,
ctx: Context = None
) -> str:
"""Create a new member in Ghost.
Args:
email: Member's email address (required)
name: Member's name (optional)
note: Notes about the member (optional)
labels: List of labels to apply to the member. Each label should be a dict with 'name' and 'slug' (optional)
newsletter_ids: List of newsletter IDs to subscribe the member to (optional)
ctx: Optional context for logging
Returns:
String representation of the created member
Raises:
GhostError: If the Ghost API request fails
ValueError: If required parameters are missing or invalid
"""
if not email:
raise ValueError("Email is required for creating a member")
if ctx:
ctx.info(f"Creating new member with email: {email}")
# Construct member data
member_data = {
"members": [{
"email": email
}]
}
# Add optional fields if provided
if name:
member_data["members"][0]["name"] = name
if note:
member_data["members"][0]["note"] = note
if labels:
member_data["members"][0]["labels"] = labels
if newsletter_ids:
member_data["members"][0]["newsletters"] = [
{"id": newsletter_id} for newsletter_id in newsletter_ids
]
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 member")
response = await make_ghost_request(
"members/",
headers,
ctx,
http_method="POST",
json_data=member_data
)
if ctx:
ctx.debug("Processing created member response")
member = response.get("members", [{}])[0]
newsletters = [nl.get('name') for nl in member.get('newsletters', [])]
subscriptions = member.get('subscriptions', [])
subscription_info = ""
if subscriptions:
for sub in subscriptions:
subscription_info += f"""
Subscription Details:
Status: {sub.get('status', 'Unknown')}
Start Date: {sub.get('start_date', 'Unknown')}
Current Period Ends: {sub.get('current_period_end', 'Unknown')}
Price: {sub.get('price', {}).get('nickname', 'Unknown')} ({sub.get('price', {}).get('amount', 0)} {sub.get('price', {}).get('currency', 'USD')})
"""
return f"""
Member created successfully:
Name: {member.get('name', 'Unknown')}
Email: {member.get('email')}
Status: {member.get('status', 'free')}
Newsletters: {', '.join(newsletters) if newsletters else 'None'}
Created: {member.get('created_at', 'Unknown')}
Note: {member.get('note', 'No notes')}
Labels: {', '.join(label.get('name', '') for label in member.get('labels', []))}
Email Count: {member.get('email_count', 0)}
Email Open Rate: {member.get('email_open_rate', 0)}%
Last Seen At: {member.get('last_seen_at', 'Never')}{subscription_info}
ID: {member.get('id')}
"""
except Exception as e:
if ctx:
ctx.error(f"Failed to create member: {str(e)}")
raise
async def read_member(member_id: str, ctx: Context = None) -> str:
"""Get the details of a specific member.
Args:
member_id: The ID of the member to retrieve
ctx: Optional context for logging
Returns:
Formatted string containing the member details
"""
if ctx:
ctx.info(f"Reading member details for ID: {member_id}")
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 /members/{member_id}/")
data = await make_ghost_request(
f"members/{member_id}/?include=newsletters,subscriptions",
headers,
ctx
)
if ctx:
ctx.debug("Processing member response data")
member = data["members"][0]
newsletters = [nl.get('name') for nl in member.get('newsletters', [])]
subscriptions = member.get('subscriptions', [])
subscription_info = ""
if subscriptions:
for sub in subscriptions:
subscription_info += f"""
Subscription Details:
Status: {sub.get('status', 'Unknown')}
Start Date: {sub.get('start_date', 'Unknown')}
Current Period Ends: {sub.get('current_period_end', 'Unknown')}
Price: {sub.get('price', {}).get('nickname', 'Unknown')} ({sub.get('price', {}).get('amount', 0)} {sub.get('price', {}).get('currency', 'USD')})
"""
return f"""
Name: {member.get('name', 'Unknown')}
Email: {member.get('email', 'Unknown')}
Status: {member.get('status', 'Unknown')}
Newsletters: {', '.join(newsletters) if newsletters else 'None'}
Created: {member.get('created_at', 'Unknown')}
Note: {member.get('note', 'No notes')}
Labels: {', '.join(label.get('name', '') for label in member.get('labels', []))}
Email Count: {member.get('email_count', 0)}
Email Opened Count: {member.get('email_opened_count', 0)}
Email Open Rate: {member.get('email_open_rate', 0)}%
Last Seen At: {member.get('last_seen_at', 'Never')}{subscription_info}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to read member: {str(e)}")
return str(e)

View File

@@ -1,328 +0,0 @@
"""Newsletter-related MCP tools for Ghost API."""
import json
from mcp.server.fastmcp import Context
from ..api import make_ghost_request, get_auth_headers
from ..config import STAFF_API_KEY
from ..exceptions import GhostError
async def list_newsletters(
format: str = "text",
page: int = 1,
limit: int = 15,
ctx: Context = None
) -> str:
"""Get the list of newsletters from your Ghost blog.
Args:
format: Output format - either "text" or "json" (default: "text")
page: Page number for pagination (default: 1)
limit: Number of newsletters per page (default: 15)
ctx: Optional context for logging
Returns:
Formatted string containing newsletter information
"""
if ctx:
ctx.info(f"Listing newsletters (page {page}, limit {limit}, format {format})")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
if ctx:
ctx.debug("Making API request to /newsletters/ with pagination")
data = await make_ghost_request(
f"newsletters/?page={page}&limit={limit}",
headers,
ctx
)
if ctx:
ctx.debug("Processing newsletters list response")
newsletters = data.get("newsletters", [])
if not newsletters:
if ctx:
ctx.info("No newsletters found in response")
return "No newsletters found."
if format.lower() == "json":
if ctx:
ctx.debug("Returning JSON format")
return json.dumps(newsletters, indent=2)
formatted_newsletters = []
for newsletter in newsletters:
formatted_newsletter = f"""
Name: {newsletter.get('name', 'Unknown')}
Description: {newsletter.get('description', 'No description')}
Status: {newsletter.get('status', 'Unknown')}
Visibility: {newsletter.get('visibility', 'Unknown')}
Subscribe on Signup: {newsletter.get('subscribe_on_signup', False)}
ID: {newsletter.get('id', 'Unknown')}
"""
formatted_newsletters.append(formatted_newsletter)
return "\n---\n".join(formatted_newsletters)
except GhostError as e:
if ctx:
ctx.error(f"Failed to list newsletters: {str(e)}")
return str(e)
async def read_newsletter(newsletter_id: str, ctx: Context = None) -> str:
"""Get the details of a specific newsletter.
Args:
newsletter_id: The ID of the newsletter to retrieve
ctx: Optional context for logging
Returns:
Formatted string containing the newsletter details
"""
if ctx:
ctx.info(f"Reading newsletter details for ID: {newsletter_id}")
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 /newsletters/{newsletter_id}/")
data = await make_ghost_request(
f"newsletters/{newsletter_id}/",
headers,
ctx
)
if ctx:
ctx.debug("Processing newsletter response data")
newsletter = data["newsletters"][0]
return f"""
Name: {newsletter.get('name', 'Unknown')}
Description: {newsletter.get('description', 'No description')}
Status: {newsletter.get('status', 'Unknown')}
Visibility: {newsletter.get('visibility', 'Unknown')}
Subscribe on Signup: {newsletter.get('subscribe_on_signup', False)}
Sort Order: {newsletter.get('sort_order', 0)}
Sender Email: {newsletter.get('sender_email', 'Not set')}
Sender Reply To: {newsletter.get('sender_reply_to', 'Not set')}
Show Header Icon: {newsletter.get('show_header_icon', True)}
Show Header Title: {newsletter.get('show_header_title', True)}
Show Header Name: {newsletter.get('show_header_name', True)}
Show Feature Image: {newsletter.get('show_feature_image', True)}
Title Font Category: {newsletter.get('title_font_category', 'Unknown')}
Body Font Category: {newsletter.get('body_font_category', 'Unknown')}
Show Badge: {newsletter.get('show_badge', True)}
Created: {newsletter.get('created_at', 'Unknown')}
Updated: {newsletter.get('updated_at', 'Unknown')}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to read newsletter: {str(e)}")
return str(e)
async def create_newsletter(
name: str,
description: str = None,
status: str = "active",
subscribe_on_signup: bool = True,
opt_in_existing: bool = False,
sender_reply_to: str = "newsletter",
show_header_icon: bool = True,
show_header_title: bool = True,
show_header_name: bool = True,
show_feature_image: bool = True,
title_font_category: str = "sans_serif",
title_alignment: str = "center",
body_font_category: str = "sans_serif",
show_badge: bool = True,
ctx: Context = None
) -> str:
"""Create a new newsletter.
Args:
name: Name of the newsletter (required)
description: Newsletter description
status: Newsletter status ("active" or "archived")
subscribe_on_signup: Whether to subscribe new members automatically
opt_in_existing: Whether to subscribe existing members
sender_reply_to: Reply-to setting ("newsletter" or "support")
show_header_icon: Whether to show header icon
show_header_title: Whether to show header title
show_header_name: Whether to show header name
show_feature_image: Whether to show feature image
title_font_category: Font category for titles
title_alignment: Title alignment
body_font_category: Font category for body text
show_badge: Whether to show badge
ctx: Optional context for logging
Returns:
Formatted string containing the created newsletter details
"""
if ctx:
ctx.info(f"Creating new newsletter: {name}")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
newsletter_data = {
"newsletters": [{
"name": name,
"description": description,
"status": status,
"subscribe_on_signup": subscribe_on_signup,
"sender_reply_to": sender_reply_to,
"show_header_icon": show_header_icon,
"show_header_title": show_header_title,
"show_header_name": show_header_name,
"show_feature_image": show_feature_image,
"title_font_category": title_font_category,
"title_alignment": title_alignment,
"body_font_category": body_font_category,
"show_badge": show_badge
}]
}
if ctx:
ctx.debug("Making API request to create newsletter")
endpoint = f"newsletters/?opt_in_existing={'true' if opt_in_existing else 'false'}"
data = await make_ghost_request(
endpoint,
headers,
ctx,
http_method="POST",
json_data=newsletter_data
)
if ctx:
ctx.debug("Processing create newsletter response")
newsletter = data["newsletters"][0]
return f"""
Newsletter created successfully!
Name: {newsletter.get('name')}
Description: {newsletter.get('description', 'No description')}
Status: {newsletter.get('status')}
ID: {newsletter.get('id')}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to create newsletter: {str(e)}")
return str(e)
async def update_newsletter(
newsletter_id: str,
name: str = None,
description: str = None,
sender_name: str = None,
sender_email: str = None,
sender_reply_to: str = None,
status: str = None,
subscribe_on_signup: bool = None,
sort_order: int = None,
header_image: str = None,
show_header_icon: bool = None,
show_header_title: bool = None,
show_header_name: bool = None,
title_font_category: str = None,
title_alignment: str = None,
show_feature_image: bool = None,
body_font_category: str = None,
footer_content: str = None,
show_badge: bool = None,
ctx: Context = None
) -> str:
"""Update an existing newsletter.
Args:
newsletter_id: ID of the newsletter to update (required)
name: New newsletter name
description: New newsletter description
sender_name: Name shown in email clients
sender_email: Email address newsletters are sent from
sender_reply_to: Reply-to setting ("newsletter" or "support")
status: Newsletter status ("active" or "archived")
subscribe_on_signup: Whether to subscribe new members automatically
sort_order: Order in lists
header_image: URL of header image
show_header_icon: Whether to show header icon
show_header_title: Whether to show header title
show_header_name: Whether to show header name
title_font_category: Font category for titles
title_alignment: Title alignment
show_feature_image: Whether to show feature image
body_font_category: Font category for body text
footer_content: Custom footer content
show_badge: Whether to show badge
ctx: Optional context for logging
Returns:
Formatted string containing the updated newsletter details
"""
if ctx:
ctx.info(f"Updating newsletter with ID: {newsletter_id}")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
# Build update data with only provided fields
update_data = {"newsletters": [{"id": newsletter_id}]}
# Add non-None values to the update data
fields = locals()
for field in [
"name", "description", "sender_name", "sender_email",
"sender_reply_to", "status", "subscribe_on_signup",
"sort_order", "header_image", "show_header_icon",
"show_header_title", "show_header_name", "title_font_category",
"title_alignment", "show_feature_image", "body_font_category",
"footer_content", "show_badge"
]:
if fields[field] is not None:
update_data["newsletters"][0][field] = fields[field]
if ctx:
ctx.debug(f"Making API request to update newsletter {newsletter_id}")
data = await make_ghost_request(
f"newsletters/{newsletter_id}/",
headers,
ctx,
http_method="PUT",
json_data=update_data
)
if ctx:
ctx.debug("Processing update newsletter response")
newsletter = data["newsletters"][0]
return f"""
Newsletter updated successfully!
Name: {newsletter.get('name')}
Description: {newsletter.get('description', 'No description')}
Status: {newsletter.get('status')}
Sender Name: {newsletter.get('sender_name', 'Not set')}
Sender Email: {newsletter.get('sender_email', 'Not set')}
Sort Order: {newsletter.get('sort_order', 0)}
ID: {newsletter.get('id')}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to update newsletter: {str(e)}")
return str(e)

View File

@@ -1,338 +0,0 @@
"""Offer-related MCP tools for Ghost API."""
import json
from mcp.server.fastmcp import Context
from ..api import make_ghost_request, get_auth_headers
from ..config import STAFF_API_KEY
from ..exceptions import GhostError
async def list_offers(
format: str = "text",
page: int = 1,
limit: int = 15,
ctx: Context = None
) -> str:
"""Get the list of offers from your Ghost blog.
Args:
format: Output format - either "text" or "json" (default: "text")
page: Page number for pagination (default: 1)
limit: Number of offers per page (default: 15)
ctx: Optional context for logging
Returns:
Formatted string containing offer information
"""
if ctx:
ctx.info(f"Listing offers (page {page}, limit {limit}, format {format})")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
if ctx:
ctx.debug("Making API request to /offers/ with pagination")
data = await make_ghost_request(
f"offers/?page={page}&limit={limit}",
headers,
ctx
)
if ctx:
ctx.debug("Processing offers list response")
offers = data.get("offers", [])
if not offers:
if ctx:
ctx.info("No offers found in response")
return "No offers found."
if format.lower() == "json":
if ctx:
ctx.debug("Returning JSON format")
return json.dumps(offers, indent=2)
formatted_offers = []
for offer in offers:
formatted_offer = f"""
Name: {offer.get('name', 'Unknown')}
Code: {offer.get('code', 'Unknown')}
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')} d
Redemption Count: {offer.get('redemption_count', 0)}
Tier: {offer.get('tier', {}).get('name', 'Unknown')}
ID: {offer.get('id', 'Unknown')}
"""
formatted_offers.append(formatted_offer)
return "\n---\n".join(formatted_offers)
except GhostError as e:
if ctx:
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.
Args:
offer_id: The ID of the offer to retrieve
ctx: Optional context for logging
Returns:
Formatted string containing the offer details
"""
if ctx:
ctx.info(f"Reading offer details for ID: {offer_id}")
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 /offers/{offer_id}/")
data = await make_ghost_request(
f"offers/{offer_id}/",
headers,
ctx
)
if ctx:
ctx.debug("Processing offer response data")
offer = data["offers"][0]
return f"""
Name: {offer.get('name', 'Unknown')}
Code: {offer.get('code', 'Unknown')}
Display Title: {offer.get('display_title', 'No display title')}
Display Description: {offer.get('display_description', 'No description')}
Type: {offer.get('type', 'Unknown')}
Status: {offer.get('status', 'Unknown')}
Cadence: {offer.get('cadence', 'Unknown')}
Amount: {offer.get('amount', 'Unknown')}
Duration: {offer.get('duration', 'Unknown')}
Currency: {offer.get('currency', 'N/A')}
Tier: {offer.get('tier', {}).get('name', 'Unknown')}
Redemption Count: {offer.get('redemption_count', 0)}
Created: {offer.get('created_at', 'Unknown')}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to read offer: {str(e)}")
return str(e)

View File

@@ -1,786 +0,0 @@
"""Post-related MCP tools for Ghost API."""
import json
from difflib import get_close_matches
from mcp.server.fastmcp import Context
from ..api import make_ghost_request, get_auth_headers
from ..config import STAFF_API_KEY
from ..exceptions import GhostError
async def search_posts_by_title(query: str, exact: bool = False, ctx: Context = None) -> str:
"""Search for posts by title.
Args:
query: The title or part of the title to search for
exact: If True, only return exact matches (default: False)
ctx: Optional context for logging
Returns:
Formatted string containing matching post information
Raises:
GhostError: If there is an error accessing the Ghost API
"""
if ctx:
ctx.info(f"Searching posts with title query: {query} (exact: {exact})")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
if ctx:
ctx.debug("Making API request to /posts/")
data = await make_ghost_request("posts", headers, ctx)
if ctx:
ctx.debug("Processing search results")
posts = data.get("posts", [])
matches = []
if ctx:
ctx.debug(f"Found {len(posts)} total posts to search through")
if exact:
if ctx:
ctx.debug("Performing exact title match")
matches = [post for post in posts if post.get('title', '').lower() == query.lower()]
else:
if ctx:
ctx.debug("Performing fuzzy title match")
titles = [post.get('title', '') for post in posts]
matching_titles = get_close_matches(query, titles, n=5, cutoff=0.3)
matches = [post for post in posts if post.get('title', '') in matching_titles]
if not matches:
if ctx:
ctx.info(f"No posts found matching query: {query}")
return f"No posts found matching '{query}'"
formatted_matches = []
for post in matches:
formatted_match = f"""
Title: {post.get('title', 'Untitled')}
Status: {post.get('status', 'Unknown')}
URL: {post.get('url', 'No URL')}
Created: {post.get('created_at', 'Unknown')}
ID: {post.get('id', 'Unknown')}
"""
formatted_matches.append(formatted_match)
return "\n---\n".join(formatted_matches)
except GhostError as e:
if ctx:
ctx.error(f"Failed to search posts: {str(e)}")
return str(e)
async def list_posts(
format: str = "text",
page: int = 1,
limit: int = 15,
ctx: Context = None
) -> str:
"""Get the list of posts from your Ghost blog.
Args:
format: Output format - either "text" or "json" (default: "text")
page: Page number for pagination (default: 1)
limit: Number of posts per page (default: 15)
ctx: Optional context for logging
Returns:
Formatted string containing post information
Raises:
GhostError: If there is an error accessing the Ghost API
"""
if ctx:
ctx.info(f"Listing posts (page {page}, limit {limit}, format {format})")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
if ctx:
ctx.debug("Making API request to /posts/ with pagination")
data = await make_ghost_request(
f"posts/?page={page}&limit={limit}",
headers,
ctx
)
if ctx:
ctx.debug("Processing posts list response")
posts = data.get("posts", [])
if not posts:
if ctx:
ctx.info("No posts found in response")
return "No posts found."
if format.lower() == "json":
if ctx:
ctx.debug("Formatting posts in JSON format")
formatted_posts = [{
"id": post.get('id', 'Unknown'),
"title": post.get('title', 'Untitled'),
"status": post.get('status', 'Unknown'),
"url": post.get('url', 'No URL'),
"created_at": post.get('created_at', 'Unknown')
} for post in posts]
return json.dumps(formatted_posts, indent=2)
formatted_posts = []
for post in posts:
formatted_post = f"""
Title: {post.get('title', 'Untitled')}
Status: {post.get('status', 'Unknown')}
URL: {post.get('url', 'No URL')}
Created: {post.get('created_at', 'Unknown')}
ID: {post.get('id', 'Unknown')}
"""
formatted_posts.append(formatted_post)
return "\n---\n".join(formatted_posts)
except GhostError as e:
if ctx:
ctx.error(f"Failed to list posts: {str(e)}")
return str(e)
async def read_post(post_id: str, ctx: Context = None) -> str:
"""Get the full content and metadata of a specific blog post.
Args:
post_id: The ID of the post to retrieve
ctx: Optional context for logging
Returns:
Formatted string containing all post details including:
- Basic info (title, slug, status, etc)
- Content in both HTML and Lexical formats
- Feature image details
- Meta fields (SEO, Open Graph, Twitter)
- Authors and tags
- Email settings
- Timestamps
Raises:
GhostError: If there is an error accessing the Ghost API
"""
if ctx:
ctx.info(f"Reading post content for ID: {post_id}")
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 /posts/{post_id}/")
data = await make_ghost_request(
f"posts/{post_id}/?formats=html,lexical&include=tags,authors",
headers,
ctx
)
if ctx:
ctx.debug("Processing post response data")
post = data["posts"][0]
# Format tags and authors
tags = [tag.get('name', 'Unknown') for tag in post.get('tags', [])]
authors = [author.get('name', 'Unknown') for author in post.get('authors', [])]
# Get content
html_content = post.get('html', 'No HTML content available')
lexical_content = post.get('lexical', 'No Lexical content available')
return f"""
Post Details:
Basic Information:
Title: {post.get('title', 'Untitled')}
Slug: {post.get('slug', 'No slug')}
Status: {post.get('status', 'Unknown')}
Visibility: {post.get('visibility', 'Unknown')}
Featured: {post.get('featured', False)}
URL: {post.get('url', 'No URL')}
Content Formats:
HTML Content:
{html_content}
Lexical Content:
{lexical_content}
Images:
Feature Image: {post.get('feature_image', 'None')}
Feature Image Alt: {post.get('feature_image_alt', 'None')}
Feature Image Caption: {post.get('feature_image_caption', 'None')}
Meta Information:
Meta Title: {post.get('meta_title', 'None')}
Meta Description: {post.get('meta_description', 'None')}
Canonical URL: {post.get('canonical_url', 'None')}
Custom Excerpt: {post.get('custom_excerpt', 'None')}
Open Graph:
OG Image: {post.get('og_image', 'None')}
OG Title: {post.get('og_title', 'None')}
OG Description: {post.get('og_description', 'None')}
Twitter Card:
Twitter Image: {post.get('twitter_image', 'None')}
Twitter Title: {post.get('twitter_title', 'None')}
Twitter Description: {post.get('twitter_description', 'None')}
Code Injection:
Header Code: {post.get('codeinjection_head', 'None')}
Footer Code: {post.get('codeinjection_foot', 'None')}
Template:
Custom Template: {post.get('custom_template', 'None')}
Relationships:
Tags: {', '.join(tags) if tags else 'None'}
Authors: {', '.join(authors) if authors else 'None'}
Email Settings:
Email Only: {post.get('email_only', False)}
Email Subject: {post.get('email', {}).get('subject', 'None')}
Timestamps:
Created: {post.get('created_at', 'Unknown')}
Updated: {post.get('updated_at', 'Unknown')}
Published: {post.get('published_at', 'Not published')}
System IDs:
ID: {post.get('id', 'Unknown')}
UUID: {post.get('uuid', 'Unknown')}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to read post: {str(e)}")
return str(e)
async def create_post(post_data: dict, ctx: Context = None) -> str:
"""Create a new blog post.
Args:
post_data: Dictionary containing post data with required fields:
- title: The title of the post
- lexical: The lexical content as a JSON string
Additional optional fields:
- status: Post status ('draft' or 'published', defaults to 'draft')
- tags: List of tags
- authors: List of authors
- feature_image: URL of featured image
Example:
{
"title": "My test post",
"lexical": "{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"Hello World\",\"type\":\"text\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}"
"status": "draft",
}
ctx: Optional context for logging
Returns:
Formatted string containing the created post details
Raises:
GhostError: If there is an error accessing the Ghost API or invalid post data
"""
if ctx:
ctx.info(f"Creating post with data: {post_data}")
if not isinstance(post_data, dict):
error_msg = "post_data must be a dictionary"
if ctx:
ctx.error(error_msg)
return error_msg
if 'title' not in post_data and 'lexical' not in post_data:
error_msg = "post_data must contain at least 'title' or 'lexical'"
if ctx:
ctx.error(error_msg)
return error_msg
try:
# Create a copy of post_data to avoid modifying the original
post_payload = post_data.copy()
# Ensure status is 'draft' by default
if 'status' not in post_payload:
post_payload['status'] = 'draft'
if ctx:
ctx.debug("Setting default status to 'draft'")
if ctx:
ctx.debug(f"Post status: {post_payload['status']}")
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
# Ensure lexical is a valid JSON string if present
if 'lexical' in post_payload:
try:
if isinstance(post_payload['lexical'], dict):
post_payload['lexical'] = json.dumps(post_payload['lexical'])
else:
# Validate the JSON string
json.loads(post_payload['lexical'])
except json.JSONDecodeError as e:
error_msg = f"Invalid JSON in lexical content: {str(e)}"
if ctx:
ctx.error(error_msg)
return error_msg
# Prepare post creation payload
request_data = {
"posts": [post_payload]
}
if ctx:
ctx.debug(f"Creating post with data: {json.dumps(request_data)}")
data = await make_ghost_request(
"posts/",
headers,
ctx,
http_method="POST",
json_data=request_data
)
if ctx:
ctx.debug("Post created successfully")
post = data["posts"][0]
# Format tags and authors for display
tags = [tag.get('name', 'Unknown') for tag in post.get('tags', [])]
authors = [author.get('name', 'Unknown') for author in post.get('authors', [])]
return f"""
Post Created Successfully:
Title: {post.get('title', 'Untitled')}
Slug: {post.get('slug', 'No slug')}
Status: {post.get('status', 'Unknown')}
URL: {post.get('url', 'No URL')}
Tags: {', '.join(tags) if tags else 'None'}
Authors: {', '.join(authors) if authors else 'None'}
Published At: {post.get('published_at', 'Not published')}
ID: {post.get('id', 'Unknown')}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to create post: {str(e)}")
return str(e)
async def update_post(post_id: str, update_data: dict, ctx: Context = None) -> str:
"""Update a blog post with new data.
Args:
post_id: The ID of the post to update
update_data: Dictionary containing the updated data and updated_at timestamp.
Note: 'updated_at' is required. If 'lexical' is provided, it must be a valid JSON string.
The lexical content must be a properly escaped JSON string in this format:
{
"root": {
"children": [
{
"children": [
{
"detail": 0,
"format": 0,
"mode": "normal",
"style": "",
"text": "Your content here",
"type": "text",
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"type": "paragraph",
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"type": "root",
"version": 1
}
}
Example usage:
update_data = {
"post_id": "67abcffb7f82ac000179d76f",
"update_data": {
"updated_at": "2025-02-11T22:54:40.000Z",
"lexical": "{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"Hello World\",\"type\":\"text\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}"
}
}
Updatable fields for a blog post:
- slug: Unique URL slug for the post.
- id: Identifier of the post.
- uuid: Universally unique identifier for the post.
- title: The title of the post.
- lexical: JSON string representing the post content in lexical format.
- html: HTML version of the post content.
- comment_id: Identifier for the comment thread.
- feature_image: URL to the post's feature image.
- feature_image_alt: Alternate text for the feature image.
- feature_image_caption: Caption for the feature image.
- featured: Boolean flag indicating if the post is featured.
- status: The publication status (e.g., published, draft).
- visibility: Visibility setting (e.g., public, private).
- created_at: Timestamp when the post was created.
- updated_at: Timestamp when the post was last updated.
- published_at: Timestamp when the post was published.
- custom_excerpt: Custom excerpt text for the post.
- codeinjection_head: Code to be injected into the head section.
- codeinjection_foot: Code to be injected into the footer section.
- custom_template: Custom template assigned to the post.
- canonical_url: The canonical URL for SEO purposes.
- tags: List of tag objects associated with the post.
- authors: List of author objects for the post.
- primary_author: The primary author object.
- primary_tag: The primary tag object.
- url: Direct URL link to the post.
- excerpt: Short excerpt or summary of the post.
- og_image: Open Graph image URL for social sharing.
- og_title: Open Graph title for social sharing.
- og_description: Open Graph description for social sharing.
- twitter_image: Twitter-specific image URL.
- twitter_title: Twitter-specific title.
- twitter_description: Twitter-specific description.
- meta_title: Meta title for SEO.
- meta_description: Meta description for SEO.
- email_only: Boolean flag indicating if the post is for email distribution only.
- newsletter: Dictionary containing newsletter configuration details.
- email: Dictionary containing email details related to the post.
ctx: Optional context for logging
Returns:
Formatted string containing the updated post details
Raises:
GhostError: If there is an error accessing the Ghost API or missing required fields
"""
if ctx:
ctx.info(f"Updating post with ID: {post_id}")
try:
# First, get the current post data to obtain the correct updated_at
if ctx:
ctx.debug("Getting current post data")
headers = await get_auth_headers(STAFF_API_KEY)
current_post = await make_ghost_request(f"posts/{post_id}/", headers, ctx)
current_updated_at = current_post["posts"][0]["updated_at"]
# Prepare update payload
post_update = {
"posts": [{
"id": post_id,
"updated_at": current_updated_at # Use the current updated_at timestamp
}]
}
# Copy all update fields
for key, value in update_data.items():
if key != "updated_at": # Skip updated_at from input
if key == "tags" and isinstance(value, list):
post_update["posts"][0]["tags"] = [
{"name": tag} if isinstance(tag, str) else tag
for tag in value
]
else:
post_update["posts"][0][key] = value
if ctx:
ctx.debug(f"Update payload: {json.dumps(post_update, indent=2)}")
# Make the update request
data = await make_ghost_request(
f"posts/{post_id}/",
headers,
ctx,
http_method="PUT",
json_data=post_update
)
# Process response...
post = data["posts"][0]
# Format response...
tags = [tag.get('name', 'Unknown') for tag in post.get('tags', [])]
authors = [author.get('name', 'Unknown') for author in post.get('authors', [])]
return f"""
Post Updated Successfully:
Title: {post.get('title', 'Untitled')}
Slug: {post.get('slug', 'No slug')}
Status: {post.get('status', 'Unknown')}
Visibility: {post.get('visibility', 'Unknown')}
Featured: {post.get('featured', False)}
URL: {post.get('url', 'No URL')}
Tags: {', '.join(tags) if tags else 'None'}
Authors: {', '.join(authors) if authors else 'None'}
Published At: {post.get('published_at', 'Not published')}
Updated At: {post.get('updated_at', 'Unknown')}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to update post: {str(e)}")
return str(e)
async def batchly_update_posts(filter_criteria: dict, update_data: dict, ctx: Context = None) -> str:
"""Update multiple blog posts that match the filter criteria.
Args:
filter_criteria: Dictionary containing fields to filter posts by, example:
{
"status": "draft",
"tag": "news",
"featured": True
}
Supported filter fields:
- status: Post status (draft, published, etc)
- tag: Filter by tag name
- author: Filter by author name
- featured: Boolean to filter featured posts
- visibility: Post visibility (public, members, paid)
update_data: Dictionary containing the fields to update. The updated_at field is required.
All fields supported by the Ghost API can be updated:
- slug: Unique URL slug for the post
- title: The title of the post
- lexical: JSON string representing the post content in lexical format
- html: HTML version of the post content
- comment_id: Identifier for the comment thread
- feature_image: URL to the post's feature image
- feature_image_alt: Alternate text for the feature image
- feature_image_caption: Caption for the feature image
- featured: Boolean flag indicating if the post is featured
- status: The publication status (e.g., published, draft)
- visibility: Visibility setting (e.g., public, private)
- created_at: Timestamp when the post was created
- updated_at: Timestamp when the post was last updated (REQUIRED)
- published_at: Timestamp when the post was published
- custom_excerpt: Custom excerpt text for the post
- codeinjection_head: Code to be injected into the head section
- codeinjection_foot: Code to be injected into the footer section
- custom_template: Custom template assigned to the post
- canonical_url: The canonical URL for SEO purposes
- tags: List of tag objects associated with the post
- authors: List of author objects for the post
- primary_author: The primary author object
- primary_tag: The primary tag object
- og_image: Open Graph image URL for social sharing
- og_title: Open Graph title for social sharing
- og_description: Open Graph description for social sharing
- twitter_image: Twitter-specific image URL
- twitter_title: Twitter-specific title
- twitter_description: Twitter-specific description
- meta_title: Meta title for SEO
- meta_description: Meta description for SEO
- email_only: Boolean flag indicating if the post is for email distribution only
- newsletter: Dictionary containing newsletter configuration details
- email: Dictionary containing email details related to the post
Example:
{
"updated_at": "2025-02-11T22:54:40.000Z",
"status": "published",
"featured": True,
"tags": [{"name": "news"}, {"name": "featured"}],
"meta_title": "My Updated Title",
"og_description": "New social sharing description"
}
ctx: Optional context for logging
Returns:
Formatted string containing summary of updated posts
Raises:
GhostError: If there is an error accessing the Ghost API or missing required fields
"""
if ctx:
ctx.info(f"Batch updating posts with filter: {filter_criteria}")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
# First get all posts
if ctx:
ctx.debug("Getting all posts to filter")
data = await make_ghost_request("posts/?limit=all&include=tags,authors", headers, ctx)
posts = data.get("posts", [])
if not posts:
return "No posts found to update."
# Filter posts based on criteria
filtered_posts = []
for post in posts:
matches = True
for key, value in filter_criteria.items():
if key == "tag":
post_tags = [tag.get("name") for tag in post.get("tags", [])]
if value not in post_tags:
matches = False
break
elif key == "author":
post_authors = [author.get("name") for author in post.get("authors", [])]
if value not in post_authors:
matches = False
break
elif key in post:
if post[key] != value:
matches = False
break
if matches:
filtered_posts.append(post)
if not filtered_posts:
return f"No posts found matching filter criteria: {filter_criteria}"
# Update each matching post
updated_count = 0
failed_count = 0
failed_posts = []
for post in filtered_posts:
try:
post_update = {
"posts": [{
"id": post["id"],
"updated_at": post["updated_at"] # Use current post's updated_at
}]
}
# Copy all update fields except updated_at
for key, value in update_data.items():
if key != "updated_at":
if key == "tags" and isinstance(value, list):
post_update["posts"][0]["tags"] = [
{"name": tag} if isinstance(tag, str) else tag
for tag in value
]
elif key == "authors" and isinstance(value, list):
post_update["posts"][0]["authors"] = [
{"name": author} if isinstance(author, str) else author
for author in value
]
else:
post_update["posts"][0][key] = value
# Validate lexical JSON if present
if "lexical" in update_data:
try:
if isinstance(update_data["lexical"], dict):
post_update["posts"][0]["lexical"] = json.dumps(update_data["lexical"])
else:
json.loads(update_data["lexical"]) # Validate JSON string
except json.JSONDecodeError as e:
raise GhostError(f"Invalid JSON in lexical content: {str(e)}")
await make_ghost_request(
f"posts/{post['id']}/",
headers,
ctx,
http_method="PUT",
json_data=post_update
)
updated_count += 1
except GhostError as e:
if ctx:
ctx.error(f"Failed to update post {post['id']}: {str(e)}")
failed_count += 1
failed_posts.append({
"id": post["id"],
"title": post.get("title", "Unknown"),
"error": str(e)
})
summary = f"""
Batch Update Summary:
Total matching posts: {len(filtered_posts)}
Successfully updated: {updated_count}
Failed to update: {failed_count}
Filter criteria used: {json.dumps(filter_criteria, indent=2)}
Fields updated: {json.dumps({k:v for k,v in update_data.items() if k != 'updated_at'}, indent=2)}
"""
if failed_posts:
summary += "\nFailed Posts:\n" + json.dumps(failed_posts, indent=2)
return summary
except GhostError as e:
if ctx:
ctx.error(f"Failed to batch update posts: {str(e)}")
return str(e)
async def delete_post(post_id: str, ctx: Context = None) -> str:
"""Delete a blog post.
Args:
post_id: The ID of the post to delete
ctx: Optional context for logging
Returns:
Success message if post was deleted
Raises:
GhostError: If there is an error accessing the Ghost API or the post doesn't exist
"""
if ctx:
ctx.info(f"Deleting post with ID: {post_id}")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
# First verify the post exists
if ctx:
ctx.debug(f"Verifying post exists: {post_id}")
try:
await make_ghost_request(f"posts/{post_id}/", headers, ctx)
except GhostError as e:
if "404" in str(e):
error_msg = f"Post with ID {post_id} not found"
if ctx:
ctx.error(error_msg)
return error_msg
raise
# Make the delete request
if ctx:
ctx.debug(f"Deleting post: {post_id}")
await make_ghost_request(
f"posts/{post_id}/",
headers,
ctx,
http_method="DELETE"
)
return f"Successfully deleted post with ID: {post_id}"
except GhostError as e:
if ctx:
ctx.error(f"Failed to delete post: {str(e)}")
return str(e)

View File

@@ -1,72 +0,0 @@
"""Role-related MCP tools for Ghost API."""
import json
from mcp.server.fastmcp import Context
from ..api import make_ghost_request, get_auth_headers
from ..config import STAFF_API_KEY
from ..exceptions import GhostError
async def list_roles(
format: str = "text",
page: int = 1,
limit: int = 15,
ctx: Context = None
) -> str:
"""Get the list of roles from your Ghost blog.
Args:
format: Output format - either "text" or "json" (default: "text")
page: Page number for pagination (default: 1)
limit: Number of roles per page (default: 15)
ctx: Optional context for logging
Returns:
Formatted string containing role information
"""
if ctx:
ctx.info(f"Listing roles (page {page}, limit {limit}, format {format})")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
if ctx:
ctx.debug("Making API request to /roles/ with pagination")
data = await make_ghost_request(
f"roles/?page={page}&limit={limit}",
headers,
ctx
)
if ctx:
ctx.debug("Processing roles list response")
roles = data.get("roles", [])
if not roles:
if ctx:
ctx.info("No roles found in response")
return "No roles found."
if format.lower() == "json":
if ctx:
ctx.debug("Returning JSON format")
return json.dumps(roles, indent=2)
formatted_roles = []
for role in roles:
formatted_role = f"""
Name: {role.get('name', 'Unknown')}
Description: {role.get('description', 'No description')}
Created: {role.get('created_at', 'Unknown')}
Updated: {role.get('updated_at', 'Unknown')}
ID: {role.get('id', 'Unknown')}
"""
formatted_roles.append(formatted_role)
return "\n---\n".join(formatted_roles)
except GhostError as e:
if ctx:
ctx.error(f"Failed to list roles: {str(e)}")
return str(e)

View File

@@ -1,482 +0,0 @@
"""Tag-related MCP tools for Ghost API."""
import json
from mcp.server.fastmcp import Context
from ..api import make_ghost_request, get_auth_headers
from ..config import STAFF_API_KEY
from ..exceptions import GhostError
async def browse_tags(
format: str = "text",
page: int = 1,
limit: int = 15,
ctx: Context = None
) -> str:
"""Get the list of tags from your Ghost blog.
Args:
format: Output format - either "text" or "json" (default: "text")
page: Page number for pagination (default: 1)
limit: Number of tags per page (default: 15)
ctx: Optional context for logging
Returns:
Formatted string containing tag information
Raises:
GhostError: If there is an error accessing the Ghost API
"""
if ctx:
ctx.info(f"Listing tags (page {page}, limit {limit}, format {format})")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
if ctx:
ctx.debug("Making API request to /tags/ with pagination")
data = await make_ghost_request(
f"tags/?page={page}&limit={limit}",
headers,
ctx
)
if ctx:
ctx.debug("Processing tags list response")
tags = data.get("tags", [])
if not tags:
if ctx:
ctx.info("No tags found in response")
return "No tags found."
if format.lower() == "json":
if ctx:
ctx.debug("Formatting tags in JSON format")
formatted_tags = [{
"id": tag.get('id', 'Unknown'),
"name": tag.get('name', 'Unknown'),
"slug": tag.get('slug', 'Unknown'),
"description": tag.get('description', None),
"feature_image": tag.get('feature_image', None),
"visibility": tag.get('visibility', 'public'),
"og_image": tag.get('og_image', None),
"og_title": tag.get('og_title', None),
"og_description": tag.get('og_description', None),
"twitter_image": tag.get('twitter_image', None),
"twitter_title": tag.get('twitter_title', None),
"twitter_description": tag.get('twitter_description', None),
"meta_title": tag.get('meta_title', None),
"meta_description": tag.get('meta_description', None),
"codeinjection_head": tag.get('codeinjection_head', None),
"codeinjection_foot": tag.get('codeinjection_foot', None),
"canonical_url": tag.get('canonical_url', None),
"accent_color": tag.get('accent_color', None),
"url": tag.get('url', 'No URL'),
"created_at": tag.get('created_at', 'Unknown'),
"updated_at": tag.get('updated_at', 'Unknown')
} for tag in tags]
return json.dumps(formatted_tags, indent=2)
formatted_tags = []
for tag in tags:
formatted_tag = f"""
ID: {tag.get('id', 'Unknown')}
Name: {tag.get('name', 'Unknown')}
Slug: {tag.get('slug', 'Unknown')}
Description: {tag.get('description', 'None')}
Feature Image: {tag.get('feature_image', 'None')}
Visibility: {tag.get('visibility', 'public')}
URL: {tag.get('url', 'No URL')}
Accent Color: {tag.get('accent_color', 'None')}
Meta Information:
Meta Title: {tag.get('meta_title', 'None')}
Meta Description: {tag.get('meta_description', 'None')}
Canonical URL: {tag.get('canonical_url', 'None')}
Open Graph:
OG Image: {tag.get('og_image', 'None')}
OG Title: {tag.get('og_title', 'None')}
OG Description: {tag.get('og_description', 'None')}
Twitter Card:
Twitter Image: {tag.get('twitter_image', 'None')}
Twitter Title: {tag.get('twitter_title', 'None')}
Twitter Description: {tag.get('twitter_description', 'None')}
Code Injection:
Header Code: {tag.get('codeinjection_head', 'None')}
Footer Code: {tag.get('codeinjection_foot', 'None')}
Timestamps:
Created: {tag.get('created_at', 'Unknown')}
Updated: {tag.get('updated_at', 'Unknown')}
"""
formatted_tags.append(formatted_tag)
return "\n---\n".join(formatted_tags)
except GhostError as e:
if ctx:
ctx.error(f"Failed to list tags: {str(e)}")
return str(e)
async def read_tag(tag_id: str, ctx: Context = None) -> str:
"""Get the full metadata of a specific tag.
Args:
tag_id: The ID of the tag to retrieve
ctx: Optional context for logging
Returns:
Formatted string containing all tag details
Raises:
GhostError: If there is an error accessing the Ghost API
"""
if ctx:
ctx.info(f"Reading tag content for ID: {tag_id}")
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 /tags/{tag_id}/")
data = await make_ghost_request(
f"tags/{tag_id}/",
headers,
ctx
)
if ctx:
ctx.debug("Processing tag response data")
tag = data["tags"][0]
return f"""
ID: {tag.get('id', 'Unknown')}
Name: {tag.get('name', 'Unknown')}
Slug: {tag.get('slug', 'Unknown')}
Description: {tag.get('description', 'None')}
Feature Image: {tag.get('feature_image', 'None')}
Visibility: {tag.get('visibility', 'public')}
URL: {tag.get('url', 'No URL')}
Accent Color: {tag.get('accent_color', 'None')}
Meta Information:
Meta Title: {tag.get('meta_title', 'None')}
Meta Description: {tag.get('meta_description', 'None')}
Canonical URL: {tag.get('canonical_url', 'None')}
Open Graph:
OG Image: {tag.get('og_image', 'None')}
OG Title: {tag.get('og_title', 'None')}
OG Description: {tag.get('og_description', 'None')}
Twitter Card:
Twitter Image: {tag.get('twitter_image', 'None')}
Twitter Title: {tag.get('twitter_title', 'None')}
Twitter Description: {tag.get('twitter_description', 'None')}
Code Injection:
Header Code: {tag.get('codeinjection_head', 'None')}
Footer Code: {tag.get('codeinjection_foot', 'None')}
Timestamps:
Created: {tag.get('created_at', 'Unknown')}
Updated: {tag.get('updated_at', 'Unknown')}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to read tag: {str(e)}")
return str(e)
async def create_tag(tag_data: dict, ctx: Context = None) -> str:
"""Create a new tag.
Args:
tag_data: Dictionary containing tag data with required fields:
- name: The name of the tag
Additional optional fields:
- slug: URL slug for the tag
- description: Description of the tag
- feature_image: URL to the tag's feature image
- visibility: Tag visibility ('public' or 'internal')
- accent_color: CSS color hex value for the tag
- meta_title: Meta title for SEO
- meta_description: Meta description for SEO
- canonical_url: The canonical URL
- og_image: Open Graph image URL
- og_title: Open Graph title
- og_description: Open Graph description
- twitter_image: Twitter card image URL
- twitter_title: Twitter card title
- twitter_description: Twitter card description
- codeinjection_head: Code to inject in header
- codeinjection_foot: Code to inject in footer
Example:
{
"name": "Technology",
"description": "Posts about technology",
"visibility": "public"
}
ctx: Optional context for logging
Returns:
Formatted string containing the created tag details
Raises:
GhostError: If there is an error accessing the Ghost API or invalid tag data
"""
if ctx:
ctx.info(f"Creating tag with data: {tag_data}")
if not isinstance(tag_data, dict):
error_msg = "tag_data must be a dictionary"
if ctx:
ctx.error(error_msg)
return error_msg
if 'name' not in tag_data:
error_msg = "tag_data must contain 'name'"
if ctx:
ctx.error(error_msg)
return error_msg
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
# Prepare tag creation payload
request_data = {
"tags": [tag_data]
}
if ctx:
ctx.debug(f"Creating tag with data: {json.dumps(request_data)}")
data = await make_ghost_request(
"tags/",
headers,
ctx,
http_method="POST",
json_data=request_data
)
if ctx:
ctx.debug("Tag created successfully")
tag = data["tags"][0]
return f"""
Tag Created Successfully:
ID: {tag.get('id', 'Unknown')}
Name: {tag.get('name', 'Unknown')}
Slug: {tag.get('slug', 'Unknown')}
Description: {tag.get('description', 'None')}
Feature Image: {tag.get('feature_image', 'None')}
Visibility: {tag.get('visibility', 'public')}
URL: {tag.get('url', 'No URL')}
Accent Color: {tag.get('accent_color', 'None')}
Meta Information:
Meta Title: {tag.get('meta_title', 'None')}
Meta Description: {tag.get('meta_description', 'None')}
Canonical URL: {tag.get('canonical_url', 'None')}
Open Graph:
OG Image: {tag.get('og_image', 'None')}
OG Title: {tag.get('og_title', 'None')}
OG Description: {tag.get('og_description', 'None')}
Twitter Card:
Twitter Image: {tag.get('twitter_image', 'None')}
Twitter Title: {tag.get('twitter_title', 'None')}
Twitter Description: {tag.get('twitter_description', 'None')}
Code Injection:
Header Code: {tag.get('codeinjection_head', 'None')}
Footer Code: {tag.get('codeinjection_foot', 'None')}
Timestamps:
Created: {tag.get('created_at', 'Unknown')}
Updated: {tag.get('updated_at', 'Unknown')}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to create tag: {str(e)}")
return str(e)
async def update_tag(tag_id: str, update_data: dict, ctx: Context = None) -> str:
"""Update a tag with new data.
Args:
tag_id: The ID of the tag to update
update_data: Dictionary containing the updated data. Fields that can be updated:
- name: The name of the tag
- slug: URL slug for the tag
- description: Description of the tag
- feature_image: URL to the tag's feature image
- visibility: Tag visibility ('public' or 'internal')
- accent_color: CSS color hex value for the tag
- meta_title: Meta title for SEO
- meta_description: Meta description for SEO
- canonical_url: The canonical URL
- og_image: Open Graph image URL
- og_title: Open Graph title
- og_description: Open Graph description
- twitter_image: Twitter card image URL
- twitter_title: Twitter card title
- twitter_description: Twitter card description
- codeinjection_head: Code to inject in header
- codeinjection_foot: Code to inject in footer
Example:
{
"name": "Updated Name",
"description": "Updated description"
}
ctx: Optional context for logging
Returns:
Formatted string containing the updated tag details
Raises:
GhostError: If there is an error accessing the Ghost API
"""
if ctx:
ctx.info(f"Updating tag with ID: {tag_id}")
try:
# First, get the current tag data to obtain the correct updated_at
if ctx:
ctx.debug("Getting current tag data")
headers = await get_auth_headers(STAFF_API_KEY)
current_tag = await make_ghost_request(f"tags/{tag_id}/", headers, ctx)
current_updated_at = current_tag["tags"][0]["updated_at"]
# Prepare update payload
tag_update = {
"tags": [{
"id": tag_id,
"updated_at": current_updated_at
}]
}
# Copy all update fields
for key, value in update_data.items():
if key != "updated_at": # Skip updated_at from input
tag_update["tags"][0][key] = value
if ctx:
ctx.debug(f"Update payload: {json.dumps(tag_update, indent=2)}")
# Make the update request
data = await make_ghost_request(
f"tags/{tag_id}/",
headers,
ctx,
http_method="PUT",
json_data=tag_update
)
tag = data["tags"][0]
return f"""
Tag Updated Successfully:
ID: {tag.get('id', 'Unknown')}
Name: {tag.get('name', 'Unknown')}
Slug: {tag.get('slug', 'Unknown')}
Description: {tag.get('description', 'None')}
Feature Image: {tag.get('feature_image', 'None')}
Visibility: {tag.get('visibility', 'public')}
URL: {tag.get('url', 'No URL')}
Accent Color: {tag.get('accent_color', 'None')}
Meta Information:
Meta Title: {tag.get('meta_title', 'None')}
Meta Description: {tag.get('meta_description', 'None')}
Canonical URL: {tag.get('canonical_url', 'None')}
Open Graph:
OG Image: {tag.get('og_image', 'None')}
OG Title: {tag.get('og_title', 'None')}
OG Description: {tag.get('og_description', 'None')}
Twitter Card:
Twitter Image: {tag.get('twitter_image', 'None')}
Twitter Title: {tag.get('twitter_title', 'None')}
Twitter Description: {tag.get('twitter_description', 'None')}
Code Injection:
Header Code: {tag.get('codeinjection_head', 'None')}
Footer Code: {tag.get('codeinjection_foot', 'None')}
Timestamps:
Created: {tag.get('created_at', 'Unknown')}
Updated: {tag.get('updated_at', 'Unknown')}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to update tag: {str(e)}")
return str(e)
async def delete_tag(tag_id: str, ctx: Context = None) -> str:
"""Delete a tag.
Args:
tag_id: The ID of the tag to delete
ctx: Optional context for logging
Returns:
Success message if tag was deleted
Raises:
GhostError: If there is an error accessing the Ghost API or the tag doesn't exist
"""
if ctx:
ctx.info(f"Deleting tag with ID: {tag_id}")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
# First verify the tag exists
if ctx:
ctx.debug(f"Verifying tag exists: {tag_id}")
try:
await make_ghost_request(f"tags/{tag_id}/", headers, ctx)
except GhostError as e:
if "404" in str(e):
error_msg = f"Tag with ID {tag_id} not found"
if ctx:
ctx.error(error_msg)
return error_msg
raise
# Make the delete request
if ctx:
ctx.debug(f"Deleting tag: {tag_id}")
await make_ghost_request(
f"tags/{tag_id}/",
headers,
ctx,
http_method="DELETE"
)
return f"Successfully deleted tag with ID: {tag_id}"
except GhostError as e:
if ctx:
ctx.error(f"Failed to delete tag: {str(e)}")
return str(e)

View File

@@ -1,330 +0,0 @@
"""Tier-related MCP tools for Ghost API."""
import json
from typing import Optional, List
from mcp.server.fastmcp import Context
from ..api import make_ghost_request, get_auth_headers
from ..config import STAFF_API_KEY
from mcp.server.fastmcp import Context
from ..api import make_ghost_request, get_auth_headers
from ..config import STAFF_API_KEY
from ..exceptions import GhostError
async def list_tiers(
format: str = "text",
page: int = 1,
limit: int = 15,
ctx: Context = None
) -> str:
"""Get the list of tiers from your Ghost blog.
Args:
format: Output format - either "text" or "json" (default: "text")
page: Page number for pagination (default: 1)
limit: Number of tiers per page (default: 15)
ctx: Optional context for logging
Returns:
Formatted string containing tier information
"""
if ctx:
ctx.info(f"Listing tiers (page {page}, limit {limit}, format {format})")
try:
if ctx:
ctx.debug("Getting auth headers")
headers = await get_auth_headers(STAFF_API_KEY)
if ctx:
ctx.debug("Making API request to /tiers/ with pagination")
data = await make_ghost_request(
f"tiers/?page={page}&limit={limit}&include=monthly_price,yearly_price,benefits",
headers,
ctx
)
if ctx:
ctx.debug("Processing tiers list response")
tiers = data.get("tiers", [])
if not tiers:
if ctx:
ctx.info("No tiers found in response")
return "No tiers found."
if format.lower() == "json":
if ctx:
ctx.debug("Returning JSON format")
return json.dumps(tiers, indent=2)
formatted_tiers = []
for tier in tiers:
benefits = tier.get('benefits', [])
formatted_tier = f"""
Name: {tier.get('name', 'Unknown')}
Description: {tier.get('description', 'No description')}
Type: {tier.get('type', 'Unknown')}
Active: {tier.get('active', False)}
Monthly Price: {tier.get('monthly_price', 'N/A')}
Yearly Price: {tier.get('yearly_price', 'N/A')}
Benefits: {', '.join(benefits) if benefits else 'None'}
ID: {tier.get('id', 'Unknown')}
"""
formatted_tiers.append(formatted_tier)
return "\n---\n".join(formatted_tiers)
except GhostError as e:
if ctx:
ctx.error(f"Failed to list tiers: {str(e)}")
return str(e)
async def read_tier(tier_id: str, ctx: Context = None) -> str:
"""Get the details of a specific tier.
Args:
tier_id: The ID of the tier to retrieve
ctx: Optional context for logging
Returns:
Formatted string containing the tier details
"""
if ctx:
ctx.info(f"Reading tier details for ID: {tier_id}")
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 /tiers/{tier_id}/")
data = await make_ghost_request(
f"tiers/{tier_id}/?include=monthly_price,yearly_price,benefits",
headers,
ctx
)
if ctx:
ctx.debug("Processing tier response data")
tier = data["tiers"][0]
benefits = tier.get('benefits', [])
return f"""
Name: {tier.get('name', 'Unknown')}
Description: {tier.get('description', 'No description')}
Type: {tier.get('type', 'Unknown')}
Active: {tier.get('active', False)}
Welcome Page URL: {tier.get('welcome_page_url', 'None')}
Created: {tier.get('created_at', 'Unknown')}
Updated: {tier.get('updated_at', 'Unknown')}
Monthly Price: {tier.get('monthly_price', 'N/A')}
Yearly Price: {tier.get('yearly_price', 'N/A')}
Currency: {tier.get('currency', 'Unknown')}
Benefits:
{chr(10).join(f'- {benefit}' for benefit in benefits) if benefits else 'No benefits listed'}
"""
except GhostError as e:
if ctx:
ctx.error(f"Failed to read tier: {str(e)}")
return str(e)
async def create_tier(
name: str,
monthly_price: Optional[int] = None,
yearly_price: Optional[int] = None,
description: Optional[str] = None,
benefits: Optional[List[str]] = None,
welcome_page_url: Optional[str] = None,
visibility: str = "public",
currency: str = "usd",
ctx: Context = None
) -> str:
"""Create a new tier in Ghost.
Args:
name: Name of the tier (required)
monthly_price: Optional monthly price in cents (e.g. 500 for $5.00)
yearly_price: Optional yearly price in cents (e.g. 5000 for $50.00)
description: Optional description of the tier
benefits: Optional list of benefits for the tier
welcome_page_url: Optional URL for the welcome page
visibility: Visibility of tier, either "public" or "none" (default: "public")
currency: Currency for prices (default: "usd")
ctx: Optional context for logging
Returns:
String representation of the created tier
Raises:
GhostError: If the Ghost API request fails
"""
if not name:
raise ValueError("Name is required for creating a tier")
if ctx:
ctx.info(f"Creating new tier: {name}")
# Construct tier data
tier_data = {
"tiers": [{
"name": name,
"description": description,
"type": "paid" if (monthly_price or yearly_price) else "free",
"active": True,
"visibility": visibility,
"welcome_page_url": welcome_page_url,
"benefits": benefits or [],
"currency": currency
}]
}
# Add pricing if provided
if monthly_price is not None:
tier_data["tiers"][0]["monthly_price"] = monthly_price
if yearly_price is not None:
tier_data["tiers"][0]["yearly_price"] = yearly_price
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 tier")
response = await make_ghost_request(
"tiers/",
headers,
ctx,
http_method="POST",
json_data=tier_data
)
if ctx:
ctx.debug("Processing created tier response")
tier = response.get("tiers", [{}])[0]
# Format response
benefits_text = "\n- ".join(tier.get('benefits', [])) if tier.get('benefits') else "None"
return f"""
Tier created successfully:
Name: {tier.get('name')}
Type: {tier.get('type')}
Description: {tier.get('description', 'No description')}
Active: {tier.get('active', False)}
Visibility: {tier.get('visibility', 'public')}
Monthly Price: {tier.get('monthly_price', 'N/A')} {tier.get('currency', 'usd').upper()}
Yearly Price: {tier.get('yearly_price', 'N/A')} {tier.get('currency', 'usd').upper()}
Currency: {tier.get('currency', 'usd').upper()}
Benefits:
- {benefits_text}
ID: {tier.get('id', 'Unknown')}
"""
except Exception as e:
if ctx:
ctx.error(f"Failed to create tier: {str(e)}")
raise
async def update_tier(
tier_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
monthly_price: Optional[int] = None,
yearly_price: Optional[int] = None,
benefits: Optional[List[str]] = None,
welcome_page_url: Optional[str] = None,
visibility: Optional[str] = None,
currency: Optional[str] = None,
active: Optional[bool] = None,
ctx: Context = None
) -> str:
"""Update an existing tier in Ghost.
Args:
tier_id: ID of the tier to update (required)
name: New name for the tier
description: New description for the tier
monthly_price: New monthly price in cents (e.g. 500 for $5.00)
yearly_price: New yearly price in cents (e.g. 5000 for $50.00)
benefits: New list of benefits for the tier
welcome_page_url: New URL for the welcome page
visibility: New visibility setting ("public" or "none")
currency: New currency for prices
active: New active status
ctx: Optional context for logging
Returns:
String representation of the updated tier
Raises:
GhostError: If the Ghost API request fails
"""
if ctx:
ctx.info(f"Updating tier with ID: {tier_id}")
# Construct update data with only provided fields
update_data = {"tiers": [{}]}
tier_updates = update_data["tiers"][0]
if name is not None:
tier_updates["name"] = name
if description is not None:
tier_updates["description"] = description
if monthly_price is not None:
tier_updates["monthly_price"] = monthly_price
if yearly_price is not None:
tier_updates["yearly_price"] = yearly_price
if benefits is not None:
tier_updates["benefits"] = benefits
if welcome_page_url is not None:
tier_updates["welcome_page_url"] = welcome_page_url
if visibility is not None:
tier_updates["visibility"] = visibility
if currency is not None:
tier_updates["currency"] = currency
if active is not None:
tier_updates["active"] = active
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 tier {tier_id}")
response = await make_ghost_request(
f"tiers/{tier_id}/",
headers,
ctx,
http_method="PUT",
json_data=update_data
)
if ctx:
ctx.debug("Processing updated tier response")
tier = response.get("tiers", [{}])[0]
# Format response
benefits_text = "\n- ".join(tier.get('benefits', [])) if tier.get('benefits') else "None"
return f"""
Tier updated successfully:
Name: {tier.get('name')}
Type: {tier.get('type')}
Description: {tier.get('description', 'No description')}
Active: {tier.get('active', False)}
Visibility: {tier.get('visibility', 'public')}
Monthly Price: {tier.get('monthly_price', 'N/A')} {tier.get('currency', 'usd').upper()}
Yearly Price: {tier.get('yearly_price', 'N/A')} {tier.get('currency', 'usd').upper()}
Currency: {tier.get('currency', 'usd').upper()}
Benefits:
- {benefits_text}
ID: {tier.get('id')}
"""
except Exception as e:
if ctx:
ctx.error(f"Failed to update tier: {str(e)}")
raise

View File

@@ -1,186 +0,0 @@
"""User-related MCP tools for Ghost API."""
import json
from mcp.server.fastmcp import Context
from ..api import make_ghost_request, get_auth_headers
from ..config import STAFF_API_KEY
from ..exceptions import GhostError
async def list_users(
format: str = "text",
page: int = 1,
limit: int = 15,
ctx: Context = None
) -> str:
"""Get the list of users from your Ghost blog.
Args:
format: Output format - either "text" or "json" (default: "text")
page: Page number for pagination (default: 1)
limit: Number of users per page (default: 15)
ctx: Optional context for logging
Returns:
Formatted string containing user information
"""
if ctx:
ctx.info(f"Listing users (page {page}, limit {limit}, format {format})")
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 /users/ with pagination")
data = await make_ghost_request(
f"users/?page={page}&limit={limit}&include=roles",
headers,
ctx
)
if ctx:
ctx.debug("Processing users list response")
users = data.get("users", [])
if not users:
if ctx:
ctx.info("No users found in response")
return "No users found."
if format.lower() == "json":
if ctx:
ctx.debug("Returning JSON format")
return json.dumps(users, indent=2)
formatted_users = []
for user in users:
roles = [role.get('name') for role in user.get('roles', [])]
formatted_user = f"""
Name: {user.get('name', 'Unknown')}
Email: {user.get('email', 'Unknown')}
Roles: {', '.join(roles)}
Status: {user.get('status', 'Unknown')}
ID: {user.get('id', 'Unknown')}
"""
formatted_users.append(formatted_user)
return "\n---\n".join(formatted_users)
except GhostError as e:
if ctx:
ctx.error(f"Failed to list users: {str(e)}")
return str(e)
async def delete_user(
user_id: str,
ctx: Context = None
) -> str:
"""Delete a user from Ghost.
Args:
user_id: ID of the user to delete (required)
ctx: Optional context for logging
Returns:
Success message if deletion was successful
Raises:
GhostError: If the Ghost API request fails or if attempting to delete the Owner
ValueError: If user_id is not provided
"""
if not user_id:
raise ValueError("user_id is required")
if ctx:
ctx.info(f"Attempting to delete user with ID: {user_id}")
try:
# First get the user to check if they are the Owner
if ctx:
ctx.debug("Getting user details to check role")
headers = await get_auth_headers(STAFF_API_KEY)
user_data = await make_ghost_request(
f"users/{user_id}/",
headers,
ctx
)
user = user_data.get("users", [{}])[0]
roles = [role.get('name') for role in user.get('roles', [])]
if 'Owner' in roles:
error_msg = "Cannot delete the Owner user"
if ctx:
ctx.error(error_msg)
raise GhostError(error_msg)
# Proceed with deletion
if ctx:
ctx.debug(f"Making API request to delete user {user_id}")
response = await make_ghost_request(
f"users/{user_id}/",
headers,
ctx,
http_method="DELETE"
)
return f"""
Successfully deleted user:
Name: {user.get('name', 'Unknown')}
Email: {user.get('email', 'Unknown')}
"""
except Exception as e:
if ctx:
ctx.error(f"Failed to delete user: {str(e)}")
raise
async def read_user(user_id: str, ctx: Context = None) -> str:
"""Get the details of a specific user.
Args:
user_id: The ID of the user to retrieve
ctx: Optional context for logging
Returns:
Formatted string containing the user details
"""
if ctx:
ctx.info(f"Reading user details for ID: {user_id}")
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 /users/{user_id}/")
data = await make_ghost_request(
f"users/{user_id}/?include=roles",
headers,
ctx
)
if ctx:
ctx.debug("Processing user data")
user = data["users"][0]
roles = [role.get('name') for role in user.get('roles', [])]
return f"""
Name: {user.get('name', 'Unknown')}
Email: {user.get('email', 'Unknown')}
Slug: {user.get('slug', 'Unknown')}
Status: {user.get('status', 'Unknown')}
Roles: {', '.join(roles)}
Location: {user.get('location', 'Not specified')}
Website: {user.get('website', 'None')}
Bio: {user.get('bio', 'No bio')}
Profile Image: {user.get('profile_image', 'None')}
Cover Image: {user.get('cover_image', 'None')}
Created: {user.get('created_at', 'Unknown')}
Last Seen: {user.get('last_seen', 'Never')}
"""
except GhostError as e:
return str(e)

View File

@@ -1,344 +0,0 @@
"""Webhook-related MCP tools for Ghost API."""
import json
from typing import Optional
from mcp.server.fastmcp import Context
from ..api import make_ghost_request, get_auth_headers
from ..config import STAFF_API_KEY
from ..exceptions import GhostError
async def create_webhook(
event: str,
target_url: str,
integration_id: Optional[str] = None,
name: Optional[str] = None,
secret: Optional[str] = None,
api_version: Optional[str] = None,
ctx: Context = None
) -> str:
"""Create a new webhook in Ghost.
Args:
event: Event to trigger the webhook (required)
target_url: URL to send the webhook to (required)
integration_id: ID of the integration (optional - only needed for user authentication)
name: Name of the webhook (optional)
secret: Secret for the webhook (optional)
api_version: API version for the webhook (optional)
ctx: Optional context for logging
Returns:
String representation of the created webhook
Raises:
GhostError: If the Ghost API request fails
ValueError: If required parameters are missing or invalid
"""
# List of valid webhook events from Ghost documentation
valid_events = [
'site.changed',
'post.added',
'post.deleted',
'post.edited',
'post.published',
'post.published.edited',
'post.unpublished',
'post.scheduled',
'post.unscheduled',
'post.rescheduled',
'page.added',
'page.deleted',
'page.edited',
'page.published',
'page.published.edited',
'page.unpublished',
'page.scheduled',
'page.unscheduled',
'page.rescheduled',
'tag.added',
'tag.edited',
'tag.deleted',
'post.tag.attached',
'post.tag.detached',
'page.tag.attached',
'page.tag.detached',
'member.added',
'member.edited',
'member.deleted'
]
if not all([event, target_url]):
raise ValueError("event and target_url are required")
if event not in valid_events:
raise ValueError(
f"Invalid event. Must be one of: {', '.join(valid_events)}\n"
"See Ghost documentation for event descriptions."
)
# Ensure target_url has a trailing slash and is a valid URL
if not target_url.endswith('/'):
target_url = f"{target_url}/"
try:
# Validate URL format
from urllib.parse import urlparse
parsed = urlparse(target_url)
if not all([parsed.scheme, parsed.netloc]):
raise ValueError
except ValueError:
raise ValueError(
"target_url must be a valid URL in the format 'https://example.com/hook/'"
)
if ctx:
ctx.info(f"Creating webhook for event: {event} targeting: {target_url}")
# Construct webhook data
webhook_data = {
"webhooks": [{
"event": event,
"target_url": target_url
}]
}
# Add optional fields if provided
webhook = webhook_data["webhooks"][0]
if integration_id:
webhook["integration_id"] = integration_id
if name:
webhook["name"] = name
if secret:
webhook["secret"] = secret
if api_version:
webhook["api_version"] = api_version
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 webhook")
response = await make_ghost_request(
"webhooks/",
headers,
ctx,
http_method="POST",
json_data=webhook_data
)
if ctx:
ctx.debug("Processing created webhook response")
webhook = response.get("webhooks", [{}])[0]
return f"""
Webhook created successfully:
Event: {webhook.get('event')}
Target URL: {webhook.get('target_url')}
Name: {webhook.get('name', 'None')}
API Version: {webhook.get('api_version', 'v5')}
Status: {webhook.get('status', 'available')}
Integration ID: {webhook.get('integration_id', 'None')}
Created: {webhook.get('created_at', 'Unknown')}
Last Triggered: {webhook.get('last_triggered_at', 'Never')}
Last Status: {webhook.get('last_triggered_status', 'N/A')}
Last Error: {webhook.get('last_triggered_error', 'None')}
ID: {webhook.get('id')}
"""
except Exception as e:
if ctx:
ctx.error(f"Failed to create webhook: {str(e)}")
raise
async def delete_webhook(
webhook_id: str,
ctx: Context = None
) -> str:
"""Delete a webhook from Ghost.
Args:
webhook_id: ID of the webhook to delete (required)
ctx: Optional context for logging
Returns:
Success message if deletion was successful
Raises:
GhostError: If the Ghost API request fails
ValueError: If webhook_id is not provided
"""
if not webhook_id:
raise ValueError("webhook_id is required")
if ctx:
ctx.info(f"Attempting to delete webhook with ID: {webhook_id}")
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 delete webhook {webhook_id}")
response = await make_ghost_request(
f"webhooks/{webhook_id}/",
headers,
ctx,
http_method="DELETE"
)
# Check for 204 status code
if response == {}:
return f"Webhook with ID {webhook_id} has been successfully deleted."
else:
raise GhostError("Unexpected response from Ghost API")
except Exception as e:
if ctx:
ctx.error(f"Failed to delete webhook: {str(e)}")
raise
async def update_webhook(
webhook_id: str,
event: Optional[str] = None,
target_url: Optional[str] = None,
name: Optional[str] = None,
api_version: Optional[str] = None,
ctx: Context = None
) -> str:
"""Update an existing webhook in Ghost.
Args:
webhook_id: ID of the webhook to update (required)
event: New event to trigger the webhook (optional)
target_url: New URL to send the webhook to (optional)
name: New name of the webhook (optional)
api_version: New API version for the webhook (optional)
ctx: Optional context for logging
Returns:
String representation of the updated webhook
Raises:
GhostError: If the Ghost API request fails
ValueError: If no fields to update are provided or if the event is invalid
"""
# List of valid webhook events from Ghost documentation
valid_events = [
'site.changed',
'post.added',
'post.deleted',
'post.edited',
'post.published',
'post.published.edited',
'post.unpublished',
'post.scheduled',
'post.unscheduled',
'post.rescheduled',
'page.added',
'page.deleted',
'page.edited',
'page.published',
'page.published.edited',
'page.unpublished',
'page.scheduled',
'page.unscheduled',
'page.rescheduled',
'tag.added',
'tag.edited',
'tag.deleted',
'post.tag.attached',
'post.tag.detached',
'page.tag.attached',
'page.tag.detached',
'member.added',
'member.edited',
'member.deleted'
]
if not any([event, target_url, name, api_version]):
raise ValueError("At least one field must be provided to update")
if event and event not in valid_events:
raise ValueError(
f"Invalid event. Must be one of: {', '.join(valid_events)}\n"
"See Ghost documentation for event descriptions."
)
if target_url:
# Ensure target_url has a trailing slash and is a valid URL
if not target_url.endswith('/'):
target_url = f"{target_url}/"
try:
# Validate URL format
from urllib.parse import urlparse
parsed = urlparse(target_url)
if not all([parsed.scheme, parsed.netloc]):
raise ValueError
except ValueError:
raise ValueError(
"target_url must be a valid URL in the format 'https://example.com/hook/'"
)
if ctx:
ctx.info(f"Updating webhook with ID: {webhook_id}")
# Construct webhook data
webhook_data = {
"webhooks": [{}]
}
webhook = webhook_data["webhooks"][0]
# Add fields to update
if event:
webhook["event"] = event
if target_url:
webhook["target_url"] = target_url
if name:
webhook["name"] = name
if api_version:
webhook["api_version"] = api_version
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 webhook {webhook_id}")
response = await make_ghost_request(
f"webhooks/{webhook_id}/",
headers,
ctx,
http_method="PUT",
json_data=webhook_data
)
if ctx:
ctx.debug("Processing updated webhook response")
webhook = response.get("webhooks", [{}])[0]
return f"""
Webhook updated successfully:
ID: {webhook.get('id')}
Event: {webhook.get('event')}
Target URL: {webhook.get('target_url')}
Name: {webhook.get('name', 'None')}
API Version: {webhook.get('api_version', 'v5')}
Status: {webhook.get('status', 'available')}
Integration ID: {webhook.get('integration_id', 'None')}
Created: {webhook.get('created_at', 'Unknown')}
Updated: {webhook.get('updated_at', 'Unknown')}
Last Triggered: {webhook.get('last_triggered_at', 'Never')}
Last Status: {webhook.get('last_triggered_status', 'N/A')}
Last Error: {webhook.get('last_triggered_error', 'None')}
"""
except Exception as e:
if ctx:
ctx.error(f"Failed to update webhook: {str(e)}")
raise

View File

@@ -1,12 +0,0 @@
#!/usr/bin/env python3
"""Main entry point for Ghost MCP server."""
from ghost_mcp import create_server
def main():
"""Initialize and run the Ghost MCP server."""
mcp = create_server()
mcp.run(transport='stdio')
if __name__ == "__main__":
main()

102
src/models.ts Normal file
View File

@@ -0,0 +1,102 @@
// Ghost blog post data model.
export interface Post {
id: string;
title: string;
status: string;
url: string;
created_at: string;
html?: string | null;
plaintext?: string | null;
excerpt?: string | null;
}
// Ghost user data model.
export interface User {
id: string;
name: string;
email: string;
slug: string;
status: string;
location?: string | null;
website?: string | null;
bio?: string | null;
profile_image?: string | null;
cover_image?: string | null;
created_at: string;
last_seen?: string | null;
roles: any[]; // Using any for simplicity, could define a more specific Role interface
}
// Ghost member data model.
export interface Member {
id: string;
name: string;
email: string;
status: string;
created_at: string;
note?: string | null;
labels: any[]; // Using any for simplicity, could define a more specific Label interface
email_count: number;
email_opened_count: number;
email_open_rate: number;
last_seen_at?: string | null;
newsletters: any[]; // Using any for simplicity, could define a more specific Newsletter interface
subscriptions: any[]; // Using any for simplicity, could define a more specific Subscription interface
}
// Ghost tier data model.
export interface Tier {
id: string;
name: string;
description?: string | null;
type: string;
active: boolean;
welcome_page_url?: string | null;
created_at: string;
updated_at: string;
monthly_price?: any | null; // Could define a more specific Price interface
yearly_price?: any | null; // Could define a more specific Price interface
currency: string;
benefits: string[];
}
// Ghost offer data model.
export interface Offer {
id: string;
name: string;
code: string;
display_title: string;
display_description?: string | null;
type: string;
status: string;
cadence: string;
amount: number;
duration: string;
currency: string;
tier: any; // Could define a more specific Tier interface or use the one above
redemption_count: number;
created_at: string;
}
// Ghost newsletter data model.
export interface Newsletter {
id: string;
name: string;
description?: string | null;
status: string;
visibility: string;
subscribe_on_signup: boolean;
sort_order: number;
sender_name: string;
sender_email?: string | null;
sender_reply_to?: string | null;
show_header_icon: boolean;
show_header_title: boolean;
show_header_name: boolean;
show_feature_image: boolean;
title_font_category: string;
body_font_category: string;
show_badge: boolean;
created_at: string;
updated_at: string;
}

34
src/prompts.ts Normal file
View File

@@ -0,0 +1,34 @@
// src/prompts.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from "./ghostApi";
// Example prompt: summarize-post
export function registerPrompts(server: McpServer) {
server.prompt(
"summarize-post",
{ postId: z.string() },
async ({ postId }) => {
// Fetch the post by ID
const post = await ghostApiClient.posts.read({ id: postId });
const title = post.title || "";
const excerpt = post.excerpt || "";
const html = post.html || "";
// Compose a summary message
const summary = `Title: ${title}\nExcerpt: ${excerpt}\n\nContent Preview:\n${html.slice(0, 300)}...`;
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Summarize the following Ghost post:\n\n${summary}`,
},
},
],
};
}
);
}

219
src/resources.ts Normal file
View File

@@ -0,0 +1,219 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from './ghostApi';
import { Post, User, Member, Tier, Offer, Newsletter } from './models'; // Import data models
// Type definitions compatible with MCP SDK resource handler expectations
type Variables = Record<string, string | string[]>;
type ReadResourceTemplateCallback = (uri: URL, variables: Variables) => Promise<any>;
// Handler for the user resource
export const handleUserResource: ReadResourceTemplateCallback = async (uri: URL, variables: Variables): Promise<any> => {
try {
const userId = variables.user_id as string; // Access parameter from variables
if (!userId) {
// TODO: Return a structured MCP error for missing parameter
throw new Error("Missing user_id parameter");
}
// TODO: Use ghostApiClient to fetch user data by ID
// const user: User = await ghostApiClient.users.read({ id: userId });
// return {
// contents: [{
// uri: uri.href,
// text: JSON.stringify(user, null, 2), // Or format as needed
// mimeType: 'application/json'
// }]
// };
return {
contents: [{
uri: uri.href,
text: `User resource requested for ID: ${userId}`,
mimeType: 'text/plain'
}]
};
} catch (error) {
console.error(`Error fetching user ${variables.user_id}:`, error);
// TODO: Return an error result in the MCP format
throw error;
}
};
// Handler for the member resource
export const handleMemberResource: ReadResourceTemplateCallback = async (uri: URL, variables: Variables): Promise<any> => {
try {
const memberId = variables.member_id as string; // Access parameter from variables
if (!memberId) {
// TODO: Return a structured MCP error for missing parameter
throw new Error("Missing member_id parameter");
}
// TODO: Use ghostApiClient to fetch member data by ID
// const member: Member = await ghostApiClient.members.read({ id: memberId });
// return {
// contents: [{
// uri: uri.href,
// text: JSON.stringify(member, null, 2), // Or format as needed
// mimeType: 'application/json'
// }]
// };
return {
contents: [{
uri: uri.href,
text: `Member resource requested for ID: ${memberId}`,
mimeType: 'text/plain'
}]
};
} catch (error) {
console.error(`Error fetching member ${variables.member_id}:`, error);
// TODO: Return an error result in the MCP format
throw error;
}
};
// Handler for the tier resource
export const handleTierResource: ReadResourceTemplateCallback = async (uri: URL, variables: Variables): Promise<any> => {
try {
const tierId = variables.tier_id as string; // Access parameter from variables
if (!tierId) {
// TODO: Return a structured MCP error for missing parameter
throw new Error("Missing tier_id parameter");
}
// TODO: Use ghostApiClient to fetch tier data by ID
// const tier: Tier = await ghostApiClient.tiers.read({ id: tierId });
// return {
// contents: [{
// uri: uri.href,
// text: JSON.stringify(tier, null, 2), // Or format as needed
// mimeType: 'application/json'
// }]
// };
return {
contents: [{
uri: uri.href,
text: `Tier resource requested for ID: ${tierId}`,
mimeType: 'text/plain'
}]
};
} catch (error) {
console.error(`Error fetching tier ${variables.tier_id}:`, error);
// TODO: Return an error result in the MCP format
throw error;
}
};
// Handler for the offer resource
export const handleOfferResource: ReadResourceTemplateCallback = async (uri: URL, variables: Variables): Promise<any> => {
try {
const offerId = variables.offer_id as string; // Access parameter from variables
if (!offerId) {
// TODO: Return a structured MCP error for missing parameter
throw new Error("Missing offer_id parameter");
}
// TODO: Use ghostApiClient to fetch offer data by ID
// const offer: Offer = await ghostApiClient.offers.read({ id: offerId });
// return {
// contents: [{
// uri: uri.href,
// text: JSON.stringify(offer, null, 2), // Or format as needed
// mimeType: 'application/json'
// }]
// };
return {
contents: [{
uri: uri.href,
text: `Offer resource requested for ID: ${offerId}`,
mimeType: 'text/plain'
}]
};
} catch (error) {
console.error(`Error fetching offer ${variables.offer_id}:`, error);
// TODO: Return an error result in the MCP format
throw error;
}
};
// Handler for the newsletter resource
export const handleNewsletterResource: ReadResourceTemplateCallback = async (uri: URL, variables: Variables): Promise<any> => {
try {
const newsletterId = variables.newsletter_id as string; // Access parameter from variables
if (!newsletterId) {
// TODO: Return a structured MCP error for missing parameter
throw new Error("Missing newsletter_id parameter");
}
// TODO: Use ghostApiClient to fetch newsletter data by ID
// const newsletter: Newsletter = await ghostApiClient.newsletters.read({ id: newsletterId });
// return {
// contents: [{
// uri: uri.href,
// text: JSON.stringify(newsletter, null, 2), // Or format as needed
// mimeType: 'application/json'
// }]
// };
return {
contents: [{
uri: uri.href,
text: `Newsletter resource requested for ID: ${newsletterId}`,
mimeType: 'text/plain'
}]
};
} catch (error) {
console.error(`Error fetching newsletter ${variables.newsletter_id}:`, error);
// TODO: Return an error result in the MCP format
throw error;
}
};
// Handler for the post resource
export const handlePostResource: ReadResourceTemplateCallback = async (uri: URL, variables: Variables): Promise<any> => {
try {
const postId = variables.post_id as string; // Access parameter from variables
if (!postId) {
// TODO: Return a structured MCP error for missing parameter
throw new Error("Missing post_id parameter");
}
// TODO: Use ghostApiClient to fetch post data by ID
// const post: Post = await ghostApiClient.posts.read({ id: postId });
// return {
// contents: [{
// uri: uri.href,
// text: JSON.stringify(post, null, 2), // Or format as needed
// mimeType: 'application/json'
// }]
// };
return {
contents: [{
uri: uri.href,
text: `Post resource requested for ID: ${postId}`,
mimeType: 'text/plain'
}]
};
} catch (error) {
console.error(`Error fetching post ${variables.post_id}:`, error);
// TODO: Return an error result in the MCP format
throw error;
}
};
// Handler for the blog info resource
export async function handleBlogInfoResource(uri: URL): Promise<any> {
try {
// TODO: Use ghostApiClient to fetch blog info
// const blogInfo = await ghostApiClient.site.read();
// return {
// contents: [{
// uri: uri.href,
// text: JSON.stringify(blogInfo, null, 2), // Or format as needed
// mimeType: 'application/json'
// }]
// };
return {
contents: [{
uri: uri.href,
text: `Blog info resource requested`,
mimeType: 'text/plain'
}]
};
} catch (error) {
console.error(`Error fetching blog info:`, error);
// TODO: Return an error result in the MCP format
throw error;
}
}

72
src/server.ts Normal file
View File

@@ -0,0 +1,72 @@
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ghostApiClient } from './ghostApi'; // Import the initialized Ghost API client
import {
handleUserResource,
handleMemberResource,
handleTierResource,
handleOfferResource,
handleNewsletterResource,
handlePostResource,
handleBlogInfoResource
} from './resources'; // Import resource handlers
// Create an MCP server instance
const server = new McpServer({
name: "ghost-mcp-ts",
version: "1.0.0", // TODO: Get version from package.json
capabilities: {
resources: {}, // Capabilities will be enabled as handlers are registered
tools: {},
prompts: {},
logging: {} // Enable logging capability
}
});
// Register resource handlers
server.resource("user", new ResourceTemplate("user://{user_id}", { list: undefined }), handleUserResource);
server.resource("member", new ResourceTemplate("member://{member_id}", { list: undefined }), handleMemberResource);
server.resource("tier", new ResourceTemplate("tier://{tier_id}", { list: undefined }), handleTierResource);
server.resource("offer", new ResourceTemplate("offer://{offer_id}", { list: undefined }), handleOfferResource);
server.resource("newsletter", new ResourceTemplate("newsletter://{newsletter_id}", { list: undefined }), handleNewsletterResource);
server.resource("post", new ResourceTemplate("post://{post_id}", { list: undefined }), handlePostResource);
server.resource("blog-info", "blog://info", handleBlogInfoResource);
// Register tools
import { registerPostTools } from "./tools/posts";
import { registerMemberTools } from "./tools/members";
registerPostTools(server);
registerMemberTools(server);
import { registerUserTools } from "./tools/users";
registerUserTools(server);
import { registerTagTools } from "./tools/tags";
registerTagTools(server);
import { registerTierTools } from "./tools/tiers";
registerTierTools(server);
import { registerOfferTools } from "./tools/offers";
registerOfferTools(server);
import { registerNewsletterTools } from "./tools/newsletters";
registerNewsletterTools(server);
import { registerInviteTools } from "./tools/invites";
registerInviteTools(server);
import { registerRoleTools } from "./tools/roles";
registerRoleTools(server);
import { registerWebhookTools } from "./tools/webhooks";
registerWebhookTools(server);
import { registerPrompts } from "./prompts";
registerPrompts(server);
// Set up and connect to the standard I/O transport
async function startServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Ghost MCP TypeScript Server running on stdio"); // Log to stderr
}
// Start the server
startServer().catch((error: any) => { // Add type annotation for error
console.error("Fatal error starting server:", error);
process.exit(1);
});

72
src/tools/invites.ts Normal file
View File

@@ -0,0 +1,72 @@
// src/tools/invites.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from "../ghostApi";
// Parameter schemas as ZodRawShape (object literals)
const browseParams = {
filter: z.string().optional(),
limit: z.number().optional(),
page: z.number().optional(),
order: z.string().optional(),
};
const addParams = {
role_id: z.string(),
email: z.string(),
};
const deleteParams = {
id: z.string(),
};
export function registerInviteTools(server: McpServer) {
// Browse invites
server.tool(
"invites_browse",
browseParams,
async (args, _extra) => {
const invites = await ghostApiClient.invites.browse(args);
return {
content: [
{
type: "text",
text: JSON.stringify(invites, null, 2),
},
],
};
}
);
// Add invite
server.tool(
"invites_add",
addParams,
async (args, _extra) => {
const invite = await ghostApiClient.invites.add(args);
return {
content: [
{
type: "text",
text: JSON.stringify(invite, null, 2),
},
],
};
}
);
// Delete invite
server.tool(
"invites_delete",
deleteParams,
async (args, _extra) => {
await ghostApiClient.invites.delete(args);
return {
content: [
{
type: "text",
text: `Invite with id ${args.id} deleted.`,
},
],
};
}
);
}

121
src/tools/members.ts Normal file
View File

@@ -0,0 +1,121 @@
// src/tools/members.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from "../ghostApi";
// Parameter schemas as ZodRawShape (object literals)
const browseParams = {
filter: z.string().optional(),
limit: z.number().optional(),
page: z.number().optional(),
order: z.string().optional(),
};
const readParams = {
id: z.string().optional(),
email: z.string().optional(),
};
const addParams = {
email: z.string(),
name: z.string().optional(),
note: z.string().optional(),
labels: z.array(z.object({ name: z.string(), slug: z.string().optional() })).optional(),
newsletters: z.array(z.object({ id: z.string() })).optional(),
};
const editParams = {
id: z.string(),
email: z.string().optional(),
name: z.string().optional(),
note: z.string().optional(),
labels: z.array(z.object({ name: z.string(), slug: z.string().optional() })).optional(),
newsletters: z.array(z.object({ id: z.string() })).optional(),
};
const deleteParams = {
id: z.string(),
};
export function registerMemberTools(server: McpServer) {
// Browse members
server.tool(
"members_browse",
browseParams,
async (args, _extra) => {
const members = await ghostApiClient.members.browse(args);
return {
content: [
{
type: "text",
text: JSON.stringify(members, null, 2),
},
],
};
}
);
// Read member
server.tool(
"members_read",
readParams,
async (args, _extra) => {
const member = await ghostApiClient.members.read(args);
return {
content: [
{
type: "text",
text: JSON.stringify(member, null, 2),
},
],
};
}
);
// Add member
server.tool(
"members_add",
addParams,
async (args, _extra) => {
const member = await ghostApiClient.members.add(args);
return {
content: [
{
type: "text",
text: JSON.stringify(member, null, 2),
},
],
};
}
);
// Edit member
server.tool(
"members_edit",
editParams,
async (args, _extra) => {
const member = await ghostApiClient.members.edit(args);
return {
content: [
{
type: "text",
text: JSON.stringify(member, null, 2),
},
],
};
}
);
// Delete member
server.tool(
"members_delete",
deleteParams,
async (args, _extra) => {
await ghostApiClient.members.delete(args);
return {
content: [
{
type: "text",
text: `Member with id ${args.id} deleted.`,
},
],
};
}
);
}

144
src/tools/newsletters.ts Normal file
View File

@@ -0,0 +1,144 @@
// src/tools/newsletters.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from "../ghostApi";
// Parameter schemas as ZodRawShape (object literals)
const browseParams = {
filter: z.string().optional(),
limit: z.number().optional(),
page: z.number().optional(),
order: z.string().optional(),
};
const readParams = {
id: z.string().optional(),
slug: z.string().optional(),
};
const addParams = {
name: z.string(),
description: z.string().optional(),
sender_reply_to: z.string().optional(),
status: z.string().optional(),
subscribe_on_signup: z.boolean().optional(),
show_header_icon: z.boolean().optional(),
show_header_title: z.boolean().optional(),
show_header_name: z.boolean().optional(),
title_font_category: z.string().optional(),
title_alignment: z.string().optional(),
show_feature_image: z.boolean().optional(),
body_font_category: z.string().optional(),
show_badge: z.boolean().optional(),
// Add more fields as needed
};
const editParams = {
id: z.string(),
name: z.string().optional(),
description: z.string().optional(),
sender_name: z.string().optional(),
sender_email: z.string().optional(),
sender_reply_to: z.string().optional(),
status: z.string().optional(),
subscribe_on_signup: z.boolean().optional(),
sort_order: z.number().optional(),
header_image: z.string().optional(),
show_header_icon: z.boolean().optional(),
show_header_title: z.boolean().optional(),
title_font_category: z.string().optional(),
title_alignment: z.string().optional(),
show_feature_image: z.boolean().optional(),
body_font_category: z.string().optional(),
footer_content: z.string().optional(),
show_badge: z.boolean().optional(),
show_header_name: z.boolean().optional(),
// Add more fields as needed
};
const deleteParams = {
id: z.string(),
};
export function registerNewsletterTools(server: McpServer) {
// Browse newsletters
server.tool(
"newsletters_browse",
browseParams,
async (args, _extra) => {
const newsletters = await ghostApiClient.newsletters.browse(args);
return {
content: [
{
type: "text",
text: JSON.stringify(newsletters, null, 2),
},
],
};
}
);
// Read newsletter
server.tool(
"newsletters_read",
readParams,
async (args, _extra) => {
const newsletter = await ghostApiClient.newsletters.read(args);
return {
content: [
{
type: "text",
text: JSON.stringify(newsletter, null, 2),
},
],
};
}
);
// Add newsletter
server.tool(
"newsletters_add",
addParams,
async (args, _extra) => {
const newsletter = await ghostApiClient.newsletters.add(args);
return {
content: [
{
type: "text",
text: JSON.stringify(newsletter, null, 2),
},
],
};
}
);
// Edit newsletter
server.tool(
"newsletters_edit",
editParams,
async (args, _extra) => {
const newsletter = await ghostApiClient.newsletters.edit(args);
return {
content: [
{
type: "text",
text: JSON.stringify(newsletter, null, 2),
},
],
};
}
);
// Delete newsletter
server.tool(
"newsletters_delete",
deleteParams,
async (args, _extra) => {
await ghostApiClient.newsletters.delete(args);
return {
content: [
{
type: "text",
text: `Newsletter with id ${args.id} deleted.`,
},
],
};
}
);
}

128
src/tools/offers.ts Normal file
View File

@@ -0,0 +1,128 @@
// src/tools/offers.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from "../ghostApi";
// Parameter schemas as ZodRawShape (object literals)
const browseParams = {
filter: z.string().optional(),
limit: z.number().optional(),
page: z.number().optional(),
order: z.string().optional(),
};
const readParams = {
id: z.string().optional(),
code: z.string().optional(),
};
const addParams = {
name: z.string(),
code: z.string(),
cadence: z.string(),
duration: z.string(),
amount: z.number(),
tier_id: z.string(),
type: z.string(),
display_title: z.string().optional(),
display_description: z.string().optional(),
duration_in_months: z.number().optional(),
currency: z.string().optional(),
// Add more fields as needed
};
const editParams = {
id: z.string(),
name: z.string().optional(),
code: z.string().optional(),
display_title: z.string().optional(),
display_description: z.string().optional(),
// Only a subset of fields are editable per Ghost API docs
};
const deleteParams = {
id: z.string(),
};
export function registerOfferTools(server: McpServer) {
// Browse offers
server.tool(
"offers_browse",
browseParams,
async (args, _extra) => {
const offers = await ghostApiClient.offers.browse(args);
return {
content: [
{
type: "text",
text: JSON.stringify(offers, null, 2),
},
],
};
}
);
// Read offer
server.tool(
"offers_read",
readParams,
async (args, _extra) => {
const offer = await ghostApiClient.offers.read(args);
return {
content: [
{
type: "text",
text: JSON.stringify(offer, null, 2),
},
],
};
}
);
// Add offer
server.tool(
"offers_add",
addParams,
async (args, _extra) => {
const offer = await ghostApiClient.offers.add(args);
return {
content: [
{
type: "text",
text: JSON.stringify(offer, null, 2),
},
],
};
}
);
// Edit offer
server.tool(
"offers_edit",
editParams,
async (args, _extra) => {
const offer = await ghostApiClient.offers.edit(args);
return {
content: [
{
type: "text",
text: JSON.stringify(offer, null, 2),
},
],
};
}
);
// Delete offer
server.tool(
"offers_delete",
deleteParams,
async (args, _extra) => {
await ghostApiClient.offers.delete(args);
return {
content: [
{
type: "text",
text: `Offer with id ${args.id} deleted.`,
},
],
};
}
);
}

124
src/tools/posts.ts Normal file
View File

@@ -0,0 +1,124 @@
// src/tools/posts.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from "../ghostApi";
// Parameter schemas as ZodRawShape (object literals)
const browseParams = {
filter: z.string().optional(),
limit: z.number().optional(),
page: z.number().optional(),
order: z.string().optional(),
};
const readParams = {
id: z.string().optional(),
slug: z.string().optional(),
};
const addParams = {
title: z.string(),
html: z.string().optional(),
lexical: z.string().optional(),
status: z.string().optional(),
};
const editParams = {
id: z.string(),
title: z.string().optional(),
html: z.string().optional(),
lexical: z.string().optional(),
status: z.string().optional(),
updated_at: z.string(),
};
const deleteParams = {
id: z.string(),
};
export function registerPostTools(server: McpServer) {
// Browse posts
server.tool(
"posts_browse",
browseParams,
async (args, _extra) => {
const posts = await ghostApiClient.posts.browse(args);
return {
content: [
{
type: "text",
text: JSON.stringify(posts, null, 2),
},
],
};
}
);
// Read post
server.tool(
"posts_read",
readParams,
async (args, _extra) => {
const post = await ghostApiClient.posts.read(args);
return {
content: [
{
type: "text",
text: JSON.stringify(post, null, 2),
},
],
};
}
);
// Add post
server.tool(
"posts_add",
addParams,
async (args, _extra) => {
// If html is present, use source: "html" to ensure Ghost uses the html content
const options = args.html ? { source: "html" } : undefined;
const post = await ghostApiClient.posts.add(args, options);
return {
content: [
{
type: "text",
text: JSON.stringify(post, null, 2),
},
],
};
}
);
// Edit post
server.tool(
"posts_edit",
editParams,
async (args, _extra) => {
// If html is present, use source: "html" to ensure Ghost uses the html content for updates
const options = args.html ? { source: "html" } : undefined;
const post = await ghostApiClient.posts.edit(args, options);
return {
content: [
{
type: "text",
text: JSON.stringify(post, null, 2),
},
],
};
}
);
// Delete post
server.tool(
"posts_delete",
deleteParams,
async (args, _extra) => {
await ghostApiClient.posts.delete(args);
return {
content: [
{
type: "text",
text: `Post with id ${args.id} deleted.`,
},
],
};
}
);
}

52
src/tools/roles.ts Normal file
View File

@@ -0,0 +1,52 @@
// src/tools/roles.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from "../ghostApi";
// Parameter schemas as ZodRawShape (object literals)
const browseParams = {
filter: z.string().optional(),
limit: z.number().optional(),
page: z.number().optional(),
order: z.string().optional(),
};
const readParams = {
id: z.string().optional(),
name: z.string().optional(),
};
export function registerRoleTools(server: McpServer) {
// Browse roles
server.tool(
"roles_browse",
browseParams,
async (args, _extra) => {
const roles = await ghostApiClient.roles.browse(args);
return {
content: [
{
type: "text",
text: JSON.stringify(roles, null, 2),
},
],
};
}
);
// Read role
server.tool(
"roles_read",
readParams,
async (args, _extra) => {
const role = await ghostApiClient.roles.read(args);
return {
content: [
{
type: "text",
text: JSON.stringify(role, null, 2),
},
],
};
}
);
}

119
src/tools/tags.ts Normal file
View File

@@ -0,0 +1,119 @@
// src/tools/tags.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from "../ghostApi";
// Parameter schemas as ZodRawShape (object literals)
const browseParams = {
filter: z.string().optional(),
limit: z.number().optional(),
page: z.number().optional(),
order: z.string().optional(),
};
const readParams = {
id: z.string().optional(),
slug: z.string().optional(),
};
const addParams = {
name: z.string(),
description: z.string().optional(),
slug: z.string().optional(),
// Add more fields as needed
};
const editParams = {
id: z.string(),
name: z.string().optional(),
description: z.string().optional(),
slug: z.string().optional(),
// Add more fields as needed
};
const deleteParams = {
id: z.string(),
};
export function registerTagTools(server: McpServer) {
// Browse tags
server.tool(
"tags_browse",
browseParams,
async (args, _extra) => {
const tags = await ghostApiClient.tags.browse(args);
return {
content: [
{
type: "text",
text: JSON.stringify(tags, null, 2),
},
],
};
}
);
// Read tag
server.tool(
"tags_read",
readParams,
async (args, _extra) => {
const tag = await ghostApiClient.tags.read(args);
return {
content: [
{
type: "text",
text: JSON.stringify(tag, null, 2),
},
],
};
}
);
// Add tag
server.tool(
"tags_add",
addParams,
async (args, _extra) => {
const tag = await ghostApiClient.tags.add(args);
return {
content: [
{
type: "text",
text: JSON.stringify(tag, null, 2),
},
],
};
}
);
// Edit tag
server.tool(
"tags_edit",
editParams,
async (args, _extra) => {
const tag = await ghostApiClient.tags.edit(args);
return {
content: [
{
type: "text",
text: JSON.stringify(tag, null, 2),
},
],
};
}
);
// Delete tag
server.tool(
"tags_delete",
deleteParams,
async (args, _extra) => {
await ghostApiClient.tags.delete(args);
return {
content: [
{
type: "text",
text: `Tag with id ${args.id} deleted.`,
},
],
};
}
);
}

131
src/tools/tiers.ts Normal file
View File

@@ -0,0 +1,131 @@
// src/tools/tiers.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from "../ghostApi";
// Parameter schemas as ZodRawShape (object literals)
const browseParams = {
filter: z.string().optional(),
limit: z.number().optional(),
page: z.number().optional(),
order: z.string().optional(),
include: z.string().optional(),
};
const readParams = {
id: z.string().optional(),
slug: z.string().optional(),
include: z.string().optional(),
};
const addParams = {
name: z.string(),
description: z.string().optional(),
welcome_page_url: z.string().optional(),
visibility: z.string().optional(),
monthly_price: z.number().optional(),
yearly_price: z.number().optional(),
currency: z.string().optional(),
benefits: z.array(z.string()).optional(),
// Add more fields as needed
};
const editParams = {
id: z.string(),
name: z.string().optional(),
description: z.string().optional(),
welcome_page_url: z.string().optional(),
visibility: z.string().optional(),
monthly_price: z.number().optional(),
yearly_price: z.number().optional(),
currency: z.string().optional(),
benefits: z.array(z.string()).optional(),
// Add more fields as needed
};
const deleteParams = {
id: z.string(),
};
export function registerTierTools(server: McpServer) {
// Browse tiers
server.tool(
"tiers_browse",
browseParams,
async (args, _extra) => {
const tiers = await ghostApiClient.tiers.browse(args);
return {
content: [
{
type: "text",
text: JSON.stringify(tiers, null, 2),
},
],
};
}
);
// Read tier
server.tool(
"tiers_read",
readParams,
async (args, _extra) => {
const tier = await ghostApiClient.tiers.read(args);
return {
content: [
{
type: "text",
text: JSON.stringify(tier, null, 2),
},
],
};
}
);
// Add tier
server.tool(
"tiers_add",
addParams,
async (args, _extra) => {
const tier = await ghostApiClient.tiers.add(args);
return {
content: [
{
type: "text",
text: JSON.stringify(tier, null, 2),
},
],
};
}
);
// Edit tier
server.tool(
"tiers_edit",
editParams,
async (args, _extra) => {
const tier = await ghostApiClient.tiers.edit(args);
return {
content: [
{
type: "text",
text: JSON.stringify(tier, null, 2),
},
],
};
}
);
// Delete tier
server.tool(
"tiers_delete",
deleteParams,
async (args, _extra) => {
await ghostApiClient.tiers.delete(args);
return {
content: [
{
type: "text",
text: `Tier with id ${args.id} deleted.`,
},
],
};
}
);
}

102
src/tools/users.ts Normal file
View File

@@ -0,0 +1,102 @@
// src/tools/users.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from "../ghostApi";
// Parameter schemas as ZodRawShape (object literals)
const browseParams = {
filter: z.string().optional(),
limit: z.number().optional(),
page: z.number().optional(),
order: z.string().optional(),
};
const readParams = {
id: z.string().optional(),
email: z.string().optional(),
slug: z.string().optional(),
};
const editParams = {
id: z.string(),
name: z.string().optional(),
email: z.string().optional(),
slug: z.string().optional(),
bio: z.string().optional(),
website: z.string().optional(),
location: z.string().optional(),
facebook: z.string().optional(),
twitter: z.string().optional(),
// Add more fields as needed
};
const deleteParams = {
id: z.string(),
};
export function registerUserTools(server: McpServer) {
// Browse users
server.tool(
"users_browse",
browseParams,
async (args, _extra) => {
const users = await ghostApiClient.users.browse(args);
return {
content: [
{
type: "text",
text: JSON.stringify(users, null, 2),
},
],
};
}
);
// Read user
server.tool(
"users_read",
readParams,
async (args, _extra) => {
const user = await ghostApiClient.users.read(args);
return {
content: [
{
type: "text",
text: JSON.stringify(user, null, 2),
},
],
};
}
);
// Edit user
server.tool(
"users_edit",
editParams,
async (args, _extra) => {
const user = await ghostApiClient.users.edit(args);
return {
content: [
{
type: "text",
text: JSON.stringify(user, null, 2),
},
],
};
}
);
// Delete user
server.tool(
"users_delete",
deleteParams,
async (args, _extra) => {
await ghostApiClient.users.delete(args);
return {
content: [
{
type: "text",
text: `User with id ${args.id} deleted.`,
},
],
};
}
);
}

77
src/tools/webhooks.ts Normal file
View File

@@ -0,0 +1,77 @@
// src/tools/webhooks.ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ghostApiClient } from "../ghostApi";
// Parameter schemas as ZodRawShape (object literals)
const addParams = {
event: z.string(),
target_url: z.string(),
name: z.string().optional(),
secret: z.string().optional(),
api_version: z.string().optional(),
integration_id: z.string().optional(), // Required for user-authenticated requests
};
const editParams = {
id: z.string(),
event: z.string().optional(),
target_url: z.string().optional(),
name: z.string().optional(),
api_version: z.string().optional(),
};
const deleteParams = {
id: z.string(),
};
export function registerWebhookTools(server: McpServer) {
// Add webhook
server.tool(
"webhooks_add",
addParams,
async (args, _extra) => {
const webhook = await ghostApiClient.webhooks.add(args);
return {
content: [
{
type: "text",
text: JSON.stringify(webhook, null, 2),
},
],
};
}
);
// Edit webhook
server.tool(
"webhooks_edit",
editParams,
async (args, _extra) => {
const webhook = await ghostApiClient.webhooks.edit(args);
return {
content: [
{
type: "text",
text: JSON.stringify(webhook, null, 2),
},
],
};
}
);
// Delete webhook
server.tool(
"webhooks_delete",
deleteParams,
async (args, _extra) => {
await ghostApiClient.webhooks.delete(args);
return {
content: [
{
type: "text",
text: `Webhook with id ${args.id} deleted.`,
},
],
};
}
);
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["node_modules"]
}

1
types/tryghost__admin-api.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '@tryghost/admin-api';

370
uv.lock generated
View File

@@ -1,370 +0,0 @@
version = 1
requires-python = ">=3.12"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
[[package]]
name = "anyio"
version = "4.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
]
[[package]]
name = "certifi"
version = "2025.1.31"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
]
[[package]]
name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "ghost-mcp"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "mcp", extra = ["cli"] },
{ name = "pyjwt" },
{ name = "pytz" },
]
[package.metadata]
requires-dist = [
{ name = "httpx" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.2.1" },
{ name = "pyjwt" },
{ name = "pytz" },
]
[[package]]
name = "h11"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
]
[[package]]
name = "httpcore"
version = "1.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "httpx-sse"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
]
[[package]]
name = "mcp"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "uvicorn" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/30/51e4555826126e3954fa2ab1e934bf74163c5fe05e98f38ca4d0f8abbf63/mcp-1.2.1.tar.gz", hash = "sha256:c9d43dbfe943aa1530e2be8f54b73af3ebfb071243827b4483d421684806cb45", size = 103968 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/0d/6770742a84c8aa1d36c0d628896a380584c5759612e66af7446af07d8775/mcp-1.2.1-py3-none-any.whl", hash = "sha256:579bf9c9157850ebb1344f3ca6f7a3021b0123c44c9f089ef577a7062522f0fd", size = 66453 },
]
[package.optional-dependencies]
cli = [
{ name = "python-dotenv" },
{ name = "typer" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "pydantic"
version = "2.10.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 },
]
[[package]]
name = "pydantic-core"
version = "2.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 },
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 },
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 },
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 },
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 },
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 },
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 },
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 },
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 },
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 },
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 },
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 },
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 },
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 },
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 },
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 },
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 },
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 },
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 },
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 },
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 },
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 },
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 },
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 },
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 },
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 },
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 },
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 },
]
[[package]]
name = "pydantic-settings"
version = "2.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718 },
]
[[package]]
name = "pygments"
version = "2.19.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 },
]
[[package]]
name = "python-dotenv"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
]
[[package]]
name = "pytz"
version = "2025.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 },
]
[[package]]
name = "rich"
version = "13.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
[[package]]
name = "sse-starlette"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 },
]
[[package]]
name = "starlette"
version = "0.45.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/fb/2984a686808b89a6781526129a4b51266f678b2d2b97ab2d325e56116df8/starlette-0.45.3.tar.gz", hash = "sha256:2cbcba2a75806f8a41c722141486f37c28e30a0921c5f6fe4346cb0dcee1302f", size = 2574076 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/61/f2b52e107b1fc8944b33ef56bf6ac4ebbe16d91b94d2b87ce013bf63fb84/starlette-0.45.3-py3-none-any.whl", hash = "sha256:dfb6d332576f136ec740296c7e8bb8c8a7125044e7c6da30744718880cdd059d", size = 71507 },
]
[[package]]
name = "typer"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 },
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
]
[[package]]
name = "uvicorn"
version = "0.34.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 },
]