Skip to main content
Glama
blogger-mcp
by tutul

blogger-mcp

An MCP server for Blogger, built on the Blogger API v3.

It gives an MCP client — Claude Code, Claude Desktop, or anything else that speaks the protocol — a set of tools for working with a Blogger blog: list and search posts, create drafts, edit them, publish or schedule them, take them back down, manage static pages, and moderate comments. The intended use is turning documents you already have into blog posts without leaving the tool you wrote them in.

It is Python, it runs locally over stdio, and it authorizes as you through a normal Google OAuth consent flow. There is no hosted component and nothing leaves your machine except calls to Google's API.

Status and maintenance

Provided as-is, with no promise of support. It is published because the code works and because several of the Blogger API's undocumented behaviours (listed below) took real effort to find and are worth writing down.

Concretely, that means:

  • No commitment to respond to issues or pull requests, and no release schedule.

  • No commitment to keep up with changes to the Blogger API. It is a stable, long-lived API, but if Google changes something this may quietly break.

  • Bug reports are welcome and may sit unanswered. Forking is entirely fine — the MIT license is there for exactly that.

If you need something dependable for production, read the code first and plan to maintain your own copy.

Related MCP server: Blogger MCP Server

What it can and cannot do

The Blogger API v3 is the ceiling here, and it has real gaps. Worth knowing before you build a workflow on top of this.

Supported

  • Posts: list / get / search / create / update / publish / schedule / revert to draft / delete

  • Pages (static pages like About): list / get / create / update / delete

  • Comments: list and moderate (approve, mark spam, remove content, delete)

  • Blogs: read only — list the account's blogs, look one up by id or URL

  • Pageviews: coarse 7-day / 30-day / all-time counts

Not supported — by the API itself, not by this server

  • No image upload. There is no media endpoint. Post bodies are HTML, and every <img> must already point at a publicly reachable absolute URL. Host images elsewhere first (Drive, GitHub, an image host) and paste in the URL.

  • No creating, renaming or deleting a blog. The blogs resource is read-only.

  • No theme, template, layout, widget or blog-settings editing.

  • No label management. Labels are just strings on a post; renaming a label across a blog means patching every post that carries it.

  • No posting or replying to comments. Moderation only.

  • No Markdown. Content is raw HTML (this server can also wrap plain text).

  • No per-post metadata of your own. The customMetaData field exists in the API schema and is documented as "The JSON meta-data for the Post", but Blogger discards writes to it — the value is already null in the insert response. There is no place to record which local file produced a post.

Undocumented API behaviour worth knowing

These were found by running against a live blog. None of them appear in the reference documentation, and three fail silently:

  • fetchBody=False on a write destroys the post body. It reads as a response-shaping option, and is one on posts.get. On posts.insert and posts.patch, Blogger applies it to the write and blanks the content. No error is returned. This server never sends it on a write.

  • customMetaData is a no-op. Writes are discarded silently; the value comes back null in the insert response itself.

  • Whether a delete goes to the trash depends on the post's state, not just on useTrash. Deleting a published or scheduled post with useTrash=true moves it to SOFT_TRASHED, where posts.revert restores it as a draft with its content. Deleting a draft removes it for good even with useTrash=true. Deleting something already in the trash removes it for good — so delete is not idempotent, and retrying one is not safe. Pages behave the same way.

  • blogs.listByUser rejects view=ADMIN with HTTP 400, even though the API's own discovery document lists ADMIN as a valid value for that parameter. Every other resource accepts it.

  • posts.search ignores whose blog it is. It is the one read that takes no view parameter, so it will search any public blog: a mistyped blog_id that happens to belong to someone else returns their posts, with no error. Every other read, given view=ADMIN, rejects that with HTTP 403. This server checks admin access before searching for that reason.

  • Enum values are UPPERCASE. orderBy=PUBLISHED, sortOption=DESCENDING, comment status=PENDING. The HTML reference page documents several of them in lowercase, which the client library rejects outright.

  • Post creation is rate limited fairly aggressively. A burst of a dozen creates returns HTTP 429 rateLimitExceeded; it recovers within a couple of minutes. There is no automatic retry in this server.

The git history contains the isolation tests for each of these.

Install

Requires Python 3.11 or newer. 3.10 is not supported: it reached end of life in October 2026, and Google's client libraries have stopped releasing for it.

