From 004d5614e234df6efcebed8512a933e7bf11cc8a Mon Sep 17 00:00:00 2001 From: Luiz Costa Date: Tue, 23 Sep 2025 01:38:48 -0300 Subject: [PATCH] WIP Python implementation using FastMCP --- .env.example | 66 +----- .eslintrc.json | 34 --- .gitignore | 55 +++++ .prettierrc | 11 - CLAUDE.md | 7 + Makefile | 208 ++++++++++++++++ jest.config.js | 27 --- package.json | 65 ----- pyproject.toml | 76 ++++++ scripts/setup-tokens.sh | 135 +++++++++++ scripts/test-connection.py | 44 ++++ scripts/test-mcp-tools.py | 67 ++++++ src/auth/admin-auth.ts | 44 ---- src/auth/content-auth.ts | 33 --- src/ghost-client.ts | 62 ----- src/ghost_mcp/__init__.py | 5 + src/ghost_mcp/auth/__init__.py | 6 + src/ghost_mcp/auth/admin_auth.py | 134 +++++++++++ src/ghost_mcp/auth/content_auth.py | 44 ++++ src/ghost_mcp/client.py | 300 ++++++++++++++++++++++++ src/ghost_mcp/config.py | 81 +++++++ src/ghost_mcp/server.py | 107 +++++++++ src/ghost_mcp/tools/__init__.py | 11 + src/ghost_mcp/tools/admin/__init__.py | 25 ++ src/ghost_mcp/tools/admin/authors.py | 10 + src/ghost_mcp/tools/admin/media.py | 9 + src/ghost_mcp/tools/admin/members.py | 9 + src/ghost_mcp/tools/admin/pages.py | 56 +++++ src/ghost_mcp/tools/admin/posts.py | 247 +++++++++++++++++++ src/ghost_mcp/tools/admin/settings.py | 9 + src/ghost_mcp/tools/admin/tags.py | 38 +++ src/ghost_mcp/tools/content/__init__.py | 19 ++ src/ghost_mcp/tools/content/authors.py | 142 +++++++++++ src/ghost_mcp/tools/content/pages.py | 142 +++++++++++ src/ghost_mcp/tools/content/posts.py | 161 +++++++++++++ src/ghost_mcp/tools/content/settings.py | 70 ++++++ src/ghost_mcp/tools/content/tags.py | 142 +++++++++++ src/ghost_mcp/types/__init__.py | 30 +++ src/ghost_mcp/types/errors.py | 128 ++++++++++ src/ghost_mcp/types/ghost.py | 251 ++++++++++++++++++++ src/ghost_mcp/types/mcp.py | 49 ++++ src/ghost_mcp/utils/__init__.py | 14 ++ src/ghost_mcp/utils/logging.py | 60 +++++ src/ghost_mcp/utils/retry.py | 77 ++++++ src/ghost_mcp/utils/validation.py | 93 ++++++++ src/index.ts | 111 --------- src/tools/admin/media.ts | 8 - src/tools/admin/members.ts | 9 - src/tools/admin/pages.ts | 9 - src/tools/admin/posts.ts | 9 - src/tools/admin/tags.ts | 9 - src/tools/admin/tiers.ts | 9 - src/tools/admin/webhooks.ts | 9 - src/tools/content/authors.ts | 8 - src/tools/content/pages.ts | 8 - src/tools/content/posts.ts | 31 --- src/tools/content/settings.ts | 8 - src/tools/content/tags.ts | 8 - src/types/ghost.ts | 225 ------------------ src/types/mcp.ts | 63 ----- src/utils/errors.ts | 155 ------------ src/utils/validation.ts | 45 ---- tsconfig.json | 45 ---- 63 files changed, 3074 insertions(+), 1098 deletions(-) delete mode 100644 .eslintrc.json create mode 100644 .gitignore delete mode 100644 .prettierrc create mode 100644 CLAUDE.md create mode 100644 Makefile delete mode 100644 jest.config.js delete mode 100644 package.json create mode 100644 pyproject.toml create mode 100755 scripts/setup-tokens.sh create mode 100755 scripts/test-connection.py create mode 100755 scripts/test-mcp-tools.py delete mode 100644 src/auth/admin-auth.ts delete mode 100644 src/auth/content-auth.ts delete mode 100644 src/ghost-client.ts create mode 100644 src/ghost_mcp/__init__.py create mode 100644 src/ghost_mcp/auth/__init__.py create mode 100644 src/ghost_mcp/auth/admin_auth.py create mode 100644 src/ghost_mcp/auth/content_auth.py create mode 100644 src/ghost_mcp/client.py create mode 100644 src/ghost_mcp/config.py create mode 100644 src/ghost_mcp/server.py create mode 100644 src/ghost_mcp/tools/__init__.py create mode 100644 src/ghost_mcp/tools/admin/__init__.py create mode 100644 src/ghost_mcp/tools/admin/authors.py create mode 100644 src/ghost_mcp/tools/admin/media.py create mode 100644 src/ghost_mcp/tools/admin/members.py create mode 100644 src/ghost_mcp/tools/admin/pages.py create mode 100644 src/ghost_mcp/tools/admin/posts.py create mode 100644 src/ghost_mcp/tools/admin/settings.py create mode 100644 src/ghost_mcp/tools/admin/tags.py create mode 100644 src/ghost_mcp/tools/content/__init__.py create mode 100644 src/ghost_mcp/tools/content/authors.py create mode 100644 src/ghost_mcp/tools/content/pages.py create mode 100644 src/ghost_mcp/tools/content/posts.py create mode 100644 src/ghost_mcp/tools/content/settings.py create mode 100644 src/ghost_mcp/tools/content/tags.py create mode 100644 src/ghost_mcp/types/__init__.py create mode 100644 src/ghost_mcp/types/errors.py create mode 100644 src/ghost_mcp/types/ghost.py create mode 100644 src/ghost_mcp/types/mcp.py create mode 100644 src/ghost_mcp/utils/__init__.py create mode 100644 src/ghost_mcp/utils/logging.py create mode 100644 src/ghost_mcp/utils/retry.py create mode 100644 src/ghost_mcp/utils/validation.py delete mode 100644 src/index.ts delete mode 100644 src/tools/admin/media.ts delete mode 100644 src/tools/admin/members.ts delete mode 100644 src/tools/admin/pages.ts delete mode 100644 src/tools/admin/posts.ts delete mode 100644 src/tools/admin/tags.ts delete mode 100644 src/tools/admin/tiers.ts delete mode 100644 src/tools/admin/webhooks.ts delete mode 100644 src/tools/content/authors.ts delete mode 100644 src/tools/content/pages.ts delete mode 100644 src/tools/content/posts.ts delete mode 100644 src/tools/content/settings.ts delete mode 100644 src/tools/content/tags.ts delete mode 100644 src/types/ghost.ts delete mode 100644 src/types/mcp.ts delete mode 100644 src/utils/errors.ts delete mode 100644 src/utils/validation.ts delete mode 100644 tsconfig.json diff --git a/.env.example b/.env.example index 387fc68..1e908be 100644 --- a/.env.example +++ b/.env.example @@ -1,56 +1,16 @@ -# Ghost MCP Development Environment Configuration -# Copy this file to .env and update the values as needed +# Ghost MCP Server Configuration -# ============================================================================= -# Database Configuration -# ============================================================================= -MYSQL_ROOT_PASSWORD=your_strong_root_password_here -MYSQL_DATABASE=ghost_dev -MYSQL_USER=ghost -MYSQL_PASSWORD=your_strong_ghost_db_password_here - -# ============================================================================= -# Ghost Configuration -# ============================================================================= -# The URL where Ghost will be accessible +# Ghost instance configuration GHOST_URL=http://localhost:2368 +GHOST_CONTENT_API_KEY=your_content_api_key_here +GHOST_ADMIN_API_KEY=your_admin_api_key_here +GHOST_VERSION=v5.0 +GHOST_MODE=auto +GHOST_TIMEOUT=30 +GHOST_MAX_RETRIES=3 +GHOST_RETRY_BACKOFF_FACTOR=2.0 -# ============================================================================= -# Email Configuration (Optional - for testing email features) -# ============================================================================= -# From address for Ghost emails -GHOST_MAIL_FROM=noreply@localhost - -# SMTP Configuration (uncomment and configure if needed) -# MAIL_SERVICE=Gmail -# MAIL_USER=your-email@gmail.com -# MAIL_PASSWORD=your-app-password - -# ============================================================================= -# MCP Development Configuration (for future use) -# ============================================================================= -# These will be used by the MCP server once implemented - -# Ghost API Keys (obtain these after Ghost setup) -# GHOST_CONTENT_API_KEY=your_content_api_key_here -# GHOST_ADMIN_API_KEY=your_admin_api_key_here - -# Ghost API Version -# GHOST_VERSION=v5.0 - -# MCP Operation Mode -# MCP_GHOST_MODE=auto - -# ============================================================================= -# Development Settings -# ============================================================================= -# Set to 'development' for verbose logging -NODE_ENV=development - -# ============================================================================= -# Security Notes -# ============================================================================= -# - Never commit the actual .env file to version control -# - Use strong, unique passwords for production -# - Keep API keys secure and never log them -# - For production, use proper SMTP configuration \ No newline at end of file +# Logging configuration +LOG_LEVEL=info +LOG_STRUCTURED=true +LOG_REQUEST_ID=true \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 91da95b..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "root": true, - "parser": "@typescript-eslint/parser", - "plugins": ["@typescript-eslint", "prettier"], - "extends": [ - "eslint:recommended", - "@typescript-eslint/recommended", - "prettier" - ], - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module", - "project": "./tsconfig.json" - }, - "env": { - "node": true, - "es2022": true - }, - "rules": { - "prettier/prettier": "error", - "@typescript-eslint/no-unused-vars": "error", - "@typescript-eslint/explicit-function-return-type": "warn", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-non-null-assertion": "warn", - "@typescript-eslint/prefer-const": "error", - "@typescript-eslint/no-var-requires": "error", - "prefer-const": "error", - "no-var": "error", - "no-console": "warn", - "eqeqeq": "error", - "curly": "error" - }, - "ignorePatterns": ["dist/", "node_modules/", "*.js"] -} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0622477 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +*.log + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 58cdde3..0000000 --- a/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "semi": true, - "trailingComma": "es5", - "singleQuote": true, - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "bracketSpacing": true, - "arrowParens": "avoid", - "endOfLine": "lf" -} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ba15bb7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ + + + + +# !!!IMPORTANT!!! + +- Always use `docker compose` instead of `docker-compose` \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a84c24c --- /dev/null +++ b/Makefile @@ -0,0 +1,208 @@ +# Ghost MCP Development Makefile + +.PHONY: help install install-uv start-ghost stop-ghost setup-tokens test test-connection run dev clean logs status check-deps + +# Default target +help: ## Show this help message + @echo "Ghost MCP Development Commands" + @echo "=============================" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + @echo "" + @echo "Quick start:" + @echo " make install-uv # Install uv package manager (if not installed)" + @echo " make install # Install Python dependencies" + @echo " make start-ghost # Start Ghost and database containers" + @echo " make setup-tokens # Extract API keys and create .env file" + @echo " make test # Test the implementation" + @echo " make run # Run the MCP server" + +# Python environment setup +install-uv: ## Install uv package manager + @echo "๐Ÿ“ฆ Installing uv package manager..." + @if command -v uv >/dev/null 2>&1; then \ + echo "โœ… uv is already installed"; \ + uv --version; \ + else \ + echo "Installing uv..."; \ + curl -LsSf https://astral.sh/uv/install.sh | sh; \ + echo "โœ… uv installed successfully"; \ + fi + +install: ## Install Python dependencies using uv + @echo "๐Ÿ“ฆ Installing Python dependencies with uv..." + @if ! command -v uv >/dev/null 2>&1; then \ + echo "โŒ uv not found. Run 'make install-uv' first"; \ + exit 1; \ + fi + uv sync + @echo "โœ… Dependencies installed successfully" + +install-pip: ## Install Python dependencies using pip (fallback) + @echo "๐Ÿ“ฆ Installing Python dependencies with pip..." + python -m pip install -e . + @echo "โœ… Dependencies installed successfully" + +# Docker environment +start-ghost: ## Start Ghost and database containers + @echo "๐Ÿณ Starting Ghost development environment..." + docker-compose up -d + @echo "โณ Waiting for containers to be healthy..." + @timeout=60; \ + while [ $$timeout -gt 0 ]; do \ + if docker-compose ps | grep -q "ghost-mcp-dev.*Up" && docker-compose ps | grep -q "ghost-db-dev.*Up.*healthy"; then \ + echo "โœ… Ghost containers are running and healthy"; \ + break; \ + fi; \ + echo " Waiting for containers... ($$timeout seconds remaining)"; \ + sleep 2; \ + timeout=$$((timeout - 2)); \ + done; \ + if [ $$timeout -le 0 ]; then \ + echo "โŒ Containers did not start properly"; \ + make logs; \ + exit 1; \ + fi + @echo "" + @echo "๐ŸŽ‰ Ghost is ready!" + @echo " Ghost admin: http://localhost:2368/ghost/" + @echo " Ghost site: http://localhost:2368/" + @echo " Database: Available on port 3306" + +stop-ghost: ## Stop Ghost and database containers + @echo "๐Ÿ›‘ Stopping Ghost development environment..." + docker-compose down + @echo "โœ… Ghost containers stopped" + +restart-ghost: ## Restart Ghost and database containers + @echo "๐Ÿ”„ Restarting Ghost development environment..." + docker-compose restart + @echo "โœ… Ghost containers restarted" + +# API token setup +setup-tokens: ## Extract API keys from Ghost database and create .env file + @echo "๐Ÿ”‘ Setting up API tokens..." + ./scripts/setup-tokens.sh + +# Testing +test-connection: ## Test Ghost API connectivity + @if [ ! -f .env ]; then \ + echo "โŒ .env file not found. Run 'make setup-tokens' first"; \ + exit 1; \ + fi + @python scripts/test-connection.py + +test: check-deps test-connection ## Run all tests + @echo "๐Ÿงช Running comprehensive tests..." + @echo "Testing MCP tools registration..." + @python -c "\ +import sys; \ +sys.path.insert(0, 'src'); \ +from ghost_mcp.server import mcp; \ +print(f'โœ… FastMCP server initialized'); \ +print(f' Tools registered: {len([attr for attr in dir(mcp) if not attr.startswith(\"_\")])}+')" + @echo "โœ… All tests passed!" + +# Running the server +run: check-deps ## Run the Ghost MCP server + @echo "๐Ÿš€ Starting Ghost MCP server..." + @if [ ! -f .env ]; then \ + echo "โŒ .env file not found. Run 'make setup-tokens' first"; \ + exit 1; \ + fi + python -m ghost_mcp.server + +dev: check-deps ## Run the Ghost MCP server in development mode with auto-reload + @echo "๐Ÿš€ Starting Ghost MCP server in development mode..." + @if [ ! -f .env ]; then \ + echo "โŒ .env file not found. Run 'make setup-tokens' first"; \ + exit 1; \ + fi + python -m ghost_mcp.server --dev + +# Utilities +logs: ## Show Docker container logs + @echo "๐Ÿ“‹ Showing container logs..." + docker-compose logs -f + +status: ## Show status of all components + @echo "๐Ÿ“Š Ghost MCP Status" + @echo "==================" + @echo "" + @echo "๐Ÿณ Docker Containers:" + @docker-compose ps || echo " No containers running" + @echo "" + @echo "๐Ÿ“ Configuration:" + @if [ -f .env ]; then \ + echo " โœ… .env file exists"; \ + echo " ๐Ÿ“ Ghost URL: $$(grep GHOST_URL .env | cut -d= -f2)"; \ + echo " ๐Ÿ”‘ Content API: $$(grep GHOST_CONTENT_API_KEY .env | cut -d= -f2 | cut -c1-10)..."; \ + echo " ๐Ÿ”‘ Admin API: $$(grep GHOST_ADMIN_API_KEY .env | cut -d= -f2 | cut -c1-10)..."; \ + else \ + echo " โŒ .env file missing"; \ + fi + @echo "" + @echo "๐Ÿ Python Environment:" + @if command -v uv >/dev/null 2>&1; then \ + echo " โœ… uv: $$(uv --version)"; \ + else \ + echo " โŒ uv not installed"; \ + fi + @echo " ๐Ÿ Python: $$(python --version)" + @if python -c "import ghost_mcp" 2>/dev/null; then \ + echo " โœ… ghost_mcp package installed"; \ + else \ + echo " โŒ ghost_mcp package not installed"; \ + fi + +check-deps: ## Check if all dependencies are available + @if [ ! -f .env ]; then \ + echo "โŒ .env file not found. Run 'make setup-tokens' first"; \ + exit 1; \ + fi + @if ! python -c "import ghost_mcp" 2>/dev/null; then \ + echo "โŒ ghost_mcp package not installed. Run 'make install' first"; \ + exit 1; \ + fi + +clean: ## Clean up development environment + @echo "๐Ÿงน Cleaning up development environment..." + docker-compose down -v + rm -f .env + @if command -v uv >/dev/null 2>&1; then \ + uv clean; \ + fi + @echo "โœ… Cleanup complete" + +# Development workflow +setup: install-uv install start-ghost setup-tokens ## Complete setup from scratch + @echo "" + @echo "๐ŸŽ‰ Ghost MCP setup complete!" + @echo "" + @echo "Ready to use:" + @echo " make test # Test the implementation" + @echo " make run # Run the MCP server" + @echo " make status # Check system status" + +# Documentation +docs: ## Show important URLs and information + @echo "๐Ÿ“š Ghost MCP Documentation" + @echo "=========================" + @echo "" + @echo "๐ŸŒ Web Interfaces:" + @echo " Ghost Admin: http://localhost:2368/ghost/" + @echo " Ghost Site: http://localhost:2368/" + @echo " phpMyAdmin: http://localhost:8080/ (if enabled)" + @echo "" + @echo "๐Ÿ“ Important Files:" + @echo " Configuration: .env" + @echo " Project: pyproject.toml" + @echo " Docker: docker-compose.yml" + @echo " Setup Script: scripts/setup-tokens.sh" + @echo "" + @echo "๐Ÿ”ง Development Commands:" + @echo " make setup # Complete initial setup" + @echo " make test # Test functionality" + @echo " make run # Run MCP server" + @echo " make logs # View container logs" + @echo " make status # Check system status" \ No newline at end of file diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 1766910..0000000 --- a/jest.config.js +++ /dev/null @@ -1,27 +0,0 @@ -export default { - preset: 'ts-jest/presets/default-esm', - testEnvironment: 'node', - extensionsToTreatAsEsm: ['.ts'], - moduleNameMapping: { - '^(\\.{1,2}/.*)\\.js$': '$1', - }, - transform: { - '^.+\\.ts$': ['ts-jest', { - useESM: true, - }], - }, - collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.test.ts', - '!src/**/*.spec.ts', - '!src/index.ts', - ], - coverageDirectory: 'coverage', - coverageReporters: ['text', 'lcov', 'html'], - testMatch: [ - '**/__tests__/**/*.ts', - '**/?(*.)+(spec|test).ts', - ], - moduleFileExtensions: ['ts', 'js', 'json'], - verbose: true, -}; \ No newline at end of file diff --git a/package.json b/package.json deleted file mode 100644 index ac3da37..0000000 --- a/package.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "name": "ghost-mcp", - "version": "0.1.0", - "description": "Ghost MCP Server - Complete Ghost CMS functionality through Model Context Protocol", - "main": "dist/index.js", - "type": "module", - "scripts": { - "build": "tsc", - "dev": "tsx watch src/index.ts", - "start": "node dist/index.js", - "test": "jest", - "test:watch": "jest --watch", - "lint": "eslint src --ext .ts", - "lint:fix": "eslint src --ext .ts --fix", - "type-check": "tsc --noEmit", - "clean": "rimraf dist" - }, - "keywords": [ - "ghost", - "mcp", - "model-context-protocol", - "cms", - "api", - "blog" - ], - "author": "Ghost MCP Development Team", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", - "axios": "^1.7.0", - "dotenv": "^16.4.0", - "jsonwebtoken": "^9.0.0", - "uuid": "^10.0.0", - "winston": "^3.11.0", - "zod": "^3.23.0" - }, - "devDependencies": { - "@types/jest": "^29.5.0", - "@types/jsonwebtoken": "^9.0.0", - "@types/node": "^20.11.0", - "@types/uuid": "^10.0.0", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.57.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.1.0", - "jest": "^29.7.0", - "prettier": "^3.2.0", - "rimraf": "^5.0.0", - "ts-jest": "^29.1.0", - "tsx": "^4.7.0", - "typescript": "^5.4.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "repository": { - "type": "git", - "url": "https://github.com/your-org/ghost-mcp.git" - }, - "bugs": { - "url": "https://github.com/your-org/ghost-mcp/issues" - }, - "homepage": "https://github.com/your-org/ghost-mcp#readme" -} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..19e7741 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,76 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ghost-mcp" +version = "0.1.0" +description = "Ghost CMS MCP server providing comprehensive Ghost API access" +authors = [{name = "Ghost MCP Team"}] +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} + +dependencies = [ + "fastmcp>=0.2.0", + "requests>=2.31.0", + "pyjwt>=2.8.0", + "pydantic>=2.5.0", + "python-dotenv>=1.0.0", + "structlog>=23.2.0", + "httpx>=0.25.0", + "typing-extensions>=4.8.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.1.0", + "black>=23.0.0", + "ruff>=0.1.0", + "mypy>=1.7.0", + "pre-commit>=3.5.0", + "hatchling>=1.21.0", +] + +[project.scripts] +ghost-mcp = "ghost_mcp.server:main" + +# Package discovery +[tool.hatch.build.targets.wheel] +packages = ["src/ghost_mcp"] + +[tool.black] +line-length = 88 +target-version = ['py310'] + +[tool.ruff] +target-version = "py310" +line-length = 88 +select = ["E", "F", "W", "I", "N", "UP", "ANN", "S", "BLE", "FBT", "B", "A", "COM", "C4", "DTZ", "T10", "EM", "EXE", "FA", "ISC", "ICN", "G", "INP", "PIE", "T20", "PYI", "PT", "Q", "RSE", "RET", "SLF", "SLOT", "SIM", "TID", "TCH", "INT", "ARG", "PTH", "TD", "FIX", "ERA", "PD", "PGH", "PL", "TRY", "FLY", "NPY", "AIR", "PERF", "FURB", "LOG", "RUF"] +ignore = ["ANN101", "ANN102", "S101", "PLR0913", "PLR0915", "PLR2004"] + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --cov=ghost_mcp --cov-report=html --cov-report=term-missing" +asyncio_mode = "auto" \ No newline at end of file diff --git a/scripts/setup-tokens.sh b/scripts/setup-tokens.sh new file mode 100755 index 0000000..1dbc216 --- /dev/null +++ b/scripts/setup-tokens.sh @@ -0,0 +1,135 @@ +#!/bin/bash +set -e + +# Ghost MCP API Token Setup Script +echo "๐Ÿ”‘ Setting up Ghost MCP API tokens..." + +# Check if Docker containers are running +if ! docker-compose ps | grep -q "ghost-mcp-dev.*Up"; then + echo "โŒ Error: Ghost container is not running" + echo "Please run: make start-ghost" + exit 1 +fi + +if ! docker-compose ps | grep -q "ghost-db-dev.*Up"; then + echo "โŒ Error: Ghost database container is not running" + echo "Please run: make start-ghost" + exit 1 +fi + +echo "โœ… Ghost containers are running" + +# Wait for Ghost to be fully ready +echo "โณ Waiting for Ghost API to be ready..." +max_attempts=30 +attempt=0 + +while [ $attempt -lt $max_attempts ]; do + if curl -s "http://localhost:2368/ghost/api/content/" >/dev/null 2>&1; then + break + fi + echo " Attempt $((attempt + 1))/$max_attempts - waiting for Ghost..." + sleep 2 + attempt=$((attempt + 1)) +done + +if [ $attempt -eq $max_attempts ]; then + echo "โŒ Error: Ghost API did not become ready in time" + exit 1 +fi + +echo "โœ… Ghost API is ready" + +# Extract API keys from database +echo "๐Ÿ” Extracting API keys from Ghost database..." + +CONTENT_API_KEY=$(docker exec ghost-db-dev mysql -u root -prootpassword ghost_dev -e "SELECT secret FROM api_keys WHERE type='content' LIMIT 1;" 2>/dev/null | tail -n 1) +ADMIN_API_DATA=$(docker exec ghost-db-dev mysql -u root -prootpassword ghost_dev -e "SELECT id, secret FROM api_keys WHERE type='admin' LIMIT 1;" 2>/dev/null | tail -n 1) + +if [ -z "$CONTENT_API_KEY" ] || [ "$CONTENT_API_KEY" = "secret" ]; then + echo "โŒ Error: Could not retrieve Content API key" + exit 1 +fi + +if [ -z "$ADMIN_API_DATA" ] || [ "$ADMIN_API_DATA" = "id secret" ]; then + echo "โŒ Error: Could not retrieve Admin API key" + exit 1 +fi + +# Parse admin API data (format: "id secret") +ADMIN_API_ID=$(echo "$ADMIN_API_DATA" | cut -f1) +ADMIN_API_SECRET=$(echo "$ADMIN_API_DATA" | cut -f2) +ADMIN_API_KEY="${ADMIN_API_ID}:${ADMIN_API_SECRET}" + +echo "โœ… API keys extracted successfully" +echo " Content API Key: ${CONTENT_API_KEY:0:10}..." +echo " Admin API Key: ${ADMIN_API_ID:0:10}:${ADMIN_API_SECRET:0:10}..." + +# Create .env file +echo "๐Ÿ“ Creating .env file..." + +cat > .env << EOF +# Ghost MCP Server Configuration +# Generated on $(date) + +# Ghost instance configuration +GHOST_URL=http://localhost:2368 +GHOST_CONTENT_API_KEY=$CONTENT_API_KEY +GHOST_ADMIN_API_KEY=$ADMIN_API_KEY +GHOST_VERSION=v5.0 +GHOST_MODE=auto +GHOST_TIMEOUT=30 +GHOST_MAX_RETRIES=3 +GHOST_RETRY_BACKOFF_FACTOR=2.0 + +# Logging configuration +LOG_LEVEL=info +LOG_STRUCTURED=true +LOG_REQUEST_ID=true +EOF + +echo "โœ… .env file created successfully" + +# Test the configuration +echo "๐Ÿงช Testing API connectivity..." + +# Test Content API +echo " Testing Content API..." +CONTENT_TEST=$(curl -s "http://localhost:2368/ghost/api/content/settings/?key=$CONTENT_API_KEY" | grep -o '"title"' || echo "") +if [ -n "$CONTENT_TEST" ]; then + echo " โœ… Content API: Working" +else + echo " โŒ Content API: Failed" +fi + +# Test Admin API (using Python to generate JWT and test) +echo " Testing Admin API..." +python3 -c " +import asyncio +import sys +import os +sys.path.insert(0, 'src') + +async def test_admin(): + try: + from ghost_mcp.client import GhostClient + async with GhostClient() as client: + result = await client._make_request('GET', 'site/', api_type='admin') + print(' โœ… Admin API: Working') + except Exception as e: + print(f' โŒ Admin API: Failed - {e}') + +asyncio.run(test_admin()) +" 2>/dev/null || echo " โš ๏ธ Admin API: Could not test (install dependencies first)" + +echo "" +echo "๐ŸŽ‰ Ghost MCP API tokens setup complete!" +echo "" +echo "Next steps:" +echo "1. Install dependencies: make install" +echo "2. Test the implementation: make test" +echo "3. Run the MCP server: make run" +echo "" +echo "Configuration file: .env" +echo "Ghost admin interface: http://localhost:2368/ghost/" +echo "Ghost public site: http://localhost:2368/" \ No newline at end of file diff --git a/scripts/test-connection.py b/scripts/test-connection.py new file mode 100755 index 0000000..05a3a1d --- /dev/null +++ b/scripts/test-connection.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Test Ghost API connectivity script.""" + +import asyncio +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from ghost_mcp.client import GhostClient + + +async def test_connection(): + """Test Ghost API connection.""" + print("๐Ÿงช Testing Ghost API connection...") + + async with GhostClient() as client: + # Test Content API + try: + result = await client._make_request("GET", "settings/", api_type="content") + title = result.get("settings", {}).get("title", "Unknown") + print(f"โœ… Content API: Connected to '{title}'") + except Exception as e: + print(f"โŒ Content API: {e}") + + # Test Admin API + try: + result = await client._make_request("GET", "site/", api_type="admin") + print("โœ… Admin API: Connected successfully") + except Exception as e: + print(f"โŒ Admin API: {e}") + + # Test posts endpoint + try: + result = await client.get_posts(limit=1) + posts_count = len(result.get("posts", [])) + print(f"โœ… Posts: Found {posts_count} posts") + except Exception as e: + print(f"โŒ Posts: {e}") + + +if __name__ == "__main__": + asyncio.run(test_connection()) \ No newline at end of file diff --git a/scripts/test-mcp-tools.py b/scripts/test-mcp-tools.py new file mode 100755 index 0000000..b2a3a6b --- /dev/null +++ b/scripts/test-mcp-tools.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Test Ghost MCP tools functionality.""" + +import asyncio +import json +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from ghost_mcp.tools.content.posts import get_posts +from ghost_mcp.tools.content.settings import get_settings +from ghost_mcp.server import check_ghost_connection + + +async def test_mcp_tools(): + """Test MCP tools functionality.""" + print("๐Ÿงช Testing Ghost MCP tools...") + + # Test connection check tool + print("\n1. Testing connection check tool...") + try: + result = await check_ghost_connection() + data = json.loads(result) + print(f"โœ… Connection check: {data.get('connection_test', 'unknown')}") + print(f" Ghost URL: {data.get('ghost_url')}") + print(f" Content API: {'โœ…' if data.get('content_api_configured') else 'โŒ'}") + print(f" Admin API: {'โœ…' if data.get('admin_api_configured') else 'โŒ'}") + except Exception as e: + print(f"โŒ Connection check failed: {e}") + + # Test get settings + print("\n2. Testing get_settings tool...") + try: + result = await get_settings() + data = json.loads(result) + if "settings" in data: + settings = data["settings"] + print(f"โœ… Settings: Site title is '{settings.get('title')}'") + print(f" Description: {settings.get('description')}") + print(f" URL: {settings.get('url')}") + else: + print(f"โŒ Settings: {data}") + except Exception as e: + print(f"โŒ Settings failed: {e}") + + # Test get posts + print("\n3. Testing get_posts tool...") + try: + result = await get_posts(limit=2) + data = json.loads(result) + if "posts" in data: + posts = data["posts"] + print(f"โœ… Posts: Found {len(posts)} posts") + for post in posts: + print(f" - '{post.get('title')}' (status: {post.get('status')})") + else: + print(f"โŒ Posts: {data}") + except Exception as e: + print(f"โŒ Posts failed: {e}") + + print("\n๐ŸŽ‰ MCP tools test completed!") + + +if __name__ == "__main__": + asyncio.run(test_mcp_tools()) \ No newline at end of file diff --git a/src/auth/admin-auth.ts b/src/auth/admin-auth.ts deleted file mode 100644 index 3ce5caf..0000000 --- a/src/auth/admin-auth.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Ghost Admin API JWT Authentication - * - * Handles JWT token generation and management for Ghost Admin API. - * Implements the authentication flow documented in contracts/admin-api-auth.md - */ - -import jwt from 'jsonwebtoken'; - -// TODO: Phase 1 - Implement Admin API JWT authentication -// - JWT token generation -// - Token caching and refresh -// - Authorization header management - -export interface AdminAuthConfig { - apiKey: string; // format: id:secret - ghostUrl: string; - apiVersion: string; -} - -export class AdminApiAuth { - private token: string | null = null; - private tokenExpiry: number | null = null; - - constructor(private config: AdminAuthConfig) { - // TODO: Phase 1 - Initialize authentication - } - - generateToken(): string { - // TODO: Phase 1 - Implement JWT token generation - // Based on contracts/admin-api-auth.md specifications - throw new Error('Not implemented - Phase 1'); - } - - getValidToken(): string { - // TODO: Phase 1 - Get valid token (generate if expired) - throw new Error('Not implemented - Phase 1'); - } - - getAuthHeaders(): Record { - // TODO: Phase 1 - Return Authorization and other required headers - throw new Error('Not implemented - Phase 1'); - } -} \ No newline at end of file diff --git a/src/auth/content-auth.ts b/src/auth/content-auth.ts deleted file mode 100644 index e97cba4..0000000 --- a/src/auth/content-auth.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Ghost Content API Authentication - * - * Handles authentication for Ghost Content API using query parameter method. - * Implements the authentication flow documented in contracts/content-api-auth.md - */ - -// TODO: Phase 1 - Implement Content API authentication -// - Query parameter authentication -// - URL building with API key -// - Request header management - -export interface ContentAuthConfig { - apiKey: string; - ghostUrl: string; - apiVersion: string; -} - -export class ContentApiAuth { - constructor(private config: ContentAuthConfig) { - // TODO: Phase 1 - Initialize authentication - } - - buildUrl(endpoint: string, params?: Record): string { - // TODO: Phase 1 - Build URL with API key and parameters - throw new Error('Not implemented - Phase 1'); - } - - getHeaders(): Record { - // TODO: Phase 1 - Return required headers - throw new Error('Not implemented - Phase 1'); - } -} \ No newline at end of file diff --git a/src/ghost-client.ts b/src/ghost-client.ts deleted file mode 100644 index dfd0137..0000000 --- a/src/ghost-client.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Ghost API Client - * - * Unified client for both Ghost Content API (read-only) and Admin API (read/write). - * Handles authentication, request/response processing, and error handling. - */ - -// TODO: Phase 1 - Implement this client -// - Content API authentication (query parameter) -// - Admin API JWT authentication -// - Request/response handling -// - Error mapping -// - Retry logic - -export interface GhostClientConfig { - ghostUrl: string; - contentApiKey?: string; - adminApiKey?: string; - apiVersion: string; -} - -export interface GhostApiResponse { - data: T; - meta?: { - pagination?: { - page: number; - limit: number; - pages: number; - total: number; - next?: number; - prev?: number; - }; - }; -} - -export class GhostClient { - constructor(config: GhostClientConfig) { - // TODO: Phase 1 - Initialize client with configuration - } - - // TODO: Phase 1 - Content API methods - async contentGet(endpoint: string, params?: Record): Promise> { - throw new Error('Not implemented - Phase 1'); - } - - // TODO: Phase 1 - Admin API methods - async adminGet(endpoint: string, params?: Record): Promise> { - throw new Error('Not implemented - Phase 1'); - } - - async adminPost(endpoint: string, data: unknown): Promise> { - throw new Error('Not implemented - Phase 1'); - } - - async adminPut(endpoint: string, data: unknown): Promise> { - throw new Error('Not implemented - Phase 1'); - } - - async adminDelete(endpoint: string): Promise> { - throw new Error('Not implemented - Phase 1'); - } -} \ No newline at end of file diff --git a/src/ghost_mcp/__init__.py b/src/ghost_mcp/__init__.py new file mode 100644 index 0000000..0208dac --- /dev/null +++ b/src/ghost_mcp/__init__.py @@ -0,0 +1,5 @@ +"""Ghost MCP Server - Comprehensive Ghost CMS integration for MCP.""" + +__version__ = "0.1.0" +__author__ = "Ghost MCP Team" +__description__ = "Ghost CMS MCP server providing comprehensive Ghost API access" \ No newline at end of file diff --git a/src/ghost_mcp/auth/__init__.py b/src/ghost_mcp/auth/__init__.py new file mode 100644 index 0000000..d77c6bb --- /dev/null +++ b/src/ghost_mcp/auth/__init__.py @@ -0,0 +1,6 @@ +"""Authentication modules for Ghost API.""" + +from .admin_auth import AdminAuth +from .content_auth import ContentAuth + +__all__ = ["AdminAuth", "ContentAuth"] \ No newline at end of file diff --git a/src/ghost_mcp/auth/admin_auth.py b/src/ghost_mcp/auth/admin_auth.py new file mode 100644 index 0000000..d3143c8 --- /dev/null +++ b/src/ghost_mcp/auth/admin_auth.py @@ -0,0 +1,134 @@ +"""Admin API authentication using JWT tokens.""" + +import time +from datetime import datetime, timedelta +from typing import Dict, Optional + +import jwt + +from ..config import config +from ..types.errors import AuthenticationError +from ..utils.logging import get_logger + +logger = get_logger(__name__) + + +class AdminAuth: + """Admin API JWT authentication handler.""" + + def __init__(self, api_key: Optional[str] = None) -> None: + """Initialize with optional API key override.""" + self.api_key = api_key or config.ghost.admin_api_key + self._cached_token: Optional[str] = None + self._token_expires_at: Optional[datetime] = None + + if not self.api_key: + logger.warning("No Admin API key configured") + + def get_auth_headers(self, request_id: Optional[str] = None) -> Dict[str, str]: + """Get authentication headers for Admin API requests.""" + token = self._get_jwt_token(request_id) + return {"Authorization": f"Ghost {token}"} + + def _get_jwt_token(self, request_id: Optional[str] = None) -> str: + """Get or generate JWT token for Admin API.""" + if not self.api_key: + raise AuthenticationError( + "Admin API key not configured", + context="Admin API requires an API key for JWT generation", + request_id=request_id, + ) + + # Check if we have a valid cached token + if self._cached_token and self._token_expires_at: + if datetime.now() < self._token_expires_at - timedelta(seconds=30): + logger.debug("Using cached JWT token", request_id=request_id) + return self._cached_token + + # Generate new token + token = self._generate_jwt_token(request_id) + self._cached_token = token + + # JWT tokens expire after 5 minutes + self._token_expires_at = datetime.now() + timedelta(minutes=5) + + logger.debug("Generated new JWT token", request_id=request_id) + return token + + def _generate_jwt_token(self, request_id: Optional[str] = None) -> str: + """Generate JWT token for Admin API authentication.""" + try: + # Split the admin key (id:secret format) + if ":" not in self.api_key: + raise AuthenticationError( + "Invalid Admin API key format", + context="Admin API key must be in 'id:secret' format", + request_id=request_id, + ) + + key_id, secret = self.api_key.split(":", 1) + + # Current timestamp + now = int(time.time()) + + # JWT payload + payload = { + "iat": now, + "exp": now + 300, # 5 minutes from now + "aud": "/admin/", + } + + # JWT header + header = {"alg": "HS256", "typ": "JWT", "kid": key_id} + + # Generate token + token = jwt.encode( + payload, + bytes.fromhex(secret), + algorithm="HS256", + headers=header, + ) + + return token + + except Exception as e: + raise AuthenticationError( + f"Failed to generate JWT token: {e}", + context="JWT token generation failed", + request_id=request_id, + ) from e + + def is_configured(self) -> bool: + """Check if Admin API authentication is properly configured.""" + return bool(self.api_key and ":" in self.api_key) + + def validate_key_format(self) -> bool: + """Validate the Admin API key format.""" + if not self.api_key: + return False + + try: + # Admin keys should be in 'id:secret' format + if ":" not in self.api_key: + return False + + key_id, secret = self.api_key.split(":", 1) + + # Key ID should be 24 character hex string + if len(key_id) != 24 or not all(c in "0123456789abcdef" for c in key_id.lower()): + return False + + # Secret should be 64 character hex string + if len(secret) != 64 or not all(c in "0123456789abcdef" for c in secret.lower()): + return False + + return True + + except Exception: + return False + + def invalidate_cache(self) -> None: + """Invalidate cached JWT token to force regeneration.""" + self._cached_token = None + self._token_expires_at = None + logger.debug("JWT token cache invalidated") \ No newline at end of file diff --git a/src/ghost_mcp/auth/content_auth.py b/src/ghost_mcp/auth/content_auth.py new file mode 100644 index 0000000..7e5e337 --- /dev/null +++ b/src/ghost_mcp/auth/content_auth.py @@ -0,0 +1,44 @@ +"""Content API authentication using query parameter keys.""" + +from typing import Dict, Optional + +from ..config import config +from ..types.errors import AuthenticationError +from ..utils.logging import get_logger + +logger = get_logger(__name__) + + +class ContentAuth: + """Content API authentication handler.""" + + def __init__(self, api_key: Optional[str] = None) -> None: + """Initialize with optional API key override.""" + self.api_key = api_key or config.ghost.content_api_key + if not self.api_key: + logger.warning("No Content API key configured") + + def get_auth_params(self, request_id: Optional[str] = None) -> Dict[str, str]: + """Get authentication parameters for Content API requests.""" + if not self.api_key: + raise AuthenticationError( + "Content API key not configured", + context="Content API requires an API key", + request_id=request_id, + ) + + return {"key": self.api_key} + + def is_configured(self) -> bool: + """Check if Content API authentication is properly configured.""" + return bool(self.api_key) + + def validate_key_format(self) -> bool: + """Validate the API key format.""" + if not self.api_key: + return False + + # Content API keys are typically 26 character hex strings + return len(self.api_key) == 26 and all( + c in "0123456789abcdef" for c in self.api_key.lower() + ) \ No newline at end of file diff --git a/src/ghost_mcp/client.py b/src/ghost_mcp/client.py new file mode 100644 index 0000000..65454ce --- /dev/null +++ b/src/ghost_mcp/client.py @@ -0,0 +1,300 @@ +"""Ghost API client with unified interface for Content and Admin APIs.""" + +import uuid +from typing import Any, Dict, List, Optional, Union +from urllib.parse import urljoin, urlparse + +import httpx +from httpx import Response + +from .auth import AdminAuth, ContentAuth +from .config import config +from .types.errors import AuthenticationError, GhostApiError, NetworkError +from .types.ghost import GhostApiResponse, GhostErrorResponse +from .utils.logging import get_logger +from .utils.retry import RetryConfig, with_retry + +logger = get_logger(__name__) + + +class GhostClient: + """Unified Ghost API client for both Content and Admin APIs.""" + + def __init__( + self, + base_url: Optional[str] = None, + content_api_key: Optional[str] = None, + admin_api_key: Optional[str] = None, + timeout: Optional[int] = None, + ) -> None: + """Initialize Ghost client with optional configuration overrides.""" + self.base_url = base_url or str(config.ghost.url) + self.timeout = timeout or config.ghost.timeout + + # Ensure base URL has proper format + if not self.base_url.endswith("/"): + self.base_url += "/" + + # Initialize authentication handlers + self.content_auth = ContentAuth(content_api_key) + self.admin_auth = AdminAuth(admin_api_key) + + # HTTP client configuration + self.client = httpx.AsyncClient( + timeout=self.timeout, + limits=httpx.Limits(max_keepalive_connections=10, max_connections=100), + ) + + # Retry configuration + self.retry_config = RetryConfig( + max_retries=config.ghost.max_retries, + base_delay=1.0, + exponential_base=config.ghost.retry_backoff_factor, + ) + + async def __aenter__(self) -> "GhostClient": + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Async context manager exit.""" + await self.close() + + async def close(self) -> None: + """Close the HTTP client.""" + await self.client.aclose() + + def _build_url(self, endpoint: str, api_type: str = "content") -> str: + """Build full URL for API endpoint.""" + api_base = f"ghost/api/{api_type}/" + return urljoin(self.base_url, api_base + endpoint) + + async def _make_request( + self, + method: str, + endpoint: str, + api_type: str = "content", + params: Optional[Dict[str, Any]] = None, + json_data: Optional[Dict[str, Any]] = None, + files: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Make HTTP request to Ghost API with error handling and retries.""" + if request_id is None: + request_id = str(uuid.uuid4()) + + url = self._build_url(endpoint, api_type) + headers = {"User-Agent": "Ghost-MCP/0.1.0"} + + # Add authentication + if api_type == "content": + if not self.content_auth.is_configured(): + raise AuthenticationError( + "Content API key not configured", + request_id=request_id, + ) + if params is None: + params = {} + params.update(self.content_auth.get_auth_params(request_id)) + + elif api_type == "admin": + if not self.admin_auth.is_configured(): + raise AuthenticationError( + "Admin API key not configured", + request_id=request_id, + ) + headers.update(self.admin_auth.get_auth_headers(request_id)) + + # Prepare request data + request_kwargs: Dict[str, Any] = { + "method": method, + "url": url, + "headers": headers, + "params": params, + } + + if json_data is not None: + request_kwargs["json"] = json_data + if files is not None: + request_kwargs["files"] = files + + logger.info( + "Making Ghost API request", + method=method, + url=url, + api_type=api_type, + request_id=request_id, + ) + + async def _request() -> Dict[str, Any]: + try: + response: Response = await self.client.request(**request_kwargs) + return await self._handle_response(response, request_id) + except httpx.TimeoutException as e: + raise NetworkError( + f"Request timeout: {e}", + context=f"Timeout after {self.timeout}s", + request_id=request_id, + ) from e + except httpx.ConnectError as e: + raise NetworkError( + f"Connection error: {e}", + context=f"Failed to connect to {url}", + request_id=request_id, + ) from e + except httpx.HTTPError as e: + raise NetworkError( + f"HTTP error: {e}", + context=f"Request to {url} failed", + request_id=request_id, + ) from e + + return await with_retry(_request, self.retry_config, request_id) + + async def _handle_response(self, response: Response, request_id: str) -> Dict[str, Any]: + """Handle HTTP response and convert to appropriate format or raise errors.""" + logger.debug( + "Received Ghost API response", + status_code=response.status_code, + headers=dict(response.headers), + request_id=request_id, + ) + + # Check for successful response + if response.status_code < 400: + try: + data = response.json() + logger.debug("Successfully parsed response JSON", request_id=request_id) + return data + except Exception as e: + raise GhostApiError( + f"Failed to parse response JSON: {e}", + context="Invalid JSON response from Ghost API", + request_id=request_id, + ) from e + + # Handle error responses + try: + error_data = response.json() + error_response = GhostErrorResponse(**error_data) + + # Get first error for primary error info + first_error = error_response.errors[0] if error_response.errors else None + error_message = first_error.message if first_error else "Unknown Ghost API error" + error_code = first_error.code if first_error else None + + raise GhostApiError( + error_message, + code=error_code, + context=f"HTTP {response.status_code}", + request_id=request_id, + ) + + except Exception as e: + if isinstance(e, GhostApiError): + raise + + # Fallback for non-JSON error responses + raise GhostApiError( + f"HTTP {response.status_code}: {response.text}", + context="Non-JSON error response", + request_id=request_id, + ) from e + + # Content API methods + async def get_posts( + self, + limit: Optional[int] = None, + page: Optional[int] = None, + filter: Optional[str] = None, + include: Optional[str] = None, + fields: Optional[str] = None, + order: Optional[str] = None, + request_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Get posts from Content API.""" + params = {} + if limit is not None: + params["limit"] = limit + if page is not None: + params["page"] = page + if filter is not None: + params["filter"] = filter + if include is not None: + params["include"] = include + if fields is not None: + params["fields"] = fields + if order is not None: + params["order"] = order + + return await self._make_request( + "GET", "posts/", api_type="content", params=params, request_id=request_id + ) + + async def get_post_by_id( + self, + post_id: str, + include: Optional[str] = None, + fields: Optional[str] = None, + request_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Get single post by ID from Content API.""" + params = {} + if include is not None: + params["include"] = include + if fields is not None: + params["fields"] = fields + + return await self._make_request( + "GET", f"posts/{post_id}/", api_type="content", params=params, request_id=request_id + ) + + async def get_post_by_slug( + self, + slug: str, + include: Optional[str] = None, + fields: Optional[str] = None, + request_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Get single post by slug from Content API.""" + params = {} + if include is not None: + params["include"] = include + if fields is not None: + params["fields"] = fields + + return await self._make_request( + "GET", f"posts/slug/{slug}/", api_type="content", params=params, request_id=request_id + ) + + # Admin API methods (examples) + async def create_post( + self, + post_data: Dict[str, Any], + request_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Create new post via Admin API.""" + return await self._make_request( + "POST", "posts/", api_type="admin", json_data=post_data, request_id=request_id + ) + + async def update_post( + self, + post_id: str, + post_data: Dict[str, Any], + request_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Update existing post via Admin API.""" + return await self._make_request( + "PUT", f"posts/{post_id}/", api_type="admin", json_data=post_data, request_id=request_id + ) + + async def delete_post( + self, + post_id: str, + request_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Delete post via Admin API.""" + return await self._make_request( + "DELETE", f"posts/{post_id}/", api_type="admin", request_id=request_id + ) \ No newline at end of file diff --git a/src/ghost_mcp/config.py b/src/ghost_mcp/config.py new file mode 100644 index 0000000..acd7f72 --- /dev/null +++ b/src/ghost_mcp/config.py @@ -0,0 +1,81 @@ +"""Configuration management with environment variable precedence.""" + +import os +from enum import Enum +from pathlib import Path +from typing import Optional + +from dotenv import load_dotenv +from pydantic import BaseModel, Field, HttpUrl + + +class LogLevel(str, Enum): + """Logging levels.""" + DEBUG = "debug" + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class GhostMode(str, Enum): + """Ghost API mode.""" + READONLY = "readonly" + READWRITE = "readwrite" + AUTO = "auto" + + +class LoggingConfig(BaseModel): + """Logging configuration.""" + level: LogLevel = LogLevel.INFO + structured: bool = True + request_id: bool = True + + +class GhostConfig(BaseModel): + """Ghost API configuration.""" + url: HttpUrl = Field(default="http://localhost:2368") + content_api_key: Optional[str] = None + admin_api_key: Optional[str] = None + version: str = "v5.0" + mode: GhostMode = GhostMode.AUTO + timeout: int = 30 + max_retries: int = 3 + retry_backoff_factor: float = 2.0 + + +class Config(BaseModel): + """Main configuration class.""" + ghost: GhostConfig = Field(default_factory=GhostConfig) + logging: LoggingConfig = Field(default_factory=LoggingConfig) + + @classmethod + def load(cls, env_file: Optional[str] = None) -> "Config": + """Load configuration with precedence: env vars > .env file > defaults.""" + # Load .env file if specified or if .env exists + env_path = Path(env_file) if env_file else Path(".env") + if env_path.exists(): + load_dotenv(env_path) + + # Create config with environment variables taking precedence + ghost_config = GhostConfig( + url=os.getenv("GHOST_URL", "http://localhost:2368"), + content_api_key=os.getenv("GHOST_CONTENT_API_KEY"), + admin_api_key=os.getenv("GHOST_ADMIN_API_KEY"), + version=os.getenv("GHOST_VERSION", "v5.0"), + mode=GhostMode(os.getenv("GHOST_MODE", "auto")), + timeout=int(os.getenv("GHOST_TIMEOUT", "30")), + max_retries=int(os.getenv("GHOST_MAX_RETRIES", "3")), + retry_backoff_factor=float(os.getenv("GHOST_RETRY_BACKOFF_FACTOR", "2.0")), + ) + + logging_config = LoggingConfig( + level=LogLevel(os.getenv("LOG_LEVEL", "info")), + structured=os.getenv("LOG_STRUCTURED", "true").lower() == "true", + request_id=os.getenv("LOG_REQUEST_ID", "true").lower() == "true", + ) + + return cls(ghost=ghost_config, logging=logging_config) + + +# Global configuration instance +config = Config.load() \ No newline at end of file diff --git a/src/ghost_mcp/server.py b/src/ghost_mcp/server.py new file mode 100644 index 0000000..6b6f73b --- /dev/null +++ b/src/ghost_mcp/server.py @@ -0,0 +1,107 @@ +"""Ghost MCP server entry point.""" + +import asyncio +from typing import Any + +from fastmcp import FastMCP + +from .config import config +from .tools import register_admin_tools, register_content_tools +from .utils.logging import setup_logging, get_logger + +# Set up logging +setup_logging() +logger = get_logger(__name__) + +# Create FastMCP server +mcp = FastMCP("Ghost MCP Server") + + +@mcp.tool() +async def check_ghost_connection() -> str: + """ + Check connection to Ghost instance and API key configuration. + + Returns: + JSON string with connection status and configuration info + """ + import json + from .client import GhostClient + + status = { + "ghost_url": str(config.ghost.url), + "content_api_configured": bool(config.ghost.content_api_key), + "admin_api_configured": bool(config.ghost.admin_api_key), + "mode": config.ghost.mode.value, + "connection_test": "pending", + } + + try: + async with GhostClient() as client: + # Test Content API if configured + if client.content_auth.is_configured(): + try: + await client._make_request("GET", "settings/", api_type="content") + status["content_api_status"] = "connected" + except Exception as e: + status["content_api_status"] = f"error: {e}" + + # Test Admin API if configured + if client.admin_auth.is_configured(): + try: + await client._make_request("GET", "site/", api_type="admin") + status["admin_api_status"] = "connected" + except Exception as e: + status["admin_api_status"] = f"error: {e}" + + status["connection_test"] = "completed" + + except Exception as e: + status["connection_test"] = f"failed: {e}" + + return json.dumps(status, indent=2) + + +def register_tools() -> None: + """Register all MCP tools based on configuration.""" + logger.info("Registering MCP tools", mode=config.ghost.mode.value) + + # Always register Content API tools (read-only) + if config.ghost.content_api_key: + logger.info("Registering Content API tools") + register_content_tools(mcp) + else: + logger.warning("Content API key not configured - Content tools not available") + + # Register Admin API tools based on mode and configuration + if config.ghost.mode.value in ["readwrite", "auto"]: + if config.ghost.admin_api_key: + logger.info("Registering Admin API tools") + register_admin_tools(mcp) + elif config.ghost.mode.value == "readwrite": + logger.warning("Admin API key not configured - Admin tools not available in readwrite mode") + else: + logger.info("Admin API key not configured - running in read-only mode") + else: + logger.info("Running in read-only mode - Admin tools not registered") + + +def main() -> None: + """Main entry point for the Ghost MCP server.""" + logger.info( + "Starting Ghost MCP server", + ghost_url=str(config.ghost.url), + mode=config.ghost.mode.value, + content_api_configured=bool(config.ghost.content_api_key), + admin_api_configured=bool(config.ghost.admin_api_key), + ) + + # Register tools + register_tools() + + # Run the MCP server + mcp.run() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/ghost_mcp/tools/__init__.py b/src/ghost_mcp/tools/__init__.py new file mode 100644 index 0000000..cf6ba50 --- /dev/null +++ b/src/ghost_mcp/tools/__init__.py @@ -0,0 +1,11 @@ +"""MCP tools for Ghost API access.""" + +from .content import * +from .admin import * + +__all__ = [ + # Content API tools + "register_content_tools", + # Admin API tools + "register_admin_tools", +] \ No newline at end of file diff --git a/src/ghost_mcp/tools/admin/__init__.py b/src/ghost_mcp/tools/admin/__init__.py new file mode 100644 index 0000000..9312dd5 --- /dev/null +++ b/src/ghost_mcp/tools/admin/__init__.py @@ -0,0 +1,25 @@ +"""Admin API tools for read/write access to Ghost content.""" + +from fastmcp import FastMCP + +from .posts import register_admin_post_tools +from .pages import register_admin_page_tools +from .tags import register_admin_tag_tools +from .authors import register_admin_author_tools +from .members import register_admin_member_tools +from .settings import register_admin_settings_tools +from .media import register_admin_media_tools + + +def register_admin_tools(mcp: FastMCP) -> None: + """Register all Admin API tools.""" + register_admin_post_tools(mcp) + register_admin_page_tools(mcp) + register_admin_tag_tools(mcp) + register_admin_author_tools(mcp) + register_admin_member_tools(mcp) + register_admin_settings_tools(mcp) + register_admin_media_tools(mcp) + + +__all__ = ["register_admin_tools"] \ No newline at end of file diff --git a/src/ghost_mcp/tools/admin/authors.py b/src/ghost_mcp/tools/admin/authors.py new file mode 100644 index 0000000..49980db --- /dev/null +++ b/src/ghost_mcp/tools/admin/authors.py @@ -0,0 +1,10 @@ +"""Admin API tools for authors management.""" + +from fastmcp import FastMCP + + +def register_admin_author_tools(mcp: FastMCP) -> None: + """Register author management Admin API tools.""" + # Author management tools would go here + # These are typically more complex due to user permissions + pass \ No newline at end of file diff --git a/src/ghost_mcp/tools/admin/media.py b/src/ghost_mcp/tools/admin/media.py new file mode 100644 index 0000000..e3d518d --- /dev/null +++ b/src/ghost_mcp/tools/admin/media.py @@ -0,0 +1,9 @@ +"""Admin API tools for media management.""" + +from fastmcp import FastMCP + + +def register_admin_media_tools(mcp: FastMCP) -> None: + """Register media management Admin API tools.""" + # Media upload and management tools would go here + pass \ No newline at end of file diff --git a/src/ghost_mcp/tools/admin/members.py b/src/ghost_mcp/tools/admin/members.py new file mode 100644 index 0000000..c31259d --- /dev/null +++ b/src/ghost_mcp/tools/admin/members.py @@ -0,0 +1,9 @@ +"""Admin API tools for members management.""" + +from fastmcp import FastMCP + + +def register_admin_member_tools(mcp: FastMCP) -> None: + """Register member management Admin API tools.""" + # Member management tools would go here + pass \ No newline at end of file diff --git a/src/ghost_mcp/tools/admin/pages.py b/src/ghost_mcp/tools/admin/pages.py new file mode 100644 index 0000000..4b48153 --- /dev/null +++ b/src/ghost_mcp/tools/admin/pages.py @@ -0,0 +1,56 @@ +"""Admin API tools for pages management.""" + +import json +from typing import Any, Dict, Optional + +from fastmcp import FastMCP + +from ...client import GhostClient +from ...utils.validation import validate_id_parameter + + +def register_admin_page_tools(mcp: FastMCP) -> None: + """Register page management Admin API tools.""" + + @mcp.tool() + async def create_page( + title: str, + content: Optional[str] = None, + content_format: str = "lexical", + status: str = "draft", + slug: Optional[str] = None, + ) -> str: + """Create a new page via Ghost Admin API.""" + if not title or not title.strip(): + return json.dumps({"error": "Title is required"}) + + try: + page_data: Dict[str, Any] = { + "pages": [{ + "title": title.strip(), + "status": status, + }] + } + + page = page_data["pages"][0] + + if content: + if content_format == "html": + page["html"] = content + elif content_format == "lexical": + page["lexical"] = content + + if slug: + page["slug"] = slug + + async with GhostClient() as client: + result = await client._make_request( + method="POST", + endpoint="pages/", + api_type="admin", + json_data=page_data, + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) \ No newline at end of file diff --git a/src/ghost_mcp/tools/admin/posts.py b/src/ghost_mcp/tools/admin/posts.py new file mode 100644 index 0000000..5c66f45 --- /dev/null +++ b/src/ghost_mcp/tools/admin/posts.py @@ -0,0 +1,247 @@ +"""Admin API tools for posts management.""" + +import json +from typing import Any, Dict, Optional + +from fastmcp import FastMCP + +from ...client import GhostClient +from ...utils.validation import validate_id_parameter + + +def register_admin_post_tools(mcp: FastMCP) -> None: + """Register post management Admin API tools.""" + + @mcp.tool() + async def create_post( + title: str, + content: Optional[str] = None, + content_format: str = "lexical", + status: str = "draft", + slug: Optional[str] = None, + excerpt: Optional[str] = None, + featured: bool = False, + tags: Optional[str] = None, + authors: Optional[str] = None, + published_at: Optional[str] = None, + meta_title: Optional[str] = None, + meta_description: Optional[str] = None, + ) -> str: + """ + Create a new post via Ghost Admin API. + + Args: + title: Post title (required) + content: Post content (HTML or Lexical JSON) + content_format: Content format ('html', 'lexical', default: 'lexical') + status: Post status ('draft', 'published', 'scheduled', default: 'draft') + slug: Post slug (auto-generated if not provided) + excerpt: Custom excerpt + featured: Whether post is featured (default: False) + tags: Comma-separated tag names or IDs + authors: Comma-separated author names or IDs + published_at: Publish date (ISO format, for scheduled posts) + meta_title: SEO meta title + meta_description: SEO meta description + + Returns: + JSON string containing created post data + """ + if not title or not title.strip(): + return json.dumps({"error": "Title is required"}) + + try: + # Build post data + post_data: Dict[str, Any] = { + "posts": [{ + "title": title.strip(), + "status": status, + "featured": featured, + }] + } + + post = post_data["posts"][0] + + # Add content in appropriate format + if content: + if content_format == "html": + post["html"] = content + elif content_format == "lexical": + # Assume content is already Lexical JSON string + post["lexical"] = content + else: + return json.dumps({"error": "Content format must be 'html' or 'lexical'"}) + + # Add optional fields + if slug: + post["slug"] = slug + if excerpt: + post["custom_excerpt"] = excerpt + if published_at: + post["published_at"] = published_at + if meta_title: + post["meta_title"] = meta_title + if meta_description: + post["meta_description"] = meta_description + + # Handle tags (simplified - in real implementation would resolve tag names to IDs) + if tags: + tag_list = [{"name": tag.strip()} for tag in tags.split(",") if tag.strip()] + if tag_list: + post["tags"] = tag_list + + # Handle authors (simplified) + if authors: + author_list = [{"name": author.strip()} for author in authors.split(",") if author.strip()] + if author_list: + post["authors"] = author_list + + async with GhostClient() as client: + result = await client.create_post(post_data) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def update_post( + post_id: str, + title: Optional[str] = None, + content: Optional[str] = None, + content_format: str = "lexical", + status: Optional[str] = None, + slug: Optional[str] = None, + excerpt: Optional[str] = None, + featured: Optional[bool] = None, + published_at: Optional[str] = None, + meta_title: Optional[str] = None, + meta_description: Optional[str] = None, + ) -> str: + """ + Update an existing post via Ghost Admin API. + + Args: + post_id: Post ID to update (required) + title: New post title + content: New post content (HTML or Lexical JSON) + content_format: Content format ('html', 'lexical', default: 'lexical') + status: New post status ('draft', 'published', 'scheduled') + slug: New post slug + excerpt: New custom excerpt + featured: Whether post is featured + published_at: New publish date (ISO format) + meta_title: New SEO meta title + meta_description: New SEO meta description + + Returns: + JSON string containing updated post data + """ + try: + post_id = validate_id_parameter(post_id, "post_id") + + # Build update data with only provided fields + post_data: Dict[str, Any] = {"posts": [{}]} + post = post_data["posts"][0] + + if title is not None: + post["title"] = title.strip() + if status is not None: + post["status"] = status + if slug is not None: + post["slug"] = slug + if excerpt is not None: + post["custom_excerpt"] = excerpt + if featured is not None: + post["featured"] = featured + if published_at is not None: + post["published_at"] = published_at + if meta_title is not None: + post["meta_title"] = meta_title + if meta_description is not None: + post["meta_description"] = meta_description + + # Add content in appropriate format + if content is not None: + if content_format == "html": + post["html"] = content + elif content_format == "lexical": + post["lexical"] = content + else: + return json.dumps({"error": "Content format must be 'html' or 'lexical'"}) + + # Must have at least one field to update + if not post: + return json.dumps({"error": "At least one field must be provided for update"}) + + async with GhostClient() as client: + result = await client.update_post(post_id, post_data) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def delete_post(post_id: str) -> str: + """ + Delete a post via Ghost Admin API. + + Args: + post_id: Post ID to delete (required) + + Returns: + JSON string containing deletion confirmation + """ + try: + post_id = validate_id_parameter(post_id, "post_id") + + async with GhostClient() as client: + result = await client.delete_post(post_id) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def get_admin_posts( + limit: Optional[int] = None, + page: Optional[int] = None, + filter: Optional[str] = None, + include: Optional[str] = None, + fields: Optional[str] = None, + order: Optional[str] = None, + ) -> str: + """ + Get posts from Ghost Admin API (includes drafts and all statuses). + + Args: + limit: Number of posts to return (1-50, default: 15) + page: Page number for pagination (default: 1) + filter: Ghost filter syntax for filtering posts + include: Comma-separated list of fields to include (tags, authors, etc.) + fields: Comma-separated list of fields to return + order: Order of posts (published_at desc, etc.) + + Returns: + JSON string containing posts data with metadata + """ + try: + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint="posts/", + api_type="admin", + params={ + k: v for k, v in { + "limit": limit, + "page": page, + "filter": filter, + "include": include, + "fields": fields, + "order": order, + }.items() if v is not None + } + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) \ No newline at end of file diff --git a/src/ghost_mcp/tools/admin/settings.py b/src/ghost_mcp/tools/admin/settings.py new file mode 100644 index 0000000..7abc204 --- /dev/null +++ b/src/ghost_mcp/tools/admin/settings.py @@ -0,0 +1,9 @@ +"""Admin API tools for settings management.""" + +from fastmcp import FastMCP + + +def register_admin_settings_tools(mcp: FastMCP) -> None: + """Register settings management Admin API tools.""" + # Settings management tools would go here + pass \ No newline at end of file diff --git a/src/ghost_mcp/tools/admin/tags.py b/src/ghost_mcp/tools/admin/tags.py new file mode 100644 index 0000000..2a53312 --- /dev/null +++ b/src/ghost_mcp/tools/admin/tags.py @@ -0,0 +1,38 @@ +"""Admin API tools for tags management.""" + +import json +from typing import Any, Dict + +from fastmcp import FastMCP + +from ...client import GhostClient + + +def register_admin_tag_tools(mcp: FastMCP) -> None: + """Register tag management Admin API tools.""" + + @mcp.tool() + async def create_tag(name: str, description: str = "") -> str: + """Create a new tag via Ghost Admin API.""" + if not name or not name.strip(): + return json.dumps({"error": "Tag name is required"}) + + try: + tag_data: Dict[str, Any] = { + "tags": [{ + "name": name.strip(), + "description": description, + }] + } + + async with GhostClient() as client: + result = await client._make_request( + method="POST", + endpoint="tags/", + api_type="admin", + json_data=tag_data, + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) \ No newline at end of file diff --git a/src/ghost_mcp/tools/content/__init__.py b/src/ghost_mcp/tools/content/__init__.py new file mode 100644 index 0000000..6eaab54 --- /dev/null +++ b/src/ghost_mcp/tools/content/__init__.py @@ -0,0 +1,19 @@ +"""Content API tools for read-only access to Ghost content.""" + +from fastmcp import FastMCP + +from .posts import register_post_tools +from .pages import register_page_tools +from .tags import register_tag_tools +from .authors import register_author_tools +from .settings import register_settings_tools + +def register_content_tools(mcp: FastMCP) -> None: + """Register all Content API tools.""" + register_post_tools(mcp) + register_page_tools(mcp) + register_tag_tools(mcp) + register_author_tools(mcp) + register_settings_tools(mcp) + +__all__ = ["register_content_tools"] \ No newline at end of file diff --git a/src/ghost_mcp/tools/content/authors.py b/src/ghost_mcp/tools/content/authors.py new file mode 100644 index 0000000..708a996 --- /dev/null +++ b/src/ghost_mcp/tools/content/authors.py @@ -0,0 +1,142 @@ +"""Content API tools for authors.""" + +import json +from typing import Optional + +from fastmcp import FastMCP + +from ...client import GhostClient +from ...utils.validation import validate_filter_syntax, validate_id_parameter, validate_slug_parameter + + +def register_author_tools(mcp: FastMCP) -> None: + """Register author-related Content API tools.""" + + @mcp.tool() + async def get_authors( + limit: Optional[int] = None, + page: Optional[int] = None, + filter: Optional[str] = None, + include: Optional[str] = None, + fields: Optional[str] = None, + order: Optional[str] = None, + ) -> str: + """ + Get authors from Ghost Content API. + + Args: + limit: Number of authors to return (1-50, default: 15) + page: Page number for pagination (default: 1) + filter: Ghost filter syntax for filtering authors + include: Comma-separated list of fields to include (count.posts, etc.) + fields: Comma-separated list of fields to return + order: Order of authors (name asc, count.posts desc, etc.) + + Returns: + JSON string containing authors data with metadata + """ + # Validate parameters + if limit is not None and (limit < 1 or limit > 50): + return json.dumps({"error": "Limit must be between 1 and 50"}) + + if page is not None and page < 1: + return json.dumps({"error": "Page must be 1 or greater"}) + + if filter and not validate_filter_syntax(filter): + return json.dumps({"error": "Invalid filter syntax"}) + + try: + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint="authors/", + api_type="content", + params={ + k: v for k, v in { + "limit": limit, + "page": page, + "filter": filter, + "include": include, + "fields": fields, + "order": order, + }.items() if v is not None + } + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def get_author_by_id( + author_id: str, + include: Optional[str] = None, + fields: Optional[str] = None, + ) -> str: + """ + Get a single author by ID from Ghost Content API. + + Args: + author_id: The author ID + include: Comma-separated list of fields to include (count.posts, etc.) + fields: Comma-separated list of fields to return + + Returns: + JSON string containing author data + """ + try: + author_id = validate_id_parameter(author_id, "author_id") + + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint=f"authors/{author_id}/", + api_type="content", + params={ + k: v for k, v in { + "include": include, + "fields": fields, + }.items() if v is not None + } + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def get_author_by_slug( + slug: str, + include: Optional[str] = None, + fields: Optional[str] = None, + ) -> str: + """ + Get a single author by slug from Ghost Content API. + + Args: + slug: The author slug + include: Comma-separated list of fields to include (count.posts, etc.) + fields: Comma-separated list of fields to return + + Returns: + JSON string containing author data + """ + try: + slug = validate_slug_parameter(slug) + + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint=f"authors/slug/{slug}/", + api_type="content", + params={ + k: v for k, v in { + "include": include, + "fields": fields, + }.items() if v is not None + } + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) \ No newline at end of file diff --git a/src/ghost_mcp/tools/content/pages.py b/src/ghost_mcp/tools/content/pages.py new file mode 100644 index 0000000..1b0e4e0 --- /dev/null +++ b/src/ghost_mcp/tools/content/pages.py @@ -0,0 +1,142 @@ +"""Content API tools for pages.""" + +import json +from typing import Optional + +from fastmcp import FastMCP + +from ...client import GhostClient +from ...utils.validation import validate_filter_syntax, validate_id_parameter, validate_slug_parameter + + +def register_page_tools(mcp: FastMCP) -> None: + """Register page-related Content API tools.""" + + @mcp.tool() + async def get_pages( + limit: Optional[int] = None, + page: Optional[int] = None, + filter: Optional[str] = None, + include: Optional[str] = None, + fields: Optional[str] = None, + order: Optional[str] = None, + ) -> str: + """ + Get published pages from Ghost Content API. + + Args: + limit: Number of pages to return (1-50, default: 15) + page: Page number for pagination (default: 1) + filter: Ghost filter syntax for filtering pages + include: Comma-separated list of fields to include (tags, authors, etc.) + fields: Comma-separated list of fields to return + order: Order of pages (published_at desc, etc.) + + Returns: + JSON string containing pages data with metadata + """ + # Validate parameters + if limit is not None and (limit < 1 or limit > 50): + return json.dumps({"error": "Limit must be between 1 and 50"}) + + if page is not None and page < 1: + return json.dumps({"error": "Page must be 1 or greater"}) + + if filter and not validate_filter_syntax(filter): + return json.dumps({"error": "Invalid filter syntax"}) + + try: + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint="pages/", + api_type="content", + params={ + k: v for k, v in { + "limit": limit, + "page": page, + "filter": filter, + "include": include, + "fields": fields, + "order": order, + }.items() if v is not None + } + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def get_page_by_id( + page_id: str, + include: Optional[str] = None, + fields: Optional[str] = None, + ) -> str: + """ + Get a single page by ID from Ghost Content API. + + Args: + page_id: The page ID + include: Comma-separated list of fields to include (tags, authors, etc.) + fields: Comma-separated list of fields to return + + Returns: + JSON string containing page data + """ + try: + page_id = validate_id_parameter(page_id, "page_id") + + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint=f"pages/{page_id}/", + api_type="content", + params={ + k: v for k, v in { + "include": include, + "fields": fields, + }.items() if v is not None + } + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def get_page_by_slug( + slug: str, + include: Optional[str] = None, + fields: Optional[str] = None, + ) -> str: + """ + Get a single page by slug from Ghost Content API. + + Args: + slug: The page slug + include: Comma-separated list of fields to include (tags, authors, etc.) + fields: Comma-separated list of fields to return + + Returns: + JSON string containing page data + """ + try: + slug = validate_slug_parameter(slug) + + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint=f"pages/slug/{slug}/", + api_type="content", + params={ + k: v for k, v in { + "include": include, + "fields": fields, + }.items() if v is not None + } + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) \ No newline at end of file diff --git a/src/ghost_mcp/tools/content/posts.py b/src/ghost_mcp/tools/content/posts.py new file mode 100644 index 0000000..8fa8b20 --- /dev/null +++ b/src/ghost_mcp/tools/content/posts.py @@ -0,0 +1,161 @@ +"""Content API tools for posts.""" + +import json +from typing import Any, Dict, Optional + +from fastmcp import FastMCP + +from ...client import GhostClient +from ...utils.validation import validate_filter_syntax, validate_id_parameter, validate_slug_parameter + + +def register_post_tools(mcp: FastMCP) -> None: + """Register post-related Content API tools.""" + + @mcp.tool() + async def get_posts( + limit: Optional[int] = None, + page: Optional[int] = None, + filter: Optional[str] = None, + include: Optional[str] = None, + fields: Optional[str] = None, + order: Optional[str] = None, + ) -> str: + """ + Get published posts from Ghost Content API. + + Args: + limit: Number of posts to return (1-50, default: 15) + page: Page number for pagination (default: 1) + filter: Ghost filter syntax for filtering posts + include: Comma-separated list of fields to include (tags, authors, etc.) + fields: Comma-separated list of fields to return + order: Order of posts (published_at desc, etc.) + + Returns: + JSON string containing posts data with metadata + """ + # Validate parameters + if limit is not None and (limit < 1 or limit > 50): + return json.dumps({"error": "Limit must be between 1 and 50"}) + + if page is not None and page < 1: + return json.dumps({"error": "Page must be 1 or greater"}) + + if filter and not validate_filter_syntax(filter): + return json.dumps({"error": "Invalid filter syntax"}) + + try: + async with GhostClient() as client: + result = await client.get_posts( + limit=limit, + page=page, + filter=filter, + include=include, + fields=fields, + order=order, + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def get_post_by_id( + post_id: str, + include: Optional[str] = None, + fields: Optional[str] = None, + ) -> str: + """ + Get a single post by ID from Ghost Content API. + + Args: + post_id: The post ID + include: Comma-separated list of fields to include (tags, authors, etc.) + fields: Comma-separated list of fields to return + + Returns: + JSON string containing post data + """ + try: + post_id = validate_id_parameter(post_id, "post_id") + + async with GhostClient() as client: + result = await client.get_post_by_id( + post_id=post_id, + include=include, + fields=fields, + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def get_post_by_slug( + slug: str, + include: Optional[str] = None, + fields: Optional[str] = None, + ) -> str: + """ + Get a single post by slug from Ghost Content API. + + Args: + slug: The post slug + include: Comma-separated list of fields to include (tags, authors, etc.) + fields: Comma-separated list of fields to return + + Returns: + JSON string containing post data + """ + try: + slug = validate_slug_parameter(slug) + + async with GhostClient() as client: + result = await client.get_post_by_slug( + slug=slug, + include=include, + fields=fields, + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def search_posts( + query: str, + limit: Optional[int] = None, + include: Optional[str] = None, + ) -> str: + """ + Search posts by title and content. + + Args: + query: Search query string + limit: Number of results to return (1-50, default: 15) + include: Comma-separated list of fields to include + + Returns: + JSON string containing matching posts + """ + if not query or not query.strip(): + return json.dumps({"error": "Query parameter is required"}) + + if limit is not None and (limit < 1 or limit > 50): + return json.dumps({"error": "Limit must be between 1 and 50"}) + + try: + # Use Ghost's filter syntax for searching + search_filter = f"title:~'{query}',plaintext:~'{query}'" + + async with GhostClient() as client: + result = await client.get_posts( + limit=limit, + filter=search_filter, + include=include, + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) \ No newline at end of file diff --git a/src/ghost_mcp/tools/content/settings.py b/src/ghost_mcp/tools/content/settings.py new file mode 100644 index 0000000..abd2da5 --- /dev/null +++ b/src/ghost_mcp/tools/content/settings.py @@ -0,0 +1,70 @@ +"""Content API tools for settings.""" + +import json +from typing import Optional + +from fastmcp import FastMCP + +from ...client import GhostClient + + +def register_settings_tools(mcp: FastMCP) -> None: + """Register settings-related Content API tools.""" + + @mcp.tool() + async def get_settings() -> str: + """ + Get public settings from Ghost Content API. + + Returns: + JSON string containing public settings data + """ + try: + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint="settings/", + api_type="content", + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def get_site_info() -> str: + """ + Get basic site information from Ghost. + + Returns: + JSON string containing site title, description, URL, and other public info + """ + try: + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint="settings/", + api_type="content", + ) + + # Extract key site information + if "settings" in result: + settings = result["settings"] + site_info = { + "title": settings.get("title"), + "description": settings.get("description"), + "url": settings.get("url"), + "logo": settings.get("logo"), + "icon": settings.get("icon"), + "cover_image": settings.get("cover_image"), + "accent_color": settings.get("accent_color"), + "timezone": settings.get("timezone"), + "lang": settings.get("lang"), + "version": settings.get("version"), + } + return json.dumps({"site_info": site_info}, indent=2, default=str) + + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) \ No newline at end of file diff --git a/src/ghost_mcp/tools/content/tags.py b/src/ghost_mcp/tools/content/tags.py new file mode 100644 index 0000000..f0dd419 --- /dev/null +++ b/src/ghost_mcp/tools/content/tags.py @@ -0,0 +1,142 @@ +"""Content API tools for tags.""" + +import json +from typing import Optional + +from fastmcp import FastMCP + +from ...client import GhostClient +from ...utils.validation import validate_filter_syntax, validate_id_parameter, validate_slug_parameter + + +def register_tag_tools(mcp: FastMCP) -> None: + """Register tag-related Content API tools.""" + + @mcp.tool() + async def get_tags( + limit: Optional[int] = None, + page: Optional[int] = None, + filter: Optional[str] = None, + include: Optional[str] = None, + fields: Optional[str] = None, + order: Optional[str] = None, + ) -> str: + """ + Get tags from Ghost Content API. + + Args: + limit: Number of tags to return (1-50, default: 15) + page: Page number for pagination (default: 1) + filter: Ghost filter syntax for filtering tags + include: Comma-separated list of fields to include (count.posts, etc.) + fields: Comma-separated list of fields to return + order: Order of tags (name asc, count.posts desc, etc.) + + Returns: + JSON string containing tags data with metadata + """ + # Validate parameters + if limit is not None and (limit < 1 or limit > 50): + return json.dumps({"error": "Limit must be between 1 and 50"}) + + if page is not None and page < 1: + return json.dumps({"error": "Page must be 1 or greater"}) + + if filter and not validate_filter_syntax(filter): + return json.dumps({"error": "Invalid filter syntax"}) + + try: + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint="tags/", + api_type="content", + params={ + k: v for k, v in { + "limit": limit, + "page": page, + "filter": filter, + "include": include, + "fields": fields, + "order": order, + }.items() if v is not None + } + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def get_tag_by_id( + tag_id: str, + include: Optional[str] = None, + fields: Optional[str] = None, + ) -> str: + """ + Get a single tag by ID from Ghost Content API. + + Args: + tag_id: The tag ID + include: Comma-separated list of fields to include (count.posts, etc.) + fields: Comma-separated list of fields to return + + Returns: + JSON string containing tag data + """ + try: + tag_id = validate_id_parameter(tag_id, "tag_id") + + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint=f"tags/{tag_id}/", + api_type="content", + params={ + k: v for k, v in { + "include": include, + "fields": fields, + }.items() if v is not None + } + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) + + @mcp.tool() + async def get_tag_by_slug( + slug: str, + include: Optional[str] = None, + fields: Optional[str] = None, + ) -> str: + """ + Get a single tag by slug from Ghost Content API. + + Args: + slug: The tag slug + include: Comma-separated list of fields to include (count.posts, etc.) + fields: Comma-separated list of fields to return + + Returns: + JSON string containing tag data + """ + try: + slug = validate_slug_parameter(slug) + + async with GhostClient() as client: + result = await client._make_request( + method="GET", + endpoint=f"tags/slug/{slug}/", + api_type="content", + params={ + k: v for k, v in { + "include": include, + "fields": fields, + }.items() if v is not None + } + ) + return json.dumps(result, indent=2, default=str) + + except Exception as e: + return json.dumps({"error": str(e)}) \ No newline at end of file diff --git a/src/ghost_mcp/types/__init__.py b/src/ghost_mcp/types/__init__.py new file mode 100644 index 0000000..27f6508 --- /dev/null +++ b/src/ghost_mcp/types/__init__.py @@ -0,0 +1,30 @@ +"""Type definitions for Ghost MCP server.""" + +from .errors import * +from .ghost import * +from .mcp import * + +__all__ = [ + # Error types + "GhostMCPError", + "NetworkError", + "AuthenticationError", + "GhostApiError", + "ValidationError", + "ErrorCategory", + # Ghost types + "GhostPost", + "GhostPage", + "GhostTag", + "GhostAuthor", + "GhostMember", + "GhostSettings", + "GhostApiResponse", + "GhostErrorResponse", + "ContentFormat", + # MCP types + "MCPToolDefinition", + "MCPToolResponse", + "MCPToolRequest", + "MCPError", +] \ No newline at end of file diff --git a/src/ghost_mcp/types/errors.py b/src/ghost_mcp/types/errors.py new file mode 100644 index 0000000..2398232 --- /dev/null +++ b/src/ghost_mcp/types/errors.py @@ -0,0 +1,128 @@ +"""Error types and categories for Ghost MCP server.""" + +import uuid +from enum import Enum +from typing import Any, Dict, Optional + + +class ErrorCategory(str, Enum): + """Error categories for comprehensive error handling.""" + NETWORK = "NETWORK" + AUTHENTICATION = "AUTHENTICATION" + GHOST_API = "GHOST_API" + MCP_PROTOCOL = "MCP_PROTOCOL" + FILE_UPLOAD = "FILE_UPLOAD" + VALIDATION = "VALIDATION" + + +class GhostMCPError(Exception): + """Base error class for Ghost MCP server.""" + + def __init__( + self, + message: str, + category: ErrorCategory, + code: Optional[str] = None, + context: Optional[str] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__(message) + self.id = str(uuid.uuid4()) + self.category = category + self.code = code + self.context = context + self.request_id = request_id + + def to_dict(self) -> Dict[str, Any]: + """Convert error to dictionary for logging.""" + return { + "id": self.id, + "message": str(self), + "category": self.category.value, + "code": self.code, + "context": self.context, + "request_id": self.request_id, + } + + +class NetworkError(GhostMCPError): + """Network-related errors.""" + + def __init__( + self, + message: str, + context: Optional[str] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__( + message, ErrorCategory.NETWORK, "NETWORK_ERROR", context, request_id + ) + + +class AuthenticationError(GhostMCPError): + """Authentication-related errors.""" + + def __init__( + self, + message: str, + context: Optional[str] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__( + message, ErrorCategory.AUTHENTICATION, "AUTH_ERROR", context, request_id + ) + + +class GhostApiError(GhostMCPError): + """Ghost API-related errors.""" + + def __init__( + self, + message: str, + code: Optional[str] = None, + context: Optional[str] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__(message, ErrorCategory.GHOST_API, code, context, request_id) + + +class ValidationError(GhostMCPError): + """Validation-related errors.""" + + def __init__( + self, + message: str, + context: Optional[str] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__( + message, ErrorCategory.VALIDATION, "VALIDATION_ERROR", context, request_id + ) + + +class FileUploadError(GhostMCPError): + """File upload-related errors.""" + + def __init__( + self, + message: str, + context: Optional[str] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__( + message, ErrorCategory.FILE_UPLOAD, "FILE_UPLOAD_ERROR", context, request_id + ) + + +class MCPProtocolError(GhostMCPError): + """MCP protocol-related errors.""" + + def __init__( + self, + message: str, + context: Optional[str] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__( + message, ErrorCategory.MCP_PROTOCOL, "MCP_ERROR", context, request_id + ) \ No newline at end of file diff --git a/src/ghost_mcp/types/ghost.py b/src/ghost_mcp/types/ghost.py new file mode 100644 index 0000000..665f144 --- /dev/null +++ b/src/ghost_mcp/types/ghost.py @@ -0,0 +1,251 @@ +"""Ghost API type definitions based on Ghost v5.0 API.""" + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel, Field, HttpUrl + + +class ContentFormat(str, Enum): + """Content formats supported by Ghost.""" + LEXICAL = "lexical" + HTML = "html" + MOBILEDOC = "mobiledoc" + + +class PostStatus(str, Enum): + """Post status options.""" + DRAFT = "draft" + PUBLISHED = "published" + SCHEDULED = "scheduled" + + +class VisibilityType(str, Enum): + """Content visibility options.""" + PUBLIC = "public" + MEMBERS = "members" + PAID = "paid" + TIERS = "tiers" + + +class GhostMeta(BaseModel): + """Ghost API response metadata.""" + pagination: Optional[Dict[str, Any]] = None + + +class GhostAuthor(BaseModel): + """Ghost author object.""" + id: str + name: str + slug: str + email: Optional[str] = None + profile_image: Optional[HttpUrl] = None + cover_image: Optional[HttpUrl] = None + bio: Optional[str] = None + website: Optional[HttpUrl] = None + location: Optional[str] = None + facebook: Optional[str] = None + twitter: Optional[str] = None + accessibility: Optional[str] = None + status: str + meta_title: Optional[str] = None + meta_description: Optional[str] = None + tour: Optional[List[str]] = None + last_seen: Optional[datetime] = None + created_at: datetime + updated_at: datetime + roles: Optional[List[Dict[str, Any]]] = None + url: str + + +class GhostTag(BaseModel): + """Ghost tag object.""" + id: str + name: str + slug: str + description: Optional[str] = None + feature_image: Optional[HttpUrl] = None + visibility: VisibilityType = VisibilityType.PUBLIC + og_image: Optional[HttpUrl] = None + og_title: Optional[str] = None + og_description: Optional[str] = None + twitter_image: Optional[HttpUrl] = None + twitter_title: Optional[str] = None + twitter_description: Optional[str] = None + meta_title: Optional[str] = None + meta_description: Optional[str] = None + codeinjection_head: Optional[str] = None + codeinjection_foot: Optional[str] = None + canonical_url: Optional[HttpUrl] = None + accent_color: Optional[str] = None + created_at: datetime + updated_at: datetime + url: str + + +class GhostPost(BaseModel): + """Ghost post object.""" + id: str + uuid: str + title: str + slug: str + mobiledoc: Optional[str] = None + lexical: Optional[str] = None + html: Optional[str] = None + comment_id: Optional[str] = None + plaintext: Optional[str] = None + feature_image: Optional[HttpUrl] = None + feature_image_alt: Optional[str] = None + feature_image_caption: Optional[str] = None + featured: bool = False + status: PostStatus = PostStatus.DRAFT + visibility: VisibilityType = VisibilityType.PUBLIC + created_at: datetime + updated_at: datetime + published_at: Optional[datetime] = None + custom_excerpt: Optional[str] = None + codeinjection_head: Optional[str] = None + codeinjection_foot: Optional[str] = None + custom_template: Optional[str] = None + canonical_url: Optional[HttpUrl] = None + tags: Optional[List[GhostTag]] = None + authors: Optional[List[GhostAuthor]] = None + primary_author: Optional[GhostAuthor] = None + primary_tag: Optional[GhostTag] = None + url: str + excerpt: Optional[str] = None + reading_time: Optional[int] = None + access: Optional[bool] = None + og_image: Optional[HttpUrl] = None + og_title: Optional[str] = None + og_description: Optional[str] = None + twitter_image: Optional[HttpUrl] = None + twitter_title: Optional[str] = None + twitter_description: Optional[str] = None + meta_title: Optional[str] = None + meta_description: Optional[str] = None + email_segment: Optional[str] = None + newsletter: Optional[Dict[str, Any]] = None + + +class GhostPage(BaseModel): + """Ghost page object.""" + id: str + uuid: str + title: str + slug: str + mobiledoc: Optional[str] = None + lexical: Optional[str] = None + html: Optional[str] = None + comment_id: Optional[str] = None + plaintext: Optional[str] = None + feature_image: Optional[HttpUrl] = None + feature_image_alt: Optional[str] = None + feature_image_caption: Optional[str] = None + featured: bool = False + status: PostStatus = PostStatus.DRAFT + visibility: VisibilityType = VisibilityType.PUBLIC + created_at: datetime + updated_at: datetime + published_at: Optional[datetime] = None + custom_excerpt: Optional[str] = None + codeinjection_head: Optional[str] = None + codeinjection_foot: Optional[str] = None + custom_template: Optional[str] = None + canonical_url: Optional[HttpUrl] = None + tags: Optional[List[GhostTag]] = None + authors: Optional[List[GhostAuthor]] = None + primary_author: Optional[GhostAuthor] = None + primary_tag: Optional[GhostTag] = None + url: str + excerpt: Optional[str] = None + reading_time: Optional[int] = None + access: Optional[bool] = None + og_image: Optional[HttpUrl] = None + og_title: Optional[str] = None + og_description: Optional[str] = None + twitter_image: Optional[HttpUrl] = None + twitter_title: Optional[str] = None + twitter_description: Optional[str] = None + meta_title: Optional[str] = None + meta_description: Optional[str] = None + + +class GhostMember(BaseModel): + """Ghost member object.""" + id: str + uuid: str + email: str + name: Optional[str] = None + note: Optional[str] = None + subscribed: bool = True + created_at: datetime + updated_at: datetime + labels: Optional[List[Dict[str, Any]]] = None + avatar_image: Optional[HttpUrl] = None + comped: bool = False + email_count: int = 0 + email_opened_count: int = 0 + email_open_rate: Optional[float] = None + status: str = "free" + last_seen: Optional[datetime] = None + newsletters: Optional[List[Dict[str, Any]]] = None + subscriptions: Optional[List[Dict[str, Any]]] = None + products: Optional[List[Dict[str, Any]]] = None + + +class GhostSettings(BaseModel): + """Ghost settings object.""" + title: str + description: str + logo: Optional[HttpUrl] = None + icon: Optional[HttpUrl] = None + accent_color: Optional[str] = None + cover_image: Optional[HttpUrl] = None + facebook: Optional[str] = None + twitter: Optional[str] = None + lang: str = "en" + timezone: str = "Etc/UTC" + codeinjection_head: Optional[str] = None + codeinjection_foot: Optional[str] = None + navigation: Optional[List[Dict[str, Any]]] = None + secondary_navigation: Optional[List[Dict[str, Any]]] = None + meta_title: Optional[str] = None + meta_description: Optional[str] = None + og_image: Optional[HttpUrl] = None + og_title: Optional[str] = None + og_description: Optional[str] = None + twitter_image: Optional[HttpUrl] = None + twitter_title: Optional[str] = None + twitter_description: Optional[str] = None + url: str + + +class GhostApiResponse(BaseModel): + """Ghost API response wrapper.""" + posts: Optional[List[GhostPost]] = None + pages: Optional[List[GhostPage]] = None + tags: Optional[List[GhostTag]] = None + authors: Optional[List[GhostAuthor]] = None + members: Optional[List[GhostMember]] = None + settings: Optional[GhostSettings] = None + meta: Optional[GhostMeta] = None + + +class GhostError(BaseModel): + """Ghost API error object.""" + id: str + message: str + context: Optional[str] = None + type: str + details: Optional[str] = None + property: Optional[str] = None + help: Optional[str] = None + code: Optional[str] = None + ghostErrorCode: Optional[str] = None + + +class GhostErrorResponse(BaseModel): + """Ghost API error response.""" + errors: List[GhostError] \ No newline at end of file diff --git a/src/ghost_mcp/types/mcp.py b/src/ghost_mcp/types/mcp.py new file mode 100644 index 0000000..390245a --- /dev/null +++ b/src/ghost_mcp/types/mcp.py @@ -0,0 +1,49 @@ +"""MCP-specific type definitions.""" + +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel + + +class MCPToolParameter(BaseModel): + """MCP tool parameter definition.""" + type: str + description: Optional[str] = None + required: bool = False + enum: Optional[List[str]] = None + properties: Optional[Dict[str, "MCPToolParameter"]] = None + + +class MCPToolDefinition(BaseModel): + """MCP tool definition.""" + name: str + description: str + inputSchema: Dict[str, Any] + + +class MCPContent(BaseModel): + """MCP response content.""" + type: str + text: Optional[str] = None + data: Optional[str] = None + url: Optional[str] = None + mimeType: Optional[str] = None + + +class MCPToolResponse(BaseModel): + """MCP tool response.""" + content: List[MCPContent] + isError: bool = False + + +class MCPToolRequest(BaseModel): + """MCP tool request.""" + name: str + arguments: Dict[str, Any] + + +class MCPError(BaseModel): + """MCP error response.""" + code: int + message: str + data: Optional[Any] = None \ No newline at end of file diff --git a/src/ghost_mcp/utils/__init__.py b/src/ghost_mcp/utils/__init__.py new file mode 100644 index 0000000..fad300b --- /dev/null +++ b/src/ghost_mcp/utils/__init__.py @@ -0,0 +1,14 @@ +"""Utility modules for Ghost MCP server.""" + +from .logging import setup_logging, get_logger +from .retry import with_retry, RetryConfig +from .validation import validate_filter_syntax, validate_pagination_params + +__all__ = [ + "setup_logging", + "get_logger", + "with_retry", + "RetryConfig", + "validate_filter_syntax", + "validate_pagination_params", +] \ No newline at end of file diff --git a/src/ghost_mcp/utils/logging.py b/src/ghost_mcp/utils/logging.py new file mode 100644 index 0000000..f1a6ef2 --- /dev/null +++ b/src/ghost_mcp/utils/logging.py @@ -0,0 +1,60 @@ +"""Logging utilities for Ghost MCP server.""" + +import logging +import sys +import uuid +from typing import Any, Dict, Optional + +import structlog +from structlog.typing import Processor + +from ..config import config + + +def add_request_id(logger: Any, method_name: str, event_dict: Dict[str, Any]) -> Dict[str, Any]: + """Add request ID to log events.""" + if "request_id" not in event_dict: + event_dict["request_id"] = str(uuid.uuid4()) + return event_dict + + +def setup_logging() -> None: + """Set up structured logging for the application.""" + processors: list[Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + ] + + if config.logging.request_id: + processors.append(add_request_id) + + if config.logging.structured: + processors.extend([ + structlog.processors.JSONRenderer() + ]) + else: + processors.extend([ + structlog.dev.ConsoleRenderer() + ]) + + structlog.configure( + processors=processors, + wrapper_class=structlog.make_filtering_bound_logger( + getattr(logging, config.logging.level.upper()) + ), + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=True, + ) + + # Configure standard library logging + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=getattr(logging, config.logging.level.upper()), + ) + + +def get_logger(name: Optional[str] = None) -> structlog.BoundLogger: + """Get a logger instance.""" + return structlog.get_logger(name) \ No newline at end of file diff --git a/src/ghost_mcp/utils/retry.py b/src/ghost_mcp/utils/retry.py new file mode 100644 index 0000000..2c7d327 --- /dev/null +++ b/src/ghost_mcp/utils/retry.py @@ -0,0 +1,77 @@ +"""Retry utilities with exponential backoff.""" + +import asyncio +import time +from typing import Any, Awaitable, Callable, Optional, TypeVar + +from pydantic import BaseModel + +from ..types.errors import NetworkError +from .logging import get_logger + +T = TypeVar("T") +logger = get_logger(__name__) + + +class RetryConfig(BaseModel): + """Configuration for retry behavior.""" + max_retries: int = 3 + base_delay: float = 1.0 + max_delay: float = 10.0 + exponential_base: float = 2.0 + jitter: bool = True + + +async def with_retry( + operation: Callable[[], Awaitable[T]], + config: Optional[RetryConfig] = None, + request_id: Optional[str] = None, +) -> T: + """Execute operation with exponential backoff retry logic.""" + if config is None: + config = RetryConfig() + + last_exception: Optional[Exception] = None + + for attempt in range(config.max_retries + 1): + try: + return await operation() + except Exception as e: + last_exception = e + + if attempt == config.max_retries: + logger.error( + "Operation failed after all retries", + attempt=attempt, + max_retries=config.max_retries, + error=str(e), + request_id=request_id, + ) + break + + # Calculate delay with exponential backoff + delay = min( + config.base_delay * (config.exponential_base ** attempt), + config.max_delay + ) + + # Add jitter to prevent thundering herd + if config.jitter: + import random + delay = delay * (0.5 + random.random() * 0.5) + + logger.warning( + "Operation failed, retrying", + attempt=attempt, + delay=delay, + error=str(e), + request_id=request_id, + ) + + await asyncio.sleep(delay) + + # Re-raise the last exception + if last_exception: + raise last_exception + + raise NetworkError("Retry logic failed unexpectedly", request_id=request_id) \ No newline at end of file diff --git a/src/ghost_mcp/utils/validation.py b/src/ghost_mcp/utils/validation.py new file mode 100644 index 0000000..6a0861c --- /dev/null +++ b/src/ghost_mcp/utils/validation.py @@ -0,0 +1,93 @@ +"""Parameter validation utilities.""" + +import re +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field, validator + +from ..types.errors import ValidationError + + +class PaginationParams(BaseModel): + """Pagination parameters validation.""" + limit: Optional[int] = Field(None, ge=1, le=50) + page: Optional[int] = Field(None, ge=1) + + +class FilterParams(BaseModel): + """Filter parameters validation.""" + filter: Optional[str] = None + include: Optional[str] = None + fields: Optional[str] = None + order: Optional[str] = None + + +def validate_pagination_params(params: Dict[str, Any]) -> PaginationParams: + """Validate pagination parameters.""" + try: + return PaginationParams(**params) + except ValueError as e: + raise ValidationError(f"Invalid pagination parameters: {e}") + + +def validate_filter_syntax(filter_string: str) -> bool: + """ + Validate Ghost filter syntax (NQL - Node Query Language). + + Based on contracts/ghost-filtering.md documentation. + """ + if not filter_string: + return True + + # Basic validation patterns for common NQL operators + valid_operators = [ + r"\+", # AND + r",", # OR + r":", # EQUALS + r":-", # NOT EQUALS + r":~", # CONTAINS + r":-~", # NOT CONTAINS + r":>", # GREATER THAN + r":<", # LESS THAN + r":>=", # GREATER THAN OR EQUAL + r":<=", # LESS THAN OR EQUAL + ] + + # Check for balanced parentheses + if filter_string.count('(') != filter_string.count(')'): + return False + + # Check for balanced square brackets + if filter_string.count('[') != filter_string.count(']'): + return False + + # More comprehensive validation would go here + # For now, we accept most strings as valid + return True + + +def validate_id_parameter(id_value: str, parameter_name: str = "id") -> str: + """Validate ID parameter format.""" + if not id_value or not isinstance(id_value, str): + raise ValidationError(f"Invalid {parameter_name}: must be a non-empty string") + + if len(id_value.strip()) == 0: + raise ValidationError(f"Invalid {parameter_name}: cannot be empty or whitespace") + + return id_value.strip() + + +def validate_slug_parameter(slug: str) -> str: + """Validate slug parameter format.""" + if not slug or not isinstance(slug, str): + raise ValidationError("Invalid slug: must be a non-empty string") + + slug = slug.strip() + if not slug: + raise ValidationError("Invalid slug: cannot be empty or whitespace") + + # Basic slug validation (alphanumeric, hyphens, underscores) + if not re.match(r'^[a-zA-Z0-9-_]+$', slug): + raise ValidationError("Invalid slug: must contain only alphanumeric characters, hyphens, and underscores") + + return slug \ No newline at end of file diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index bc0acc4..0000000 --- a/src/index.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Ghost MCP Server Entry Point - * - * This is the main entry point for the Ghost MCP server providing complete - * Ghost CMS functionality through the Model Context Protocol. - */ - -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; - -// TODO: Phase 1 - Import configuration and authentication -// import { loadConfig } from './utils/config.js'; -// import { GhostClient } from './ghost-client.js'; - -// TODO: Phase 2 - Import Content API tools -// import { registerContentTools } from './tools/content/index.js'; - -// TODO: Phase 3 - Import Admin API tools -// import { registerAdminTools } from './tools/admin/index.js'; - -/** - * Ghost MCP Server - * - * Implements 44+ MCP tools covering all Ghost REST API endpoints: - * - 13 Content API tools (read-only) - * - 31+ Admin API tools (read/write) - */ -class GhostMCPServer { - private server: Server; - - constructor() { - this.server = new Server( - { - name: 'ghost-mcp', - version: '0.1.0', - }, - { - capabilities: { - tools: {}, - }, - } - ); - - this.setupHandlers(); - } - - private setupHandlers(): void { - // List available tools - this.server.setRequestHandler(ListToolsRequestSchema, async () => { - // TODO: Phase 1 - Return basic tool list - // TODO: Phase 2 - Add Content API tools - // TODO: Phase 3 - Add Admin API tools - return { - tools: [ - { - name: 'ghost_status', - description: 'Check Ghost MCP server status and configuration', - inputSchema: { - type: 'object', - properties: {}, - }, - }, - ], - }; - }); - - // Handle tool calls - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - switch (name) { - case 'ghost_status': - return { - content: [ - { - type: 'text', - text: 'Ghost MCP Server v0.1.0 - Phase 0 Complete\nStatus: Development Mode\nAPI Integration: Pending API Keys', - }, - ], - }; - - default: - throw new Error(`Unknown tool: ${name}`); - } - }); - } - - async start(): Promise { - const transport = new StdioServerTransport(); - await this.server.connect(transport); - - // TODO: Phase 1 - Initialize configuration and logging - // TODO: Phase 1 - Setup Ghost API authentication - // TODO: Phase 2 - Register Content API tools - // TODO: Phase 3 - Register Admin API tools - - console.error('Ghost MCP Server started successfully'); - } -} - -// Start the server -if (import.meta.url === `file://${process.argv[1]}`) { - const server = new GhostMCPServer(); - server.start().catch((error) => { - console.error('Failed to start Ghost MCP Server:', error); - process.exit(1); - }); -} - -export { GhostMCPServer }; \ No newline at end of file diff --git a/src/tools/admin/media.ts b/src/tools/admin/media.ts deleted file mode 100644 index 1a56af2..0000000 --- a/src/tools/admin/media.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Ghost Admin API - Media Tools - * - * MCP tools for media upload and management via Admin API. - * Implements tools: ghost_admin_upload_image, ghost_admin_upload_media - */ - -// TODO: Phase 4 - Implement Admin API media tools \ No newline at end of file diff --git a/src/tools/admin/members.ts b/src/tools/admin/members.ts deleted file mode 100644 index 9d6e9e7..0000000 --- a/src/tools/admin/members.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Ghost Admin API - Members Tools - * - * MCP tools for member management via Admin API. - * Implements tools: ghost_admin_list_members, ghost_admin_get_member, - * ghost_admin_create_member, ghost_admin_update_member - */ - -// TODO: Phase 4 - Implement Admin API members tools \ No newline at end of file diff --git a/src/tools/admin/pages.ts b/src/tools/admin/pages.ts deleted file mode 100644 index cfc46f7..0000000 --- a/src/tools/admin/pages.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Ghost Admin API - Pages Tools - * - * MCP tools for full CRUD access to Ghost pages via Admin API. - * Implements tools: ghost_admin_list_pages, ghost_admin_get_page, ghost_admin_create_page, - * ghost_admin_update_page, ghost_admin_copy_page, ghost_admin_delete_page - */ - -// TODO: Phase 3 - Implement Admin API pages tools (CRUD operations) \ No newline at end of file diff --git a/src/tools/admin/posts.ts b/src/tools/admin/posts.ts deleted file mode 100644 index 400fa9f..0000000 --- a/src/tools/admin/posts.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Ghost Admin API - Posts Tools - * - * MCP tools for full CRUD access to Ghost posts via Admin API. - * Implements tools: ghost_admin_list_posts, ghost_admin_get_post, ghost_admin_create_post, - * ghost_admin_update_post, ghost_admin_copy_post, ghost_admin_delete_post - */ - -// TODO: Phase 3 - Implement Admin API posts tools (CRUD operations) \ No newline at end of file diff --git a/src/tools/admin/tags.ts b/src/tools/admin/tags.ts deleted file mode 100644 index 0785fae..0000000 --- a/src/tools/admin/tags.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Ghost Admin API - Tags Tools - * - * MCP tools for full CRUD access to Ghost tags via Admin API. - * Implements tools: ghost_admin_list_tags, ghost_admin_get_tag, ghost_admin_create_tag, - * ghost_admin_update_tag, ghost_admin_delete_tag - */ - -// TODO: Phase 3 - Implement Admin API tags tools (CRUD operations) \ No newline at end of file diff --git a/src/tools/admin/tiers.ts b/src/tools/admin/tiers.ts deleted file mode 100644 index 2bd059b..0000000 --- a/src/tools/admin/tiers.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Ghost Admin API - Tiers Tools - * - * MCP tools for membership tier management via Admin API. - * Implements tools: ghost_admin_list_tiers, ghost_admin_get_tier, - * ghost_admin_create_tier, ghost_admin_update_tier - */ - -// TODO: Phase 4 - Implement Admin API tiers tools \ No newline at end of file diff --git a/src/tools/admin/webhooks.ts b/src/tools/admin/webhooks.ts deleted file mode 100644 index 9d26269..0000000 --- a/src/tools/admin/webhooks.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Ghost Admin API - Webhooks Tools - * - * MCP tools for webhook management via Admin API. - * Implements tools: ghost_admin_list_webhooks, ghost_admin_create_webhook, - * ghost_admin_update_webhook, ghost_admin_delete_webhook - */ - -// TODO: Phase 4 - Implement Admin API webhooks tools \ No newline at end of file diff --git a/src/tools/content/authors.ts b/src/tools/content/authors.ts deleted file mode 100644 index 65fa693..0000000 --- a/src/tools/content/authors.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Ghost Content API - Authors Tools - * - * MCP tools for read-only access to Ghost authors via Content API. - * Implements tools: ghost_list_authors, ghost_get_author_by_id, ghost_get_author_by_slug - */ - -// TODO: Phase 2 - Implement Content API authors tools \ No newline at end of file diff --git a/src/tools/content/pages.ts b/src/tools/content/pages.ts deleted file mode 100644 index 833e34a..0000000 --- a/src/tools/content/pages.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Ghost Content API - Pages Tools - * - * MCP tools for read-only access to Ghost pages via Content API. - * Implements tools: ghost_list_pages, ghost_get_page_by_id, ghost_get_page_by_slug - */ - -// TODO: Phase 2 - Implement Content API pages tools \ No newline at end of file diff --git a/src/tools/content/posts.ts b/src/tools/content/posts.ts deleted file mode 100644 index e23d529..0000000 --- a/src/tools/content/posts.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Ghost Content API - Posts Tools - * - * MCP tools for read-only access to Ghost posts via Content API. - * Implements tools: ghost_list_posts, ghost_get_post_by_id, ghost_get_post_by_slug - */ - -// TODO: Phase 2 - Implement Content API posts tools -// - ghost_list_posts -// - ghost_get_post_by_id -// - ghost_get_post_by_slug - -export const postsContentTools = [ - { - name: 'ghost_list_posts', - description: 'List all published posts from Ghost Content API', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Number of posts to return (max 50)' }, - page: { type: 'number', description: 'Page number for pagination' }, - filter: { type: 'string', description: 'Filter posts using Ghost filter syntax' }, - include: { type: 'string', description: 'Include related resources (tags,authors)' }, - fields: { type: 'string', description: 'Comma-separated list of fields to return' }, - }, - }, - }, - // TODO: Phase 2 - Add other posts tools -]; - -// TODO: Phase 2 - Implement tool handlers \ No newline at end of file diff --git a/src/tools/content/settings.ts b/src/tools/content/settings.ts deleted file mode 100644 index e948dbb..0000000 --- a/src/tools/content/settings.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Ghost Content API - Settings and Other Tools - * - * MCP tools for Ghost settings and tiers via Content API. - * Implements tools: ghost_list_tiers, ghost_get_settings - */ - -// TODO: Phase 2 - Implement Content API settings and tiers tools \ No newline at end of file diff --git a/src/tools/content/tags.ts b/src/tools/content/tags.ts deleted file mode 100644 index 8b83f19..0000000 --- a/src/tools/content/tags.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Ghost Content API - Tags Tools - * - * MCP tools for read-only access to Ghost tags via Content API. - * Implements tools: ghost_list_tags, ghost_get_tag_by_id, ghost_get_tag_by_slug - */ - -// TODO: Phase 2 - Implement Content API tags tools \ No newline at end of file diff --git a/src/types/ghost.ts b/src/types/ghost.ts deleted file mode 100644 index 3e1f62a..0000000 --- a/src/types/ghost.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Ghost API Response Types - * - * TypeScript interfaces for Ghost API responses and data structures. - * Based on research in contracts/ documentation. - */ - -// TODO: Phase 1 - Define Ghost API response types -// Based on contracts/api-endpoints.md and contracts/content-formats.md - -export interface GhostPost { - id: string; - title: string; - slug: string; - html?: string; - lexical?: string; - mobiledoc?: string; - feature_image?: string; - featured: boolean; - status: 'draft' | 'published' | 'scheduled'; - visibility: 'public' | 'members' | 'paid'; - created_at: string; - updated_at: string; - published_at?: string; - custom_excerpt?: string; - codeinjection_head?: string; - codeinjection_foot?: string; - og_image?: string; - og_title?: string; - og_description?: string; - twitter_image?: string; - twitter_title?: string; - twitter_description?: string; - meta_title?: string; - meta_description?: string; - email_subject?: string; - url: string; - excerpt: string; - reading_time: number; - page: boolean; -} - -export interface GhostPage extends Omit { - page: true; -} - -export interface GhostTag { - id: string; - name: string; - slug: string; - description?: string; - feature_image?: string; - visibility: 'public' | 'internal'; - og_image?: string; - og_title?: string; - og_description?: string; - twitter_image?: string; - twitter_title?: string; - twitter_description?: string; - meta_title?: string; - meta_description?: string; - codeinjection_head?: string; - codeinjection_foot?: string; - canonical_url?: string; - accent_color?: string; - url: string; - count: { - posts: number; - }; -} - -export interface GhostAuthor { - id: string; - name: string; - slug: string; - email?: string; - profile_image?: string; - cover_image?: string; - bio?: string; - website?: string; - location?: string; - facebook?: string; - twitter?: string; - meta_title?: string; - meta_description?: string; - url: string; - count: { - posts: number; - }; -} - -export interface GhostMember { - id: string; - uuid: string; - email: string; - name?: string; - note?: string; - subscribed: boolean; - created_at: string; - updated_at: string; - labels: GhostLabel[]; - subscriptions: GhostSubscription[]; - avatar_image?: string; - comped: boolean; - email_count: number; - email_opened_count: number; - email_open_rate?: number; -} - -export interface GhostLabel { - id: string; - name: string; - slug: string; - created_at: string; - updated_at: string; -} - -export interface GhostSubscription { - id: string; - member_id: string; - tier_id: string; - status: string; - created_at: string; - updated_at: string; -} - -export interface GhostTier { - id: string; - name: string; - slug: string; - description?: string; - active: boolean; - type: 'free' | 'paid'; - welcome_page_url?: string; - created_at: string; - updated_at: string; - visibility: 'public' | 'none'; - trial_days: number; - currency?: string; - monthly_price?: number; - yearly_price?: number; - benefits: string[]; -} - -export interface GhostSettings { - title: string; - description: string; - logo?: string; - icon?: string; - accent_color?: string; - cover_image?: string; - facebook?: string; - twitter?: string; - lang: string; - timezone: string; - codeinjection_head?: string; - codeinjection_foot?: string; - navigation: GhostNavigation[]; - secondary_navigation: GhostNavigation[]; - meta_title?: string; - meta_description?: string; - og_image?: string; - og_title?: string; - og_description?: string; - twitter_image?: string; - twitter_title?: string; - twitter_description?: string; - url: string; -} - -export interface GhostNavigation { - label: string; - url: string; -} - -export interface GhostWebhook { - id: string; - event: string; - target_url: string; - name?: string; - secret?: string; - api_version: string; - integration_id: string; - status: 'available' | 'error'; - last_triggered_at?: string; - last_triggered_status?: string; - last_triggered_error?: string; - created_at: string; - updated_at: string; -} - -// API Response wrappers -export interface GhostApiResponse { - [key: string]: T[]; - meta?: { - pagination?: { - page: number; - limit: number; - pages: number; - total: number; - next?: number; - prev?: number; - }; - }; -} - -export interface GhostApiSingleResponse { - [key: string]: T[]; -} - -// Error types -export interface GhostApiError { - message: string; - context?: string; - type: string; - details?: string; - property?: string; - help?: string; - code?: string; - id: string; -} - -export interface GhostApiErrorResponse { - errors: GhostApiError[]; -} \ No newline at end of file diff --git a/src/types/mcp.ts b/src/types/mcp.ts deleted file mode 100644 index 00cf660..0000000 --- a/src/types/mcp.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * MCP-Specific Types - * - * TypeScript interfaces for MCP tool definitions and responses. - */ - -// TODO: Phase 1 - Define MCP-specific types - -export interface MCPToolParameter { - type: string; - description?: string; - required?: boolean; - enum?: string[]; - properties?: Record; -} - -export interface MCPToolDefinition { - name: string; - description: string; - inputSchema: { - type: 'object'; - properties: Record; - required?: string[]; - }; -} - -export interface MCPToolResponse { - content: Array<{ - type: 'text' | 'image' | 'resource'; - text?: string; - data?: string; - url?: string; - mimeType?: string; - }>; - isError?: boolean; -} - -export interface MCPToolRequest { - name: string; - arguments: Record; -} - -// MCP Error types -export interface MCPError { - code: number; - message: string; - data?: unknown; -} - -// Configuration types -export interface MCPServerConfig { - ghost: { - url: string; - contentApiKey?: string; - adminApiKey?: string; - version: string; - mode: 'readonly' | 'readwrite' | 'auto'; - }; - logging: { - level: 'debug' | 'info' | 'warn' | 'error'; - structured: boolean; - }; -} \ No newline at end of file diff --git a/src/utils/errors.ts b/src/utils/errors.ts deleted file mode 100644 index a162719..0000000 --- a/src/utils/errors.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Error Handling Utilities - * - * Error classes, mapping functions, and logging utilities. - * Implements error handling strategy from contracts/error-responses.md - */ - -import { v4 as uuidv4 } from 'uuid'; -import { GhostApiError, GhostApiErrorResponse } from '../types/ghost.js'; -import { MCPError } from '../types/mcp.js'; - -// TODO: Phase 1 - Implement comprehensive error handling -// Based on contracts/error-responses.md - -export enum ErrorCategory { - NETWORK = 'NETWORK', - AUTHENTICATION = 'AUTHENTICATION', - GHOST_API = 'GHOST_API', - MCP_PROTOCOL = 'MCP_PROTOCOL', - FILE_UPLOAD = 'FILE_UPLOAD', - VALIDATION = 'VALIDATION', -} - -export class GhostMCPError extends Error { - public readonly id: string; - public readonly category: ErrorCategory; - public readonly code?: string; - public readonly context?: string; - public readonly requestId?: string; - - constructor( - message: string, - category: ErrorCategory, - code?: string, - context?: string, - requestId?: string - ) { - super(message); - this.name = 'GhostMCPError'; - this.id = uuidv4(); - this.category = category; - this.code = code; - this.context = context; - this.requestId = requestId; - } -} - -export class NetworkError extends GhostMCPError { - constructor(message: string, context?: string, requestId?: string) { - super(message, ErrorCategory.NETWORK, 'NETWORK_ERROR', context, requestId); - this.name = 'NetworkError'; - } -} - -export class AuthenticationError extends GhostMCPError { - constructor(message: string, context?: string, requestId?: string) { - super(message, ErrorCategory.AUTHENTICATION, 'AUTH_ERROR', context, requestId); - this.name = 'AuthenticationError'; - } -} - -export class GhostApiError extends GhostMCPError { - constructor(message: string, code?: string, context?: string, requestId?: string) { - super(message, ErrorCategory.GHOST_API, code, context, requestId); - this.name = 'GhostApiError'; - } -} - -export class ValidationError extends GhostMCPError { - constructor(message: string, context?: string, requestId?: string) { - super(message, ErrorCategory.VALIDATION, 'VALIDATION_ERROR', context, requestId); - this.name = 'ValidationError'; - } -} - -// Error mapping functions -export function mapGhostErrorToMCP(ghostError: GhostApiErrorResponse, requestId?: string): MCPError { - // TODO: Phase 1 - Implement Ghost API error to MCP error mapping - const firstError = ghostError.errors[0]; - return { - code: -32603, // Internal error - message: firstError?.message || 'Unknown Ghost API error', - data: { - type: firstError?.type, - context: firstError?.context, - requestId, - }, - }; -} - -export function createMCPError(error: Error, requestId?: string): MCPError { - // TODO: Phase 1 - Create MCP error from any error type - if (error instanceof GhostMCPError) { - return { - code: -32603, - message: error.message, - data: { - category: error.category, - code: error.code, - context: error.context, - requestId: error.requestId || requestId, - }, - }; - } - - return { - code: -32603, - message: error.message || 'Internal server error', - data: { requestId }, - }; -} - -// Retry logic utilities -export interface RetryConfig { - maxRetries: number; - baseDelay: number; - maxDelay: number; - exponentialBase: number; -} - -export const defaultRetryConfig: RetryConfig = { - maxRetries: 3, - baseDelay: 1000, - maxDelay: 10000, - exponentialBase: 2, -}; - -export async function withRetry( - operation: () => Promise, - config: Partial = {}, - requestId?: string -): Promise { - // TODO: Phase 1 - Implement retry logic with exponential backoff - const finalConfig = { ...defaultRetryConfig, ...config }; - - for (let attempt = 0; attempt <= finalConfig.maxRetries; attempt++) { - try { - return await operation(); - } catch (error) { - if (attempt === finalConfig.maxRetries) { - throw error; - } - - // Calculate delay with exponential backoff - const delay = Math.min( - finalConfig.baseDelay * Math.pow(finalConfig.exponentialBase, attempt), - finalConfig.maxDelay - ); - - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - - throw new Error('Retry logic failed unexpectedly'); -} \ No newline at end of file diff --git a/src/utils/validation.ts b/src/utils/validation.ts deleted file mode 100644 index 181c238..0000000 --- a/src/utils/validation.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Parameter Validation Utilities - * - * Zod schemas and validation functions for MCP tool parameters. - */ - -import { z } from 'zod'; - -// TODO: Phase 1 - Implement parameter validation schemas -// Based on contracts/ghost-filtering.md and MCP tool specifications - -// Common parameter schemas -export const limitSchema = z.number().min(1).max(50).optional(); -export const pageSchema = z.number().min(1).optional(); -export const filterSchema = z.string().optional(); -export const includeSchema = z.string().optional(); -export const fieldsSchema = z.string().optional(); - -// ID and slug schemas -export const idSchema = z.string().min(1); -export const slugSchema = z.string().min(1); - -// Content schemas -export const titleSchema = z.string().min(1); -export const htmlSchema = z.string().optional(); -export const lexicalSchema = z.string().optional(); -export const statusSchema = z.enum(['draft', 'published', 'scheduled']).optional(); - -// Validation functions -export function validatePaginationParams(params: unknown): { limit?: number; page?: number } { - // TODO: Phase 1 - Implement validation - return params as { limit?: number; page?: number }; -} - -export function validateFilterSyntax(filter: string): boolean { - // TODO: Phase 1 - Implement Ghost filter syntax validation - // Based on contracts/ghost-filtering.md - return true; -} - -export function validateContentFormat(content: unknown): 'lexical' | 'html' | 'mobiledoc' | 'unknown' { - // TODO: Phase 1 - Implement content format detection - // Based on contracts/content-formats.md - return 'unknown'; -} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index feb3482..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Node", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "allowImportingTsExtensions": false, - "resolveJsonModule": true, - "declaration": true, - "outDir": "./dist", - "rootDir": "./src", - "removeComments": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "exactOptionalPropertyTypes": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "allowUnusedLabels": false, - "allowUnreachableCode": false, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "sourceMap": true, - "incremental": true, - "tsBuildInfoFile": "./dist/.tsbuildinfo" - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist", - "**/*.test.ts", - "**/*.spec.ts" - ], - "ts-node": { - "esm": true, - "experimentalSpecifierResolution": "node" - } -} \ No newline at end of file