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