mirror of
https://github.com/jlengrand/ghost-mcp.git
synced 2026-03-10 00:11:21 +00:00
♻️ Refactor it into typescript
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,6 +9,7 @@ wheels/
|
|||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
.vscode
|
.vscode
|
||||||
ghost-admin-api.md
|
|
||||||
mcp-python-sdk.md
|
|
||||||
.qodo
|
.qodo
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
knowledge/
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
3.12
|
|
||||||
44
Dockerfile
44
Dockerfile
@@ -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
221
README.md
@@ -1,185 +1,122 @@
|
|||||||
# Ghost MCP Server
|
# Ghost MCP Server
|
||||||
|
|
||||||
[](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.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
## 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
|
- Comprehensive entity access including posts, users, members, tiers, offers, and newsletters
|
||||||
- Advanced search functionality with both fuzzy and exact matching options
|
- Advanced search functionality with both fuzzy and exact matching options
|
||||||
- Detailed, human-readable output for Ghost entities
|
- Detailed, human-readable output for Ghost entities
|
||||||
- Robust error handling using custom `GhostError` exceptions
|
- Robust error handling using custom `GhostError` exceptions
|
||||||
- Integrated logging support via MCP context for enhanced troubleshooting
|
- 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
|
## 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`:
|
To use this with MCP clients, for instance, Claude Desktop, add the following to your `claude_desktop_config.json`:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"ghost": {
|
"ghost-mcp-ts": {
|
||||||
"command": "/Users/username/.local/bin/uv",
|
"command": "node",
|
||||||
"args": [
|
"args": [
|
||||||
"--directory",
|
"ABSOLUTE_PATH_TO_GHOST_MCP_SERVER/build/server.js",
|
||||||
"/path/to/ghost-mcp",
|
],
|
||||||
"run",
|
"env": {
|
||||||
"src/main.py"
|
"GHOST_API_URL": "https://yourblog.com",
|
||||||
],
|
"GHOST_ADMIN_API_KEY": "your_admin_api_key",
|
||||||
"env": {
|
"GHOST_API_VERSION": "v5.0"
|
||||||
"GHOST_API_URL": "your_ghost_api_url",
|
}
|
||||||
"GHOST_STAFF_API_KEY": "your_staff_api_key"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing with MCP Inspector
|
## Available Resources
|
||||||
|
|
||||||
```bash
|
The following Ghost CMS resources are available through this MCP server:
|
||||||
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
|
|
||||||
```
|
- **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
|
## 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
|
### Posts
|
||||||
- `ghost`: Central tool for accessing all Ghost CMS functionality
|
- **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:
|
### Newsletters
|
||||||
1. `action`: The specific Ghost operation to perform
|
- **Browse Newsletters**: List newsletters.
|
||||||
2. `params`: A dictionary of parameters for the specified action
|
- **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:
|
### Offers
|
||||||
```python
|
- **Browse Offers**: List offers.
|
||||||
# List posts
|
- **Read Offer**: Retrieve an offer by ID.
|
||||||
ghost(action="list_posts", params={"format": "text", "page": 1, "limit": 15})
|
- **Add Offer**: Create a new offer.
|
||||||
|
- **Edit Offer**: Update offer details.
|
||||||
|
- **Delete Offer**: Remove an offer.
|
||||||
|
|
||||||
# Search posts by title
|
### Invites
|
||||||
ghost(action="search_posts_by_title", params={"query": "Welcome", "exact": False})
|
- **Browse Invites**: List invites.
|
||||||
|
- **Add Invite**: Create a new invite.
|
||||||
|
- **Delete Invite**: Remove an invite.
|
||||||
|
|
||||||
# Create a post
|
### Roles
|
||||||
ghost(action="create_post", params={
|
- **Browse Roles**: List roles.
|
||||||
"post_data": {
|
- **Read Role**: Retrieve a role by ID.
|
||||||
"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}}"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
### Users
|
||||||
- `list_posts`: List blog posts with pagination
|
- **Browse Users**: List users.
|
||||||
- `search_posts_by_title`: Search for posts by title
|
- **Read User**: Retrieve a user by ID or slug.
|
||||||
- `read_post`: Retrieve full content of a specific post
|
- **Edit User**: Update user details.
|
||||||
- `create_post`: Create a new post
|
- **Delete User**: Remove a user.
|
||||||
- `update_post`: Update a specific post
|
|
||||||
- `delete_post`: Delete a specific post
|
|
||||||
- `batchly_update_posts`: Update multiple posts in a single request
|
|
||||||
|
|
||||||
#### Tags Actions
|
### Webhooks
|
||||||
- `browse_tags`: List all tags
|
- **Browse Webhooks**: List webhooks.
|
||||||
- `read_tag`: Retrieve specific tag information
|
- **Add Webhook**: Create a new webhook.
|
||||||
- `create_tag`: Create a new tag
|
- **Delete Webhook**: Remove a webhook.
|
||||||
- `update_tag`: Update an existing tag
|
|
||||||
- `delete_tag`: Delete a specific tag
|
|
||||||
|
|
||||||
#### Users Actions
|
> 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/`.
|
||||||
- `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
|
|
||||||
|
|
||||||
#### 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
|
## Error Handling
|
||||||
|
|
||||||
@@ -194,4 +131,4 @@ Ghost MCP Server employs a custom `GhostError` exception to handle API communica
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
1286
package-lock.json
generated
Normal file
1286
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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
15
src/config.ts
Normal 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
24
src/ghostApi.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from .server import create_server
|
|
||||||
|
|
||||||
__all__ = ['create_server']
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Custom exceptions for Ghost MCP server."""
|
|
||||||
|
|
||||||
class GhostError(Exception):
|
|
||||||
"""Custom exception for Ghost API errors."""
|
|
||||||
pass
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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)}"
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
12
src/main.py
12
src/main.py
@@ -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
102
src/models.ts
Normal 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
34
src/prompts.ts
Normal 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
219
src/resources.ts
Normal 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
72
src/server.ts
Normal 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
72
src/tools/invites.ts
Normal 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
121
src/tools/members.ts
Normal 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
144
src/tools/newsletters.ts
Normal 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
128
src/tools/offers.ts
Normal 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
124
src/tools/posts.ts
Normal 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
52
src/tools/roles.ts
Normal 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
119
src/tools/tags.ts
Normal 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
131
src/tools/tiers.ts
Normal 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
102
src/tools/users.ts
Normal 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
77
src/tools/webhooks.ts
Normal 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
15
tsconfig.json
Normal 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
1
types/tryghost__admin-api.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module '@tryghost/admin-api';
|
||||||
370
uv.lock
generated
370
uv.lock
generated
@@ -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 },
|
|
||||||
]
|
|
||||||
Reference in New Issue
Block a user