uv venv
uv pip install -e .

Setup

1. Google Cloud project (once, ~5 minutes)

  1. Create a project at https://console.cloud.google.com/.

  2. Enable the Blogger API v3 for it (APIs & Services → Library → "Blogger API v3" → Enable).

  3. Configure the OAuth consent screen:

    • User type External

    • Add the scope https://www.googleapis.com/auth/blogger

    • Add your own Google account as a test user

  4. APIs & Services → Credentials → Create credentials → OAuth client ID → application type Desktop app. Download the JSON.

  5. Save it as ~/.config/blogger-mcp/client_secret.json (or point the BLOGGER_MCP_CLIENT_SECRETS environment variable at wherever you put it).

Expect to re-authorize about once a week. While an external app's publishing status is Testing, Google issues refresh tokens that expire after 7 days. When that happens the server returns an error saying so; run blogger-mcp-auth again. See below for why leaving Testing is harder than it looks.

About the 7-day expiry

Testing status also means only accounts listed as test users on the consent screen can authorize at all (up to 100 of them), so add anyone who needs access there.

Escaping the 7-day expiry means publishing the consent screen to Production, and Google requires an app homepage and a privacy policy to do that. Those have to be on a domain you own and have verified in Search Console — Google explicitly rules out platforms where you cannot prove you own the subdomain, which excludes a Blogger blog, GitHub Pages, Google Sites and similar. The Blogger scope is broad enough to count as sensitive, so a published app also shows a "Google hasn't verified this app" warning until it passes review, which wants a demo video and takes days to weeks.

Google's documentation ties the 7-day expiry specifically to Testing status, which implies publishing alone is enough. Reports from developers of apps that are in Production but unverified are mixed, and this project has not tested it. Do not assume publishing will fix it.

For a personal setup, re-authorizing weekly is usually less work than owning a domain to satisfy a review process. If you do want to go through with it, you need a real domain, a page describing the app, and a privacy policy on that same domain.

2. Authorize (once)

blogger-mcp-auth

This opens a browser, asks you to sign in and consent, then caches the token at ~/.config/blogger-mcp/token.json (mode 0600). It finishes by printing the blogs on the account together with their blog ids — you will need one of those ids for every call.

Re-run it any time you need to re-authorize or switch account.

The consent flow lives in this separate command rather than in the server on purpose: opening a browser from inside the server would block the stdio transport, and the client would just see a server that never responds.

3. Register the server with your MCP client

Claude Code:

claude mcp add blogger -- /path/to/blogger/.venv/bin/blogger-mcp

