diff --git a/.gitignore b/.gitignore index e09000b..17ed381 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ wheels/ # Virtual environments .venv .vscode -ghost-admin-api.md -mcp-python-sdk.md .qodo +node_modules/ +build/ +knowledge/ diff --git a/.python-version b/.python-version deleted file mode 100644 index e4fba21..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 7533bfe..0000000 --- a/Dockerfile +++ /dev/null @@ -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= -ENV GHOST_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"] diff --git a/README.md b/README.md index 80f2388..364a651 100644 --- a/README.md +++ b/README.md @@ -1,185 +1,122 @@ # Ghost MCP Server -[![smithery badge](https://smithery.ai/badge/@MFYDev/ghost-mcp)](https://smithery.ai/server/@MFYDev/ghost-mcp) - -Ghost Server MCP server - A Model Context Protocol (MCP) server for interacting with Ghost CMS through LLM interfaces like Claude. This server provides secure and comprehensive access to your Ghost blog, leveraging JWT authentication and a rich set of MCP tools for managing posts, users, members, tiers, offers, and newsletters. ![demo](./assets/ghost-mcp-demo.gif) ## Features -- Secure JWT Authentication for Ghost Admin API requests +- Secure Ghost Admin API requests with `@tryghost/admin-api` - Comprehensive entity access including posts, users, members, tiers, offers, and newsletters - Advanced search functionality with both fuzzy and exact matching options - Detailed, human-readable output for Ghost entities - Robust error handling using custom `GhostError` exceptions - Integrated logging support via MCP context for enhanced troubleshooting -## Installation - -### Installing via Smithery - -To install Ghost MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@MFYDev/ghost-mcp): - -```bash -npx -y @smithery/cli install @MFYDev/ghost-mcp --client claude -``` - -### Manual Installation -```bash -# Clone repository -git clone git@github.com/mfydev/ghost-mcp.git -cd ghost-mcp - -# Create virtual environment and install -uv venv -source .venv/bin/activate # On Windows: .venv\Scripts\activate -uv pip install -e . -``` - -## Requirements - -- Python ≥ 3.12 -- Running Ghost instance with Admin API access (v5.x+ recommended) -- Node.js (for testing with MCP Inspector) - ## Usage -### Environment Variables - -```bash -GHOST_API_URL=https://yourblog.com # Your Ghost Admin API URL -GHOST_STAFF_API_KEY=your_staff_api_key # Your Ghost Staff API key -``` - -### Usage with MCP Clients To use this with MCP clients, for instance, Claude Desktop, add the following to your `claude_desktop_config.json`: ```json { "mcpServers": { - "ghost": { - "command": "/Users/username/.local/bin/uv", - "args": [ - "--directory", - "/path/to/ghost-mcp", - "run", - "src/main.py" - ], - "env": { - "GHOST_API_URL": "your_ghost_api_url", - "GHOST_STAFF_API_KEY": "your_staff_api_key" + "ghost-mcp-ts": { + "command": "node", + "args": [ + "ABSOLUTE_PATH_TO_GHOST_MCP_SERVER/build/server.js", + ], + "env": { + "GHOST_API_URL": "https://yourblog.com", + "GHOST_ADMIN_API_KEY": "your_admin_api_key", + "GHOST_API_VERSION": "v5.0" + } } } - } } ``` -### Testing with MCP Inspector +## Available Resources -```bash -GHOST_API_URL=your_ghost_api_url GHOST_STAFF_API_KEY=your_staff_api_key npx @modelcontextprotocol/inspector uv --directory /path/to/ghost-mcp run src/main.py -``` +The following Ghost CMS resources are available through this MCP server: + +- **Posts**: Articles and content published on your Ghost site. +- **Members**: Registered users and subscribers of your site. +- **Newsletters**: Email newsletters managed and sent via Ghost. +- **Offers**: Promotional offers and discounts for members. +- **Invites**: Invitations for new users or staff to join your Ghost site. +- **Roles**: User roles and permissions within the Ghost admin. +- **Tags**: Organizational tags for posts and content. +- **Tiers**: Subscription tiers and plans for members. +- **Users**: Admin users and staff accounts. +- **Webhooks**: Automated event notifications to external services. ## Available Tools -Ghost MCP now provides a single unified tool that provides access to all Ghost CMS functionality: +This MCP server exposes a comprehensive set of tools for managing your Ghost CMS via the Model Context Protocol. Each resource provides a set of operations, typically including browsing, reading, creating, editing, and deleting entities. Below is a summary of the available tools: -### Main Tool -- `ghost`: Central tool for accessing all Ghost CMS functionality +### Posts +- **Browse Posts**: List posts with optional filters, pagination, and ordering. +- **Read Post**: Retrieve a post by ID or slug. +- **Add Post**: Create a new post with title, content, and status. +- **Edit Post**: Update an existing post by ID. +- **Delete Post**: Remove a post by ID. -### Using the Ghost Tool +### Members +- **Browse Members**: List members with filters and pagination. +- **Read Member**: Retrieve a member by ID or email. +- **Add Member**: Create a new member. +- **Edit Member**: Update member details. +- **Delete Member**: Remove a member. -The ghost tool accepts two main parameters: -1. `action`: The specific Ghost operation to perform -2. `params`: A dictionary of parameters for the specified action +### Newsletters +- **Browse Newsletters**: List newsletters. +- **Read Newsletter**: Retrieve a newsletter by ID. +- **Add Newsletter**: Create a new newsletter. +- **Edit Newsletter**: Update newsletter details. +- **Delete Newsletter**: Remove a newsletter. -Example usage: -```python -# List posts -ghost(action="list_posts", params={"format": "text", "page": 1, "limit": 15}) +### Offers +- **Browse Offers**: List offers. +- **Read Offer**: Retrieve an offer by ID. +- **Add Offer**: Create a new offer. +- **Edit Offer**: Update offer details. +- **Delete Offer**: Remove an offer. -# Search posts by title -ghost(action="search_posts_by_title", params={"query": "Welcome", "exact": False}) +### Invites +- **Browse Invites**: List invites. +- **Add Invite**: Create a new invite. +- **Delete Invite**: Remove an invite. -# Create a post -ghost(action="create_post", params={ - "post_data": { - "title": "New Post via MCP", - "status": "draft", - "lexical": "{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"Hello World\",\"type\":\"text\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}" - } -}) -``` +### Roles +- **Browse Roles**: List roles. +- **Read Role**: Retrieve a role by ID. -### Available Actions +### Tags +- **Browse Tags**: List tags. +- **Read Tag**: Retrieve a tag by ID or slug. +- **Add Tag**: Create a new tag. +- **Edit Tag**: Update tag details. +- **Delete Tag**: Remove a tag. -The ghost tool supports all the same actions as before, but now through a unified interface: +### Tiers +- **Browse Tiers**: List tiers. +- **Read Tier**: Retrieve a tier by ID. +- **Add Tier**: Create a new tier. +- **Edit Tier**: Update tier details. +- **Delete Tier**: Remove a tier. -#### Posts Actions -- `list_posts`: List blog posts with pagination -- `search_posts_by_title`: Search for posts by title -- `read_post`: Retrieve full content of a specific post -- `create_post`: Create a new post -- `update_post`: Update a specific post -- `delete_post`: Delete a specific post -- `batchly_update_posts`: Update multiple posts in a single request +### Users +- **Browse Users**: List users. +- **Read User**: Retrieve a user by ID or slug. +- **Edit User**: Update user details. +- **Delete User**: Remove a user. -#### Tags Actions -- `browse_tags`: List all tags -- `read_tag`: Retrieve specific tag information -- `create_tag`: Create a new tag -- `update_tag`: Update an existing tag -- `delete_tag`: Delete a specific tag +### Webhooks +- **Browse Webhooks**: List webhooks. +- **Add Webhook**: Create a new webhook. +- **Delete Webhook**: Remove a webhook. -#### Users Actions -- `list_roles`: List all available roles -- `create_invite`: Create a new user invitation -- `list_users`: List all users -- `read_user`: Get details of a specific user -- `delete_user`: Delete a specific user +> Each tool is accessible via the MCP protocol and can be invoked from compatible clients. For detailed parameter schemas and usage, see the source code in `src/tools/`. -#### Members Actions -- `list_members`: List members -- `read_member`: Retrieve specific member information -- `create_member`: Create a new member -- `update_member`: Update an existing member - -#### Tiers Actions -- `list_tiers`: List all membership tiers -- `read_tier`: Retrieve specific tier information -- `create_tier`: Create a new tier -- `update_tier`: Update an existing tier - -#### Offers Actions -- `list_offers`: List promotional offers -- `read_offer`: Get specific offer information -- `create_offer`: Create a new offer -- `update_offer`: Update an existing offer - -#### Newsletters Actions -- `list_newsletters`: List all newsletters -- `read_newsletter`: Retrieve specific newsletter information -- `create_newsletter`: Create a new newsletter -- `update_newsletter`: Update an existing newsletter - -#### Webhooks Actions -- `create_webhook`: Create a new webhook -- `update_webhook`: Update an existing webhook -- `delete_webhook`: Delete a specific webhook - -## Available Resources - -All resources follow the URI pattern: `[type]://[id]` - -- `user://{user_id}`: User profiles and roles -- `member://{member_id}`: Member details and subscriptions -- `tier://{tier_id}`: Tier configurations -- `offer://{offer_id}`: Offer details -- `newsletter://{newsletter_id}`: Newsletter settings -- `post://{post_id}`: Post content and metadata -- `blog://info`: General blog information ## Error Handling @@ -194,4 +131,4 @@ Ghost MCP Server employs a custom `GhostError` exception to handle API communica ## License -MIT +MIT \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..40ddc42 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1286 @@ +{ + "name": "ghost-mcp-ts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ghost-mcp-ts", + "version": "1.0.0", + "license": "ISC", + "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" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.1.tgz", + "integrity": "sha512-xNYdFdkJqEfIaTVP1gPKoEvluACHZsHZegIoICX8DM1o6Qf3G5u2BQJHmgd0n4YgRPqqK/u1ujQvrgAxxSJT9w==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.3", + "eventsource": "^3.0.2", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tryghost/admin-api": { + "version": "1.13.13", + "resolved": "https://registry.npmjs.org/@tryghost/admin-api/-/admin-api-1.13.13.tgz", + "integrity": "sha512-RBcL9weuLWjBHrEWVvqp838D+jrLlguiAUcda90RgBCZ+CbQkSuslCuffy0yAoTUa2QGBNj+Nlzjx1J8Q25FIA==", + "license": "MIT", + "dependencies": { + "axios": "^1.0.0", + "form-data": "^4.0.0", + "jsonwebtoken": "^9.0.0" + } + }, + "node_modules/@types/axios": { + "version": "0.9.36", + "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.9.36.tgz", + "integrity": "sha512-NLOpedx9o+rxo/X5ChbdiX6mS1atE4WHmEEIcR9NLenRVa5HoVjAvjafwU3FPTqnZEstpoqCaW7fagqSoTDNeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", + "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", + "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", + "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", + "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4425008 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 0e804a5..0000000 --- a/pyproject.toml +++ /dev/null @@ -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 diff --git a/smithery.yaml b/smithery.yaml deleted file mode 100644 index ca8393c..0000000 --- a/smithery.yaml +++ /dev/null @@ -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 } }) diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..146b707 --- /dev/null +++ b/src/config.ts @@ -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); +} \ No newline at end of file diff --git a/src/ghostApi.ts b/src/ghostApi.ts new file mode 100644 index 0000000..6addbc7 --- /dev/null +++ b/src/ghostApi.ts @@ -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 { + 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}`); + } +} +*/ \ No newline at end of file diff --git a/src/ghost_mcp/__init__.py b/src/ghost_mcp/__init__.py deleted file mode 100644 index 8043a86..0000000 --- a/src/ghost_mcp/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .server import create_server - -__all__ = ['create_server'] diff --git a/src/ghost_mcp/api.py b/src/ghost_mcp/api.py deleted file mode 100644 index 86c5e69..0000000 --- a/src/ghost_mcp/api.py +++ /dev/null @@ -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) diff --git a/src/ghost_mcp/config.py b/src/ghost_mcp/config.py deleted file mode 100644 index b1f407f..0000000 --- a/src/ghost_mcp/config.py +++ /dev/null @@ -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"] diff --git a/src/ghost_mcp/exceptions.py b/src/ghost_mcp/exceptions.py deleted file mode 100644 index d513579..0000000 --- a/src/ghost_mcp/exceptions.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Custom exceptions for Ghost MCP server.""" - -class GhostError(Exception): - """Custom exception for Ghost API errors.""" - pass diff --git a/src/ghost_mcp/models.py b/src/ghost_mcp/models.py deleted file mode 100644 index fc21379..0000000 --- a/src/ghost_mcp/models.py +++ /dev/null @@ -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 diff --git a/src/ghost_mcp/resources.py b/src/ghost_mcp/resources.py deleted file mode 100644 index a0e3125..0000000 --- a/src/ghost_mcp/resources.py +++ /dev/null @@ -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) diff --git a/src/ghost_mcp/server.py b/src/ghost_mcp/server.py deleted file mode 100644 index a5c55bc..0000000 --- a/src/ghost_mcp/server.py +++ /dev/null @@ -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 diff --git a/src/ghost_mcp/tools/__init__.py b/src/ghost_mcp/tools/__init__.py deleted file mode 100644 index a120f44..0000000 --- a/src/ghost_mcp/tools/__init__.py +++ /dev/null @@ -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"] diff --git a/src/ghost_mcp/tools/ghost.py b/src/ghost_mcp/tools/ghost.py deleted file mode 100644 index c8518cc..0000000 --- a/src/ghost_mcp/tools/ghost.py +++ /dev/null @@ -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)}" diff --git a/src/ghost_mcp/tools/invites.py b/src/ghost_mcp/tools/invites.py deleted file mode 100644 index 1f1a6e5..0000000 --- a/src/ghost_mcp/tools/invites.py +++ /dev/null @@ -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 diff --git a/src/ghost_mcp/tools/members.py b/src/ghost_mcp/tools/members.py deleted file mode 100644 index 5f1bef2..0000000 --- a/src/ghost_mcp/tools/members.py +++ /dev/null @@ -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) diff --git a/src/ghost_mcp/tools/newsletters.py b/src/ghost_mcp/tools/newsletters.py deleted file mode 100644 index 0925d44..0000000 --- a/src/ghost_mcp/tools/newsletters.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/src/ghost_mcp/tools/offers.py b/src/ghost_mcp/tools/offers.py deleted file mode 100644 index 12520d9..0000000 --- a/src/ghost_mcp/tools/offers.py +++ /dev/null @@ -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) diff --git a/src/ghost_mcp/tools/posts.py b/src/ghost_mcp/tools/posts.py deleted file mode 100644 index f68e6c0..0000000 --- a/src/ghost_mcp/tools/posts.py +++ /dev/null @@ -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) diff --git a/src/ghost_mcp/tools/roles.py b/src/ghost_mcp/tools/roles.py deleted file mode 100644 index e51fde7..0000000 --- a/src/ghost_mcp/tools/roles.py +++ /dev/null @@ -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) diff --git a/src/ghost_mcp/tools/tags.py b/src/ghost_mcp/tools/tags.py deleted file mode 100644 index ed9307b..0000000 --- a/src/ghost_mcp/tools/tags.py +++ /dev/null @@ -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) diff --git a/src/ghost_mcp/tools/tiers.py b/src/ghost_mcp/tools/tiers.py deleted file mode 100644 index 41922d5..0000000 --- a/src/ghost_mcp/tools/tiers.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/ghost_mcp/tools/users.py b/src/ghost_mcp/tools/users.py deleted file mode 100644 index ebd70fd..0000000 --- a/src/ghost_mcp/tools/users.py +++ /dev/null @@ -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) diff --git a/src/ghost_mcp/tools/webhooks.py b/src/ghost_mcp/tools/webhooks.py deleted file mode 100644 index fd35a60..0000000 --- a/src/ghost_mcp/tools/webhooks.py +++ /dev/null @@ -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 diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 34e1c8a..0000000 --- a/src/main.py +++ /dev/null @@ -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() diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..fb4cdf6 --- /dev/null +++ b/src/models.ts @@ -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; +} \ No newline at end of file diff --git a/src/prompts.ts b/src/prompts.ts new file mode 100644 index 0000000..a3a3b28 --- /dev/null +++ b/src/prompts.ts @@ -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}`, + }, + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/resources.ts b/src/resources.ts new file mode 100644 index 0000000..c533d7d --- /dev/null +++ b/src/resources.ts @@ -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; +type ReadResourceTemplateCallback = (uri: URL, variables: Variables) => Promise; + +// Handler for the user resource +export const handleUserResource: ReadResourceTemplateCallback = async (uri: URL, variables: Variables): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 { + 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; + } +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..556d91a --- /dev/null +++ b/src/server.ts @@ -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); +}); \ No newline at end of file diff --git a/src/tools/invites.ts b/src/tools/invites.ts new file mode 100644 index 0000000..e021218 --- /dev/null +++ b/src/tools/invites.ts @@ -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.`, + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/members.ts b/src/tools/members.ts new file mode 100644 index 0000000..3930e1a --- /dev/null +++ b/src/tools/members.ts @@ -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.`, + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/newsletters.ts b/src/tools/newsletters.ts new file mode 100644 index 0000000..58de729 --- /dev/null +++ b/src/tools/newsletters.ts @@ -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.`, + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/offers.ts b/src/tools/offers.ts new file mode 100644 index 0000000..2f66709 --- /dev/null +++ b/src/tools/offers.ts @@ -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.`, + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/posts.ts b/src/tools/posts.ts new file mode 100644 index 0000000..a283767 --- /dev/null +++ b/src/tools/posts.ts @@ -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.`, + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/roles.ts b/src/tools/roles.ts new file mode 100644 index 0000000..e1b92ba --- /dev/null +++ b/src/tools/roles.ts @@ -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), + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/tags.ts b/src/tools/tags.ts new file mode 100644 index 0000000..2c16f55 --- /dev/null +++ b/src/tools/tags.ts @@ -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.`, + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/tiers.ts b/src/tools/tiers.ts new file mode 100644 index 0000000..08a5e7b --- /dev/null +++ b/src/tools/tiers.ts @@ -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.`, + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/users.ts b/src/tools/users.ts new file mode 100644 index 0000000..93b06b1 --- /dev/null +++ b/src/tools/users.ts @@ -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.`, + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/src/tools/webhooks.ts b/src/tools/webhooks.ts new file mode 100644 index 0000000..c432c6f --- /dev/null +++ b/src/tools/webhooks.ts @@ -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.`, + }, + ], + }; + } + ); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bf97f28 --- /dev/null +++ b/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/types/tryghost__admin-api.d.ts b/types/tryghost__admin-api.d.ts new file mode 100644 index 0000000..4f6e180 --- /dev/null +++ b/types/tryghost__admin-api.d.ts @@ -0,0 +1 @@ +declare module '@tryghost/admin-api'; \ No newline at end of file diff --git a/uv.lock b/uv.lock deleted file mode 100644 index b88b718..0000000 --- a/uv.lock +++ /dev/null @@ -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 }, -]