diff --git a/.gitignore b/.gitignore index 0622477..4c8c7bb 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,7 @@ Thumbs.db .pytest_cache/ .coverage htmlcov/ -.tox/ \ No newline at end of file +.tox/ + +# Claude Code +.mcp.json diff --git a/Makefile b/Makefile index ea099d1..86086b7 100644 --- a/Makefile +++ b/Makefile @@ -25,15 +25,15 @@ deps-deps-install-uv: ## Install uv package manager fi # Install the MCP server system-wide -install: ## Install the MCP server system-wide - claude mcp remove ghost-mcp -s user || true - claude mcp add ghost-mcp -s user -- \ +install-user: ## Install the MCP server system-wide + claude mcp remove ghost -s user || true + claude mcp add ghost -s user -- \ bash -c "cd $(PWD) && uv run python -m ghost_mcp.server" # Install the MCP server in the project scope only -install-local: ## Install the MCP server in the project scope only - claude mcp remove ghost-mcp || true - claude mcp add ghost-mcp -- \ +install-project: ## Install the MCP server in the project scope only + claude mcp remove -s project ghost || true + claude mcp add ghost -s project -- \ bash -c "cd $(PWD) && uv run python -m ghost_mcp.server" deps-install-python: ## Install Python dependencies using uv diff --git a/src/ghost_mcp/tools/admin/posts.py b/src/ghost_mcp/tools/admin/posts.py index 5c66f45..c39a10c 100644 --- a/src/ghost_mcp/tools/admin/posts.py +++ b/src/ghost_mcp/tools/admin/posts.py @@ -7,6 +7,15 @@ from fastmcp import FastMCP from ...client import GhostClient from ...utils.validation import validate_id_parameter +from ...utils.content_validation import ( + validate_post_title, + validate_post_content, + validate_content_format, + validate_post_status, + validate_published_at, + get_content_format_examples, +) +from ...types.errors import ValidationError def register_admin_post_tools(mcp: FastMCP) -> None: @@ -28,80 +37,259 @@ def register_admin_post_tools(mcp: FastMCP) -> None: meta_description: Optional[str] = None, ) -> str: """ - Create a new post via Ghost Admin API. + Create a new post via Ghost Admin API with comprehensive validation. + + This tool creates a new blog post with rich content support. Ghost uses Lexical + format as the primary content format, which provides better structure and + rendering than HTML. 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 + title: Post title (required, max 255 characters) + Example: "My Amazing Blog Post" + + content: Post content in specified format (optional) + - For Lexical format: JSON string with structured content + - For HTML format: Valid HTML markup + - If not provided, creates post with empty content + + content_format: Content format (default: 'lexical', recommended) + - 'lexical': JSON-based structured content (preferred) + - 'html': HTML markup (for simple content or migration) + + status: Post status (default: 'draft') + - 'draft': Saves as draft (not published) + - 'published': Publishes immediately + - 'scheduled': Schedules for future (requires published_at) + + slug: URL slug for the post (optional, auto-generated if not provided) + Example: "my-amazing-blog-post" + + excerpt: Custom excerpt/summary (optional) + Used for SEO and post previews + 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 + Featured posts appear prominently on the site + + tags: Comma-separated tag names (optional) + Example: "tutorial,javascript,web-development" + + authors: Comma-separated author names (optional) + Example: "John Doe,Jane Smith" + + published_at: Publish date for scheduled posts (optional) + ISO datetime format: "2024-01-01T10:00:00.000Z" + Required when status is 'scheduled' + + meta_title: SEO meta title (optional, max 300 characters) + Used in search results and social shares + + meta_description: SEO meta description (optional, max 500 characters) + Used in search results and social shares + + Content Format Examples: + + Lexical (Simple): + ```json + { + "root": { + "children": [ + { + "children": [ + { + "detail": 0, + "format": 0, + "mode": "normal", + "style": "", + "text": "Hello world!", + "type": "text", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "paragraph", + "version": 1 + } + ], + "direction": "ltr", + "format": "", + "indent": 0, + "type": "root", + "version": 1 + } + } + ``` + + Lexical (Rich Content): + ```json + { + "root": { + "children": [ + { + "children": [ + { + "text": "My Heading", + "type": "text", + "version": 1 + } + ], + "type": "heading", + "tag": "h1", + "version": 1 + }, + { + "children": [ + { + "text": "Paragraph with ", + "type": "text", + "version": 1 + }, + { + "text": "a link", + "type": "link", + "url": "https://example.com", + "version": 1 + } + ], + "type": "paragraph", + "version": 1 + } + ], + "type": "root", + "version": 1 + } + } + ``` + + HTML (Simple): + ```html +
Hello world!
+ ``` + + HTML (Rich Content): + ```html +Paragraph with a link.
+Hello world!
", + "html_rich": '''This is a paragraph with bold text and a link.
+code block'''
+ }
\ No newline at end of file
diff --git a/tests/e2e/template/complex-post.md b/tests/e2e/template/complex-post.md
new file mode 100644
index 0000000..f06138e
--- /dev/null
+++ b/tests/e2e/template/complex-post.md
@@ -0,0 +1,95 @@
+# Using Playwright MCP Server with Google Chrome Flatpak on Linux
+
+The Model Context Protocol (MCP) has revolutionized how AI assistants interact with external tools and services. One particularly powerful integration is the [Playwright MCP server](https://github.com/microsoft/playwright-mcp), which enables AI to control web browsers for automation tasks. This guide shows you the simplest way to get Playwright MCP working with Google Chrome on Linux using Flatpak.
+
+## The Simple Solution
+
+Instead of complex configurations, we'll use a two-step approach:
+1. Install Google Chrome from Flathub
+2. Create a symbolic link that Playwright expects
+
+## Step 1: Install Google Chrome from Flathub
+
+First, install Google Chrome using Flatpak:
+
+```bash
+flatpak install flathub com.google.Chrome
+```
+
+## Step 2: Create the Symbolic Link
+
+Playwright expects Chrome to be located at `/opt/google/chrome/chrome`. We'll create a symbolic link pointing to the Flatpak Chrome binary:
+
+```bash
+# Create the directory structure
+sudo mkdir -p /opt/google/chrome
+
+# Create the symbolic link
+sudo ln -s /var/lib/flatpak/exports/bin/com.google.Chrome /opt/google/chrome/chrome
+```
+
+## Step 3: Add to Claude Code
+
+If you're using Claude Code, you can quickly add the Playwright MCP server:
+
+```bash
+claude mcp add playwright npx @playwright/mcp@latest
+```
+
+Or manually add this configuration to your MCP settings:
+
+```json
+{
+ "mcpServers": {
+ "playwright": {
+ "command": "npx",
+ "args": [
+ "@playwright/mcp@latest"
+ ]
+ }
+ }
+}
+```
+
+## That's It!
+
+Now Playwright MCP server will automatically find and use your Flatpak Chrome installation.
+
+## Test the Setup
+
+You can test that everything works by running a simple Playwright script:
+
+```ini
+# Start Claude Code
+claude
+
+# Ask something like
+Use the playwright MCP tools to write a haiku in the https://note.thenets.org/playwright-example
+# the Chrome Browser should open
+```
+
+## Why This Works
+
+- Playwright looks for Chrome at `/opt/google/chrome/chrome` by default
+- Flatpak installs Chrome at `/var/lib/flatpak/exports/bin/com.google.Chrome`
+- The symbolic link bridges this gap without complex configuration
+- Chrome runs with all the security benefits of Flatpak sandboxing
+
+## Troubleshooting
+
+If the symbolic link doesn't work, verify the Flatpak Chrome path:
+
+```bash
+ls -la /var/lib/flatpak/exports/bin/com.google.Chrome
+```
+
+If Chrome isn't at that location, find it with:
+
+```bash
+flatpak list --app | grep Chrome
+which com.google.Chrome
+```
+
+## Conclusion
+
+This simple two-step solution eliminates the complexity typically associated with using Flatpak browsers with Playwright. By creating a symbolic link, you get the best of both worlds: the security of Flatpak and the simplicity of standard Playwright configuration.
\ No newline at end of file
diff --git a/tests/e2e/test_e2e_posts.py b/tests/e2e/test_e2e_posts.py
index dd8889c..37c3f36 100644
--- a/tests/e2e/test_e2e_posts.py
+++ b/tests/e2e/test_e2e_posts.py
@@ -148,14 +148,267 @@ class TestPostsAdminAPIE2E(BaseE2ETest):
cleanup_test_content["track_post"](post["id"])
async def test_create_post_published(self, mcp_server, test_post_data, cleanup_test_content):
- """Test creating a published post."""
- # Create published post
+ """Test creating a published post with complex content."""
+ # Complex post content based on the template in Lexical format
+ complex_title = "Using Playwright MCP Server with Google Chrome Flatpak on Linux"
+ complex_content = json.dumps({
+ "root": {
+ "children": [
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "Using Playwright MCP Server with Google Chrome Flatpak on Linux",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "heading",
+ "version": 1,
+ "tag": "h1"
+ },
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "The Model Context Protocol (MCP) has revolutionized how AI assistants interact with external tools and services. One particularly powerful integration is the ",
+ "type": "text",
+ "version": 1
+ },
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "Playwright MCP server",
+ "type": "link",
+ "url": "https://github.com/microsoft/playwright-mcp",
+ "version": 1
+ },
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": ", which enables AI to control web browsers for automation tasks.",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "paragraph",
+ "version": 1
+ },
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "The Simple Solution",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "heading",
+ "version": 1,
+ "tag": "h2"
+ },
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "Instead of complex configurations, we'll use a two-step approach:",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "paragraph",
+ "version": 1
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "Install Google Chrome from Flathub",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "listitem",
+ "version": 1,
+ "value": 1
+ },
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "Create a symbolic link that Playwright expects",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "listitem",
+ "version": 1,
+ "value": 2
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "list",
+ "version": 1,
+ "listType": "number",
+ "start": 1
+ },
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "Step 1: Install Google Chrome",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "heading",
+ "version": 1,
+ "tag": "h2"
+ },
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "First, install Google Chrome using Flatpak:",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "paragraph",
+ "version": 1
+ },
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "flatpak install flathub com.google.Chrome",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "code",
+ "version": 1,
+ "language": "bash"
+ },
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "Conclusion",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "heading",
+ "version": 1,
+ "tag": "h2"
+ },
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "This simple solution eliminates complexity and combines Flatpak security with Playwright simplicity.",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "paragraph",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "root",
+ "version": 1
+ }
+ })
+
+ # Create published post with complex content
result = await self.call_mcp_tool(
mcp_server, "create_post",
- title=test_post_data["title"],
- content=test_post_data["content"],
- content_format=test_post_data["content_format"],
- status="published"
+ title=complex_title,
+ content=complex_content,
+ content_format="lexical",
+ status="published",
+ excerpt="Learn how to set up Playwright MCP server with Chrome Flatpak on Linux using a simple two-step approach.",
+ featured=True,
+ meta_title="Playwright MCP + Chrome Flatpak Setup Guide",
+ meta_description="Simple guide to configure Playwright MCP server with Google Chrome Flatpak on Linux using symbolic links."
)
response = json.loads(result)
@@ -163,11 +416,262 @@ class TestPostsAdminAPIE2E(BaseE2ETest):
assert "posts" in response
post = response["posts"][0]
assert post["status"] == "published"
+ assert post["title"] == complex_title
+ assert post["featured"] is True
assert "published_at" in post
assert post["published_at"] is not None
+ assert post["excerpt"] == "Learn how to set up Playwright MCP server with Chrome Flatpak on Linux using a simple two-step approach."
+ assert post["meta_title"] == "Playwright MCP + Chrome Flatpak Setup Guide"
+
+ # Verify content contains key elements
+ if "lexical" in post:
+ lexical_content = post["lexical"]
+ assert "Playwright MCP" in lexical_content
+ assert "heading" in lexical_content # Has headings
+ assert "code" in lexical_content # Has code blocks
+ assert "list" in lexical_content # Has lists
# Track for cleanup
- cleanup_test_content["track_post"](post["id"])
+ post_id = post["id"]
+ cleanup_test_content["track_post"](post_id)
+
+ # Retrieve the post using Admin API to verify all metadata including status
+ retrieve_result = await self.call_mcp_tool(
+ mcp_server, "get_admin_posts",
+ filter=f"id:{post_id}"
+ )
+ retrieve_response = json.loads(retrieve_result)
+
+ assert "posts" in retrieve_response
+ assert len(retrieve_response["posts"]) == 1
+ retrieved_post = retrieve_response["posts"][0]
+
+ # Verify the post was stored correctly with all metadata
+ assert retrieved_post["title"] == complex_title
+ assert retrieved_post["status"] == "published"
+ assert retrieved_post["featured"] is True
+ assert retrieved_post["excerpt"] == "Learn how to set up Playwright MCP server with Chrome Flatpak on Linux using a simple two-step approach."
+
+ # Verify content was stored and is accessible
+ if "lexical" in retrieved_post and retrieved_post["lexical"]:
+ retrieved_lexical_content = retrieved_post["lexical"]
+ # Verify key content elements are present
+ assert "Using Playwright MCP Server" in retrieved_lexical_content
+ assert "Simple Solution" in retrieved_lexical_content
+ assert "Install Google Chrome" in retrieved_lexical_content
+ assert "flatpak install" in retrieved_lexical_content
+ else:
+ # If no lexical content, test should fail
+ assert False, "Post content was not properly stored - no Lexical content found"
+
+ async def test_create_and_verify_content_lexical(self, mcp_server, cleanup_test_content):
+ """Test creating a post with Lexical content and verifying it was stored correctly."""
+ # Create a post with specific Lexical content
+ test_title = "Content Verification Test - Lexical"
+ test_content = json.dumps({
+ "root": {
+ "children": [
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "Test Heading for Verification",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "heading",
+ "version": 1,
+ "tag": "h2"
+ },
+ {
+ "children": [
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "This is a test paragraph with ",
+ "type": "text",
+ "version": 1
+ },
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": "a test link",
+ "type": "link",
+ "url": "https://example.com/test",
+ "version": 1
+ },
+ {
+ "detail": 0,
+ "format": 0,
+ "mode": "normal",
+ "style": "",
+ "text": " for content verification.",
+ "type": "text",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "paragraph",
+ "version": 1
+ }
+ ],
+ "direction": "ltr",
+ "format": "",
+ "indent": 0,
+ "type": "root",
+ "version": 1
+ }
+ })
+
+ # Create the post
+ create_result = await self.call_mcp_tool(
+ mcp_server, "create_post",
+ title=test_title,
+ content=test_content,
+ content_format="lexical",
+ status="published"
+ )
+ create_response = json.loads(create_result)
+
+ assert "posts" in create_response
+ created_post = create_response["posts"][0]
+ post_id = created_post["id"]
+
+ # Track for cleanup
+ cleanup_test_content["track_post"](post_id)
+
+ # Retrieve the post using Admin API to verify status and content
+ retrieve_result = await self.call_mcp_tool(
+ mcp_server, "get_admin_posts",
+ filter=f"id:{post_id}"
+ )
+ retrieve_response = json.loads(retrieve_result)
+
+ assert "posts" in retrieve_response
+ assert len(retrieve_response["posts"]) == 1
+ retrieved_post = retrieve_response["posts"][0]
+
+ # Verify basic metadata
+ assert retrieved_post["title"] == test_title
+ assert retrieved_post["status"] == "published"
+
+ # Verify Lexical content integrity
+ assert "lexical" in retrieved_post
+ retrieved_lexical = json.loads(retrieved_post["lexical"])
+ original_lexical = json.loads(test_content)
+
+ # Verify structure
+ assert "root" in retrieved_lexical
+ assert retrieved_lexical["root"]["type"] == "root"
+ assert len(retrieved_lexical["root"]["children"]) == 2
+
+ # Verify heading content
+ heading = retrieved_lexical["root"]["children"][0]
+ assert heading["type"] == "heading"
+ assert heading["tag"] == "h2"
+ assert heading["children"][0]["text"] == "Test Heading for Verification"
+
+ # Verify paragraph with link
+ paragraph = retrieved_lexical["root"]["children"][1]
+ assert paragraph["type"] == "paragraph"
+ assert len(paragraph["children"]) == 3
+ assert paragraph["children"][1]["type"] == "link"
+ assert paragraph["children"][1]["url"] == "https://example.com/test"
+ assert paragraph["children"][1]["text"] == "a test link"
+
+ async def test_create_and_verify_content_html(self, mcp_server, cleanup_test_content):
+ """Test creating a post with HTML content and verifying it was stored correctly."""
+ # Create a post with HTML content
+ test_title = "Content Verification Test - HTML"
+ test_content = """This is a test paragraph with an HTML link for content verification.
+Unclosed paragraph