Or in a client config file (Claude Desktop's claude_desktop_config.json):

{
  "mcpServers": {
    "blogger": {
      "command": "/path/to/blogger/.venv/bin/blogger-mcp"
    }
  }
}

The server never opens a browser. If the cached token is missing or broken it returns an error telling you to run blogger-mcp-auth.

Configuration

All optional:

Variable

Default

Purpose

BLOGGER_MCP_HOME

~/.config/blogger-mcp

Directory for credentials

BLOGGER_MCP_CLIENT_SECRETS

$BLOGGER_MCP_HOME/client_secret.json

OAuth client JSON

BLOGGER_MCP_TOKEN

$BLOGGER_MCP_HOME/token.json

Cached token

BLOGGER_MCP_READONLY

unset

Set to 1 to request the read-only scope

To manage two Google accounts, run two server instances with different BLOGGER_MCP_TOKEN paths.

Design decisions

blog_id is always an explicit argument. The OAuth token decides which account the server acts as; blog_id decides which blog a call touches. The server never defaults or remembers a blog — the caller is expected to know which blog it is operating on. list_blogs exists to discover ids, not as a required first step.

Writes are safe by default. create_post and create_page produce drafts. Publishing is always a separate, explicit publish_post call. The intent is that "organize a document" and "put it on the public internet" are never the same action — which matters more than usual when the caller is a language model. Deletes report what actually happened — delete_post reads the post back afterwards and says whether it went to the trash (restorable with revert_post) or is gone for good, because that depends on the post's state at the time. revert_post remains the way to take a post down without risking anything.

Errors carry their remediation. Anticipated failures are raised as ToolError so the message survives to the caller. Requests are built inside the error handler as well as executed there, because the client library validates enum arguments while building — a mistake there would otherwise surface as an opaque "error executing tool".

Agent guidance (the Skill)

skills/blogger-publishing/SKILL.md is an Agent Skill describing the intended workflows: draft → review → publish, safely updating an existing post, and what to do about the image limitation. The MCP server gives an agent the tools; the Skill tells it how to use them well.

The Skill is only useful alongside the MCP server — install both in whichever client you use.

Claude Code — copy or symlink the folder into your skills directory:

ln -s "$PWD/skills/blogger-publishing" ~/.claude/skills/blogger-publishing

Claude Desktop / claude.ai — build the ZIP and upload it:

./build-skill.sh          # writes dist/blogger-publishing.zip

Then in Claude: Settings → Capabilities → enable code execution (skills require it), then Customize → Skills → + and upload dist/blogger-publishing.zip.

The ZIP deliberately contains blogger-publishing/ as its root entry, which is the structure the uploader expects. Note that description: in the frontmatter is capped at 200 characters — keep it under that if you edit it, or the upload is rejected.

License

MIT — see LICENSE.

Available Tools

18 tools
create_pageA

Create a static page. Creates a DRAFT by default.

Same content rules as create_post: raw HTML or plain text, no Markdown, no image upload.

Inputs: title (required), blog_id (required), content (required), draft, +1 more. Returns: object

create_postA

Create a post. Creates a DRAFT by default.

Publishing is a separate, explicit step: call publish_post afterwards. Only set draft=False when a human has asked for immediate publication.

content_format="html" sends content through untouched — Blogger stores post bodies as raw HTML. content_format="text" escapes the text and wraps it in paragraphs. There is no Markdown support and no image upload: any in the HTML must already point at a public absolute URL.

Blogger cannot store per-post metadata of your own (its customMetaData field silently discards writes), so there is no way to record which local file a post came from. Track that outside this server if you need it.

Inputs: title (required), blog_id (required), content (required), draft, +3 more. Returns: object

delete_pageB
DestructiveIdempotent

Delete a static page. Treat this as irreversible (see delete_post).

Inputs: blog_id (required), page_id (required), permanent. Returns: object

delete_postA
DestructiveIdempotent

Delete a post. Treat this as irreversible and confirm with a human first.

useTrash is sent unless permanent=True, but it buys nothing you can act on: a deleted post returns 404 and never shows up in a SOFT_TRASHED listing (verified against the live API). Whether Blogger's own web UI can still restore it is not something the API can tell you.

If the goal is to take a post off the public blog, use revert_post instead — it keeps the content and is trivially undone by publishing again.

Inputs: blog_id (required), post_id (required), permanent. Returns: object

get_blogA
Read-only

Look up one blog by its id, or by its public URL.

Pass exactly one of blog_id or url (e.g. "https://example.blogspot.com/").

Inputs: url, blog_id. Returns: object

get_pageA
Read-only

Fetch one static page in full, including its HTML body.

Inputs: blog_id (required), page_id (required). Returns: object

get_pageviewsA
Read-only

Blog-level pageview counts.

Blogger only exposes three coarse buckets and the numbers are approximate — treat them as a rough signal, not analytics. There is no per-post breakdown.

Inputs: blog_id (required), time_range. Returns: object

get_postA
Read-only

Fetch one post in full, including its HTML body and sync metadata.

Always call this before update_post so you are editing against the current content rather than a stale copy.

Inputs: blog_id (required), post_id (required). Returns: object

list_blogsA
Read-only

List the blogs the authorized Google account can access.

Use this to discover a blog_id when you do not already have one. Every other tool requires the caller to pass blog_id explicitly.

Inputs: no named parameters. Returns: object

list_commentsA
Read-only

List comments on one post, or across the whole blog if post_id is omitted.

Use status="PENDING" to find comments waiting for moderation.

Inputs: blog_id (required), status, post_id, page_token, +1 more. Returns: object

list_pagesA
Read-only

List the static pages of a blog (About, Contact, ...).

Pages differ from posts: no labels, no dates in the archive, and they do not appear in the blog's post feed.

Inputs: blog_id (required), status, page_token, max_results, +1 more. Returns: object

list_postsA
Read-only

List posts in a blog.

status filters to LIVE / DRAFT / SCHEDULED / SOFT_TRASHED (omit for all). labels is a comma-separated list. start_date/end_date are RFC 3339 timestamps. Bodies are omitted by default — pass fetch_bodies=True only when you actually need the HTML, since post bodies are large.

Inputs: blog_id (required), sort, labels, status, +6 more. Returns: object

moderate_commentA
DestructiveIdempotent

Moderate one comment.

  • approve — publish a pending comment (also un-spams it)

  • spam — mark as spam and hide it

  • remove_content — blank the text but leave the "comment removed" stub

  • delete — remove it entirely; this one is NOT recoverable, so confirm with the human first

The Blogger API cannot create comments, so replying to a commenter has to be done by hand in the Blogger UI.

Inputs: action (required), blog_id (required), post_id (required), comment_id (required). Returns: object

publish_postA
Idempotent

Publish a draft post, making it publicly visible.

This is the irreversible-feeling step — confirm with the human before calling it unless they already asked for publication.

Pass publish_date as an RFC 3339 timestamp (e.g. "2026-09-10T09:00:00Z") to schedule publication for a future time instead of publishing now; the post then sits in SCHEDULED status until then.

Inputs: blog_id (required), post_id (required), publish_date. Returns: object

revert_postA
Idempotent

Take a live or scheduled post back to DRAFT, removing it from the blog.

The content is kept; only its visibility changes. This is the safe way to pull a post down.

Inputs: blog_id (required), post_id (required). Returns: object

search_postsA
Read-only

Full-text search over the published posts of a blog.

Note this only covers live posts; drafts are not indexed. To find a draft, use list_posts with status="DRAFT".

Inputs: query (required), blog_id (required), fetch_bodies. Returns: object

update_pageB
DestructiveIdempotent

Update a static page, sending only the fields you pass (PATCH semantics).

Set publish=True to publish a draft page as part of the edit.

Inputs: blog_id (required), page_id (required), title, content, +2 more. Returns: object

update_postA
DestructiveIdempotent

Update a post, sending only the fields you pass (PATCH semantics).

Omitted fields are left untouched, so you can change just the title without resending the body. Two things to know:

  • labels REPLACES the whole label list. To add one label, read the current list with get_post first and pass the full new list.

  • publish=False (the default) does not unpublish anything — a live post stays live. It only means "do not publish a draft as part of this edit". Use revert_post to take a live post back to draft.

Inputs: blog_id (required), post_id (required), title, labels, +4 more. Returns: object

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4/5.0

    Scored across 18 tools

    Disambiguation5/5

    Each tool targets a distinct resource and action: blogs, posts, pages, comments, and pageviews are cleanly separated. The post lifecycle tools (create/update/publish/revert/delete) are especially well-delineated, with descriptions clarifying the overlap between revert and delete.

    Naming Consistency5/5

    Tool names follow a consistent verb_noun pattern: list_*, get_*, create_*, update_*, delete_*, plus a few action-specific verbs like publish_post, revert_post, and moderate_comment. The pattern makes the set predictable and easy to navigate.

    Tool Count4/5

    18 tools is slightly above the typical sweet spot, but each tool covers a distinct Blogger resource or lifecycle step. The count is justified by the breadth of the domain: posts, pages, comments, blogs, and pageviews.

    Completeness4/5

    The post lifecycle is fully covered, and pages and comments have solid coverage. Minor gaps exist, such as no way to unpublish a page and no comment creation, but the API limitations are explicitly documented and agents can work around them.

    Maintenance

    ActivityMaintained
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables LLMs to automate Google Blogger content management by providing tools for single and batch post creation via the Blogger API. It supports secure OAuth2 authentication and allows for seamless blog integration through the Model Context Protocol.
      MIT
    • A
      license
      A
      quality
      D
      maintenance
      Enables AI assistants to interact with the Google Blogger API v3 to manage blog posts and metadata. It supports the full post lifecycle including creating, updating, publishing, and deleting content through natural language.
      10
      25 npm
      1
      MIT
    • A
      license
      A
      quality
      D
      maintenance
      A custom MCP server for interacting with Google Blogger blogs. It provides tools to list, create, edit, delete, and publish blog posts through Claude Code or Claude Desktop.
      9
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables AI models to interact with Google Blogger blogs, manage posts, labels, and retrieve blog information via API key or OAuth2.
      20 npm
      MIT