PR
私が使っているPCガジェット類

作業環境で実際に使っている・気になっている周辺機器などをまとめました

※ 一部のリンクは広告(アフィリエイト)を含みます

Claude CodeでWordPress自動投稿【MCP活用】

「AIに記事を書かせて、そのままWordPressに下書き保存できたら楽なのに」と思ったことはないですか?

今回は Claude Code で WordPress に自動投稿する仕組みを MCP(AIに外部サービスを操作させる仕組み)で作りました
投稿先はXserverで動かしている自分のブログで、届くのは下書きの状態
そのMCPサーバー自体、私がPythonを一行ずつ書いたというよりClaude Code本人に「こういうサーバーを作って」と頼んで作ってもらったんです

なので中心になるのは、Claudeにどう頼めばMCPサーバーができあがるのか、どうテストすれば任せられる状態になるのか、のあたり
すぐ動かしたい人向けに、いま動かしている全文も記事の末尾に折りたたんであります

目次

Claude CodeでWordPress自動投稿|何ができるようになるか

セットアップが終わると、「VLOOKUPの解説記事を書いて、下書きで保存して」の一言でWordPress管理画面の「投稿一覧」に下書きができあがります

「VLOOKUP関数について、初心者向けに1000字くらいで解説記事を書いて、下書きで保存しておいて」

下書きを作るだけでなく、装飾やメタの設定まで会話で済みます

  • テーマを伝えるだけで記事の下書きをWordPressに自動投稿
  • アイキャッチ画像のアップロードと設定
  • カテゴリ・タグの設定(既存から選ぶ・足りなければ新規作成)
  • メタディスクリプション(SEO用の説明文)の登録
  • テーマ独自のブロック(SWELLのステップ・FAQ・注意ボックスなど)での装飾
  • 記事ごとのカスタムCSS(SWELLの投稿別CSS欄)の設定
  • 既存記事の取得・編集も会話で完結(タイトル変更・本文追記など)
  • 下書き一覧を出してもらって、書きかけ記事の整理も簡単

もちろん公開前のプレビュー確認は管理画面で行いますが、それ以外の編集作業をブラウザでぽちぽち進めなくて済むのは思ったより楽なんです

ジャベ雄

私は下書き作成を、ぜんぶこれに寄せました

テーマ独自のブロックは「見本ページ」を読ませて覚えさせる

1つだけコツがあって、「SWELLのステップブロックで書いて」と頼むだけではClaudeはあなたのテーマの正確なブロックの形までは分かりません
理由は単純で、ブロックの中身(HTML)がテーマごとに違うから

そこで有効なのが、使いたいブロックを並べた見本用の固定ページを1枚だけ作っておく方法です

その見本ページをClaudeに読み取らせると、実際のブロックのHTMLを覚えて、同じ形を記事の中で再現できるようになります

私の場合は、ステップ・FAQ・吹き出し・各種ボックスなどを1ページにまとめた見本を作って、それをClaudeに読ませてから装飾してもらう、という流れにしています

逆に苦手なこと|任せきれない部分は人がやる

いちばんはっきりしているのが画像で、AIは記事の見た目を見ているわけではないし、必要なスクショを自分で撮ることもできません
「この記事に合うスクショを貼って」と頼んでも、返ってくるのは場所の提案まで

ローカルの画像をアップロードするにしても、メディアライブラリから選ぶにしても、「どれが適切か」の判断はAIには難しいと思います
ここは人の出番

代わりに「キャプチャのメモ」を入れてもらう

まるごと任せられないだけで、下ごしらえは頼めます

「ここにスクショを入れる」という場所と、「何を写したスクショにするか」のメモを、該当箇所に書いておいてもらう使い方です
そうすれば人は、メモ通りにスクショを撮って貼るだけ

実際この記事の画像も、先にAIが「ここにアプリケーションパスワード発行画面のスクショ」とメモを置いておいて、その場所に私が撮ったものを入れています

WordPress REST APIにないことはできない

このMCPはWordPressのREST API(記事・固定ページ・メディア・カテゴリなどを操作する窓口)を通して動いているので、そこに用意されていないこと——サイト全体に適用されるCSSやJavaScript、プラグインの設定、メニューやサイト構成の変更などは反映できません
記事1本ごとのCSSのほうは、後で触れる補助プラグインを入れれば設定できる範囲
JSも同じように開放はできますが、安全を考えるとおすすめしません

「こう変えたい」と相談すれば書き方や手順の提案はしてくれますが、実際に反映するのはテーマ・カスタマイザー・プラグイン側の作業で、MCPの守備範囲の外になります

あえて「触れる範囲」は絞っている

逆に、REST API経由で触れるメタ情報(SEOのディスクリプションなど)は、できる中でもあえて最小限に絞っています
SEOのタイトルやog:imageなども、開放すればMCPから設定できる項目
ただ自動で書き換えられる範囲が広いほど、うっかり上書きや、認証情報が漏れたときの被害も大きくなります
なので「実際に自動化したいものだけ開けておく」のが安全だと思います

具体的には、SEOプラグインのメタディスクリプションやSWELLの投稿別CSSは、標準のWordPressでは外部から書き込めません
そこを開けるための補助プラグインをClaudeに作ってもらって、mu-pluginsという特別なフォルダに置いています(置き場所と中身は記事の末尾に)

この「Claudeに作ってもらって自分で置く」流れは、MCPサーバー本体を作るときと同じ
補助プラグインは1ファイルの短いPHPなので、依頼すればすぐ作ってもらえます

それから、これは「できない」というより最後は人が見ておきたいところですが、仕上がりの見た目
AIはブロックのHTMLを組むところまでで、テーブルの幅やモバイルでの崩れといった実際の表示までは見えていません
ファクトの裏取りと同じ感覚で、公開前にプレビューでさっと目を通しておくと取りこぼしが減ると思います

もう1つ、これはMCPというよりAIに記事を書かせるとき全般のコツですが、「リストには下線を付ける」「画像は影付きスタイルにする」のような自分ルールを決めておくと、AIはそれに合わせて作ってくれます

そもそもMCPって何|AIと外部サービスを繋ぐ共通規格

「MCP」はModel Context Protocolの略で、Anthropic(Claudeを作っている会社)が提唱しているAIアシスタント業界の新しい標準仕様です

ざっくり言うと、AIに外部のサービスを操作させるための共通言語のようなものになります

MCPで何が変わるのか

これまでもAIに「○○をやって」と頼むことはできましたが、AI自身は実際にWordPressを操作したりファイルを読み書きしたりはできませんでした

MCPサーバーを間に挟むことで、AIから外部システムを「ツール」として呼び出せるようになり、AIが実作業まで進められるようになります

今回作る仕組みの全体像

図にするとこんな流れになります

[あなた] ⇄ [Claude Code] ⇄ [MCPサーバー] ⇄ [WordPress REST API] ⇄ [WordPress]
  • Claude Code が「下書きを保存して」というあなたの指示を理解する
  • MCPサーバー(自分のPCで動く小さなプログラム)がそれをWordPressが分かる形に翻訳する
  • WordPress REST API が実際に記事を作成する

2026年9月に確認した時点では、WordPress公式のMCP Adapterはあるものの、投稿を作る・直す能力までは入っていません(くわしくは後ろの節で)
なので今回はPythonで自作しました
といっても最初はだいたい200行の小さなものをClaudeに作ってもらって、そこから少しずつツールを足していった形です

Claude Code自体の基本的な使い方はまた別記事で書こうと思いますが、その前段としてClaudeの使い方を初心者向けに解説した記事もあるので、Claudeに触ったことがない人はそちらから読んでもらえると入りやすいです

Claudeに「WordPress操作のMCPサーバーを作って」と頼む

MCPサーバーは自分でゼロからコードを書かなくても、Claude Code本人に作ってもらえます

コツは一度に全部を作らせないことで、1つのツールを作る → 動かしてテスト → 次のツールを足すという小さいサイクルで育てていくと失敗しにくいです

ジャベ雄

最初に全部まとめて作らせて、見事にやらかしたんです

最初の依頼:土台と接続確認だけ

まずはこんなふうに、使う技術と認証の方針を伝えて土台を作ってもらいます

PythonでWordPressを操作するMCPサーバーを作りたいです
・FastMCP(mcp.server.fastmcp の FastMCP)を使う
・WordPress REST API(/wp-json/wp/v2/)を叩く
・認証はアプリケーションパスワードで、認証情報は .env から読む(python-dotenv)
・まずは接続確認のツール(サイト名とログインユーザーを返す)だけ作って

ここで認証情報は .env から読むと最初に指定しておくと、あとの作業が楽になります
コードの中にパスワードが残らないので、そのまま公開しても問題ない形

ツールを1つずつ足していく

接続確認が通ったら、記事を作るツールから順番に足していきます

接続確認が通ったので、記事を下書き保存するツール create_post を追加して
デフォルトのstatusはdraft、publishは明示したときだけにして

同じ調子でlist_posts(一覧取得)、update_post(更新)、get_post(取得)と1つずつ頼んでいきます
カテゴリやタグの一覧取得・新規作成も、同じやり方

うまく作ってもらうための小さなコツ

  • 一度に全機能を作らせない、1ツールずつ作ってテストしてから次を足す
  • 認証情報は最初に「.envから読む」と指定する(コードに直書きさせない)
  • 接続確認のツールから作ると、最初の動作確認がすぐできる
  • エラーが出たらエラーメッセージをそのまま貼ると、原因を見つけて直してくれる
  • 「デフォルトはdraft」のような安全側の仕様も、最初の依頼文に入れておく

Claude Code×WordPress自動投稿のセットアップ手順

Python・Claude Code(Pro以上)・アプリケーションパスワードの3つが揃えば、6ステップで接続確認まで終わります

用意するもの

必要なもの補足
Claude Code(Desktop版)ClaudeのPro / Maxプランで利用可能
WordPress(自己ホスト型)Xserver、ロリポップ、ConoHaなど、バージョンは5.6以上
Python 3.10以上Windows 11なら公式インストーラーで導入
PowerShellWindows 11標準搭載、コマンドを数行打つだけ

Claude Codeは有料プラン専用なので、無料で試したい場合はClaudeのコネクタ機能を使う代替手段もあります

Step1:プロジェクトフォルダを作る

任意の場所にフォルダを作ります
私の場合はデスクトップ

C:\Users\ユーザー名\Desktop\blog\

Step2:必要なファイルをClaudeに作ってもらう

前の章で作ってもらったwp_mcp_server.pyに加えて、動かすために次の3ファイルが必要です
いずれもClaudeに頼めば、そのまま作って配置までやってくれます

  • requirements.txt(必要なライブラリ一覧)
  • .mcp.json(Claude CodeにMCPサーバーの場所を教える設定)
  • .env(WordPressの認証情報を書く、中身は次のステップで自分で書く)

こんな感じで頼めばまとめて作ってくれます

このサーバーを動かすのに必要な requirements.txt と .mcp.json も作って、同じフォルダに置いて
.env は中身が認証情報なので、空のテンプレート(WP_BLOG_BASE_URL / WP_BLOG_USERNAME / WP_BLOG_APP_PASSWORD の3つを書く枠)だけ作っておいて

.mcp.json や CLAUDE.md のような設定ファイルがどこに置かれて何をするのかは、最初はちょっと分かりにくいので、別記事で一覧にしてあります

.envの中身(=認証情報)だけは人が書きます
ここはClaudeに渡さない情報なので、空のテンプレートだけ作ってもらって、値は次のステップで自分で埋めるのが安全です

Step3:Pythonライブラリのインストールも頼める

requirements.txtができたら、ライブラリのインストールもClaudeに頼めます

requirements.txt のライブラリをインストールして

これで mcp・requests・markdown などのパッケージが入ります
自分で実行したい場合は、PowerShellで次のコマンドでもOK

cd C:\Users\ユーザー名\Desktop\blog\
python -m pip install -r requirements.txt

Step4:WordPressでアプリケーションパスワードを発行

ここが今回の肝
普段のログインパスワードではなく API専用の使い捨てパスワードを発行します

  1. WordPress管理画面にログインして「ユーザー → プロフィール」を開く
  2. ページ最下部にある「アプリケーションパスワード」欄まで移動
  3. 名前を入力(例:Claude MCP)して「新しいアプリケーションパスワードを追加」をクリック
  4. xxxx xxxx xxxx xxxx xxxx xxxx 形式の文字列が表示されるのでコピー
WordPressのプロフィール画面にあるアプリケーションパスワードの欄
発行直後に一度だけ表示されるアプリケーションパスワード

この画面を閉じると二度と表示されません
すぐ次のステップで貼り付けてください

Step5:.envファイルに認証情報を書く

Step2でClaudeに枠だけ作ってもらった .env ファイルを開いて、次の3つの値を自分で埋めます

WP_BLOG_BASE_URL=https://あなたのドメイン
WP_BLOG_USERNAME=WordPressのログインユーザー名
WP_BLOG_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx

アプリケーションパスワードはスペース込みのままで大丈夫なので、わざわざ削る必要はありません

末尾に載せた完全版は、旧い名前の WP_BASE_URL / WP_USERNAME / WP_APP_PASSWORD も同じ意味で読みます

Step6:Claude Codeで動作確認

このフォルダでClaude Codeを起動すると、.mcp.json が自動で検出されてMCPサーバーの起動許可を求められます
「Allow」を押してください

その後Claude Codeに次のように頼みます

「WordPressの接続確認をして」

サイト名とログインユーザー名が返ってくれば接続成功
ここでやっとひと息つけます

AIで仕事を減らしたい方へPR

AI鬼管理|Claude Code活用の業務自動化トレーニング

Claude Code を業務の自動化に使うためのトレーニングです
いきなり申し込む形ではなく、まずは無料の業務効率化診断(オンライン面談)から始められます

無料の業務効率化診断を見てみる

Notta|AI自動文字起こし

会議や動画の音声を自動で文字にするサービスです
議事録や書き起こしの手作業を減らしたいときに

Nottaを見てみる

作ってもらったサーバーの動作テスト手順

MCPサーバーのテストはClaudeに実際に実行させて、会話で結果を確認するのが一番早いです

ツールを1つ足すたびに次の流れを回しておくと、どこかが壊れた瞬間に気づけます
本格的なテストコード(pytestなど)を書くのは、仕様が固まってからで十分だと思います

STEP
接続を確認する

「WordPressの接続確認をして」と頼んで、サイト名とログインユーザー名が返ってくればまず合格です

STEP
テスト用の下書きを作る

「テスト用の下書きを1件作って、投稿IDを教えて」と頼みます
WordPress管理画面の投稿一覧に下書きが増えていれば作成は成功

STEP
一覧と中身を取得する

「下書き一覧を出して」「さっきの投稿の中身を見せて」と頼んで、取得系のツールがちゃんと動くかを見ます

STEP
テスト投稿を片付ける

「さっきのテスト投稿を削除して」で後片付けまでできれば、作成から削除まで一通り通ったことになります

STEP
わざと失敗させてみる

.envのパスワードを1文字だけ変えて、401(認証失敗)が分かりやすく返るかも見ておきます
エラーのときに何が起きたか分かる作りになっていれば、ひと安心
確認できたらパスワードは元に戻します

この「会話で1ツールずつ叩く」テストの積み重ねが、結局いちばん速くて確実でした

Claude Codeで実際に記事を投稿する流れ

セットアップが終わったら、基本はチャットで会話しながら書いてもらうだけです

「ExcelのVLOOKUPについて、初心者向けに1000字程度で解説記事を書いて、下書きで保存しておいて」

これだけで、Claudeが記事本文を生成してcreate_postツール経由でWordPressに下書きとして投稿してくれます

あとは管理画面でプレビューして、必要なら手直しして「公開」を押すだけです

運用上の3つの注意点

便利な仕組みですが、AIに丸投げで運用する前にいくつか注意点があります

  • 下書き保存を原則とする:AIが書いた記事は人間が目視チェックしてから公開する、私のセットアップではデフォルトをdraftに固定しています
  • 認証情報はコミットしない:.env には認証情報が入るので、GitHubなどに公開しないよう .gitignore に原則追加
  • ハルシネーション対策:固有名詞・日付・数字・出典は鵜呑みにせず裏取りを推奨

特にハルシネーション対策は、生成AIの裏取りのやり方のところでも書いた通り、AI全般で気をつけたいポイントです

自作しない選択肢|公式のMCP Adapterと橋渡しプラグイン

2026年9月に確認した限りでは、プラグインを2つ入れるだけでも記事の下書き投稿までは届きます
ただし私の環境で公式ルートを丸ごと動かしてみたわけではないので、ここからは公開されている説明を読んだ範囲での整理

土台になるのがWordPress公式のMCP Adapterで、WordPress側にAIからの窓口を1つ開けるだけの部品みたいな感じです
でも、入れただけで見えるようになるのは discover / get-ability-info / execute の3つで、肝心の能力は既定で非公開のまま

WordPress 6.9本体が持っている能力も、サイト情報・ユーザー情報・環境情報の3つだけ
投稿を作る・直す能力は7.2以降に先送りと公式の提案に書かれていて、読み取り専用のものすら7.1では見送られました

だから、足りないところを外から足す橋渡しプラグインが出てきています(2つとも動かすには WordPress 6.9以上が前提)
Enable Abilities for MCPは投稿の作成・更新・削除を含む112個の能力を足してくれて、Claude.aiから繋ぐためのOAuthコネクタまで入っているとのことでした

MCPやコネクタといった言葉の意味は、Claude用語集のほうにまとめてあります

  • SEOのメタは Yoast / Rank Math / SEOPress に対応、SEO SIMPLE PACK は書かれていない
  • MCP Adapterの既定の権限チェックはログイン済みなら通る形なので、使うなら権限の設定をちゃんと足すことになる
  • MCP Adapterはまだ1.0より前なので、バージョンが上がると書き方が変わることがある
  • 旧 Automattic/wordpress-mcp は2026年1月にアーカイブ済み、READMEにも新規で使わないよう書かれている

私は公式ルートに寄せるかでちょっと迷いましたが、結局この記事の自作方式のままです
SEO SIMPLE PACK のメタだったり、3サイトの切り替えだったりが要るので、いまの公式ルートだと足りないままなんです

逆にWordPressは1サイトだけ・SEOはYoastかRank Math・自作は避けたいという条件なら、やっぱり公式ルートのほうが近道だと思います

ジャベ雄

公式が育ったら、自作のほうは引退させる予定です

まとめ|AIに任せる作業と人間がやるべき作業

AIに渡せたのは、下書き作成・装飾・メタ設定の3つでした

やったことできるようになったこと
MCPサーバー本体をClaudeに作ってもらうClaude CodeからWordPressを直接操作(記事・カテゴリ・タグ・メタ・アイキャッチ)
アプリケーションパスワードで認証普段のログインパスワードを使わずに済む
補助プラグインで触れる範囲を選ぶ必要な項目(メタディスクリプション・投稿別CSS)だけ安全に開放

最初の接続確認さえ通れば「テーマを伝える → 下書きが管理画面にできてる」という流れがとても快適です

個人的に思うのは、「AIに全部任せる」じゃなくて、手間のかかる部分をAIに渡して、最後の判断は人間がする、この役割分担がちょうど良い距離感でした

ここまで読むと細かい手順や注意点が多く見えるかもしれませんが、実際にはこの記事で書いたほど細かく指示しなくても、Claudeが自分で判断してよしなに進めてくれる場面が多いです
必要なら途中で「ここはこうして」と相談すれば直してくれるので、まずは小さく頼んで動かしてみるところから始めてみてください

同じくWordPress×AIの自動投稿を試したい方の参考になれば嬉しいです

ジャベ雄

私も最初の1個は、接続確認だけでした

コード全文と設定ファイル|コピペで使う場合

完全版は18個のツールで、だいたい900行
複数サイトの切り替え・Markdownファイルからの一発入稿・アイキャッチの設定・メディアのアップロードまで入っている形

requirements.txt

mcp>=1.2.0
requests>=2.32.0
python-dotenv>=1.0.0
markdown>=3.6
python-frontmatter>=1.1.0

.mcp.json

{
  "mcpServers": {
    "wordpress": {
      "command": "python",
      "args": ["C:\\path\\to\\wp_mcp_server.py"],
      "env": {}
    }
  }
}

wp_mcp_server.py(最小形の抜粋)

from mcp.server.fastmcp import FastMCP
import os, requests
from dotenv import load_dotenv

load_dotenv()
WP_BASE_URL = os.getenv("WP_BASE_URL", "").rstrip("/")
WP_USERNAME = os.getenv("WP_USERNAME", "")
WP_APP_PASSWORD = os.getenv("WP_APP_PASSWORD", "")
API_BASE = f"{WP_BASE_URL}/wp-json/wp/v2"

mcp = FastMCP("wordpress")

@mcp.tool()
def create_post(title: str, content: str, status: str = "draft") -> dict:
    """新しい記事をWordPressに作成する(デフォルトは下書き)"""
    resp = requests.post(
        f"{API_BASE}/posts",
        auth=(WP_USERNAME, WP_APP_PASSWORD),
        json={"title": title, "content": content, "status": status},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json()

if __name__ == "__main__":
    mcp.run()

この抜粋は旧い名前の WP_BASE_URL のほうを読む形なので、WP_BLOG_* で揃えるなら下のコード全文を使ってください

短いコードですが、仕組みのポイントはこのあたりです

  • FastMCP(“wordpress”) でサーバーを1つ定義する
  • @mcp.tool() を付けた関数が、そのままClaudeから呼べるツールになる
  • 関数の説明文(docstring)が「このツールは何をするか」のClaude向けの説明になるので、日本語で丁寧に書いておく
  • 認証・タイムアウト・エラー処理は共通の関数にまとめて、各ツールはそれを呼ぶだけにすると見通しが良い
  • 接続先のサイトを引数で切り替えられるようにすると、複数サイトの運用にも対応できる

完成版もこの形で、create_post・update_post・upload_media などのツールを登録しています

このサーバーをそっくり真似しなくても、「こういうツールが欲しい」とClaudeに伝えて1つずつ足していくやり方なら、自分の運用に合った形が作れます

wp_mcp_server.py(コード全文)

.env に書くキーは WP_BLOG_BASE_URL / WP_BLOG_USERNAME / WP_BLOG_APP_PASSWORD の3つ
メタディスクリプションまで書き込みたいときは、次の補助プラグインもセットで要ります

コード全文(コピペ用)
"""WordPress MCP Server (multi-site).

WordPress REST API(`/wp-json/wp/v2/`)とアプリケーションパスワード認証を使い、
Claude Code などの MCP クライアントから 投稿(posts)・固定ページ(pages)・メディア・
カテゴリ / タグ を操作するための MCP サーバー。

複数サイト対応:
  .env に `WP_<NAME>_BASE_URL` / `WP_<NAME>_USERNAME` / `WP_<NAME>_APP_PASSWORD` を置くと、
  `<NAME>` を小文字にしたものがサイト名になる(例: WP_BLOG_* → site="blog")。
  サイトはいくつでも登録でき、各ツールの `site` 引数で切り替える。

任意の環境変数:
  WP_DEFAULT_SITE                : site 省略時に使うサイト名(未設定なら "blog" があれば blog、無ければ最初に見つかったサイト)
  WP_<NAME>_AUTHOR_ID            : 新規作成する投稿・固定ページの著者 user_id(更新には影響しない)
  WP_META_DESCRIPTION_KEY        : frontmatter の meta_description を書き込む post meta キー
                                   (既定 "ssp_meta_description" = SEO SIMPLE PACK。空文字にすると書き込まない)
  WP_<NAME>_META_DESCRIPTION_KEY : 上をサイト別に上書きしたい場合
  ※ 書き込み先の meta キーは REST 公開(register_post_meta の show_in_rest)されている必要がある。
    SEO SIMPLE PACK 用の補助プラグインは mu-plugins/ssp-meta-rest.php を参照。

後方互換: 旧キー WP_BASE_URL / WP_USERNAME / WP_APP_PASSWORD は site="blog" の別名として認識する。
"""

from __future__ import annotations

import os
import re
import sys
from pathlib import Path
from typing import Any

import frontmatter
import markdown as md
import requests
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP

ROOT = Path(__file__).resolve().parent
load_dotenv(ROOT / ".env")

TIMEOUT = 30

mcp = FastMCP("wordpress")


# =========================================================================
# サイト別認証情報の解決
# =========================================================================

# サイトは環境変数から自動発見する: WP_<NAME>_BASE_URL の <NAME> を小文字化したものがサイト名
_SITE_KEY_RE = re.compile(r"^WP_([A-Z0-9]+)_BASE_URL$")
_LEGACY_BLOG_KEYS = {"BASE_URL": "WP_BASE_URL", "USERNAME": "WP_USERNAME", "APP_PASSWORD": "WP_APP_PASSWORD"}


def _discover_sites() -> list[str]:
    """登録済みサイト名を列挙する(blog を先頭に、あとは名前順)。"""
    names: set[str] = set()
    for key, val in os.environ.items():
        m = _SITE_KEY_RE.match(key)
        if m and val.strip():
            names.add(m.group(1).lower())
    if os.getenv("WP_BASE_URL", "").strip():
        names.add("blog")  # 旧キー(WP_BASE_URL / WP_USERNAME / WP_APP_PASSWORD)は blog の別名
    return sorted(names, key=lambda n: (n != "blog", n))


def _env_for(site: str, suffix: str) -> str:
    """site 用の環境変数 WP_<SITE>_<suffix> を返す。site="blog" は旧キーにもフォールバックする。"""
    val = os.getenv(f"WP_{site.upper()}_{suffix}", "").strip()
    if not val and site == "blog" and suffix in _LEGACY_BLOG_KEYS:
        val = os.getenv(_LEGACY_BLOG_KEYS[suffix], "").strip()
    return val


def _default_site() -> str:
    env = os.getenv("WP_DEFAULT_SITE", "").strip().lower()
    if env:
        return env
    sites = _discover_sites()
    if not sites or "blog" in sites:
        return "blog"
    return sites[0]


DEFAULT_SITE = _default_site()


def _default_author(site: str) -> int:
    """新規作成時に立てる著者(user_id)。WP_<SITE>_AUTHOR_ID が数値なら送る。
    未設定なら author を送らない(= 認証ユーザーが著者)。更新系は author を送らないので影響しない。"""
    raw = _env_for((site or DEFAULT_SITE).strip().lower(), "AUTHOR_ID")
    return int(raw) if raw.isdigit() else 0


def _meta_description_key(site: str) -> str:
    """frontmatter の meta_description を書き込む post meta キー。空文字なら書き込まない。
    優先順: WP_<SITE>_META_DESCRIPTION_KEY > WP_META_DESCRIPTION_KEY > "ssp_meta_description"(SEO SIMPLE PACK)。"""
    site = (site or DEFAULT_SITE).strip().lower()
    for key in (f"WP_{site.upper()}_META_DESCRIPTION_KEY", "WP_META_DESCRIPTION_KEY"):
        if key in os.environ:
            return os.environ[key].strip()
    return "ssp_meta_description"


def _site_config(site: str) -> dict[str, str]:
    site = (site or DEFAULT_SITE).strip().lower()
    known = _discover_sites()
    if site not in known:
        raise ValueError(
            f"未知の site 名: {site!r}。.env に登録済みのサイト: {known}"
            f"(WP_{site.upper()}_BASE_URL / _USERNAME / _APP_PASSWORD を追加すると使えます)"
        )
    return {
        "site": site,
        "base_url": _env_for(site, "BASE_URL").rstrip("/"),
        "username": _env_for(site, "USERNAME"),
        "password": _env_for(site, "APP_PASSWORD"),
    }


def _require_credentials(site: str) -> dict[str, str]:
    cfg = _site_config(site)
    missing = [
        label
        for label, val in (
            ("BASE_URL", cfg["base_url"]),
            ("USERNAME", cfg["username"]),
            ("APP_PASSWORD", cfg["password"]),
        )
        if not val
    ]
    if missing:
        prefix = f"WP_{cfg['site'].upper()}_"
        env_hint = " / ".join(prefix + label for label in ("BASE_URL", "USERNAME", "APP_PASSWORD"))
        raise RuntimeError(
            f"site={cfg['site']!r} の認証情報が不足: {', '.join(missing)}。"
            f"{ROOT / '.env'} に {env_hint} を設定してください"
        )
    return cfg


def _request(method: str, path: str, *, site: str = DEFAULT_SITE, **kwargs: Any) -> Any:
    cfg = _require_credentials(site)
    url = f"{cfg['base_url']}/wp-json/wp/v2{path}"
    resp = requests.request(
        method,
        url,
        auth=(cfg["username"], cfg["password"]),
        timeout=TIMEOUT,
        headers={"Accept": "application/json", **kwargs.pop("headers", {})},
        **kwargs,
    )
    if not resp.ok:
        raise RuntimeError(
            f"WordPress API [{site}] {method} {path} failed: HTTP {resp.status_code} - {resp.text[:500]}"
        )
    if resp.status_code == 204 or not resp.content:
        return None
    return resp.json()


# =========================================================================
# 汎用ヘルパー
# =========================================================================

def _resolve_terms(taxonomy: str, names_or_ids: list[str | int], *, site: str) -> list[int]:
    """カテゴリ・タグの名前を ID に変換。存在しない名前は新規作成する。"""
    if not names_or_ids:
        return []
    ids: list[int] = []
    for item in names_or_ids:
        if isinstance(item, int):
            ids.append(item)
            continue
        s = str(item).strip()
        if s.isdigit():
            ids.append(int(s))
            continue
        existing = _request("GET", f"/{taxonomy}", site=site, params={"search": s, "per_page": 100})
        match = next((t for t in existing if t.get("name") == s), None)
        if match:
            ids.append(int(match["id"]))
        else:
            created = _request("POST", f"/{taxonomy}", site=site, json={"name": s})
            ids.append(int(created["id"]))
    return ids


def _markdown_to_html(text: str) -> str:
    return md.markdown(
        text,
        extensions=["extra", "sane_lists", "toc"],
        output_format="html5",
    )


def _entity_summary(entity: dict[str, Any]) -> dict[str, Any]:
    """投稿(post)または固定ページ(page)の共通サマリを返す。"""
    return {
        "id": entity.get("id"),
        "status": entity.get("status"),
        "title": (entity.get("title") or {}).get("rendered") or (entity.get("title") or {}).get("raw"),
        "slug": entity.get("slug"),
        "link": entity.get("link"),
        "date": entity.get("date"),
        "modified": entity.get("modified"),
        "type": entity.get("type"),
        "parent": entity.get("parent"),
        "menu_order": entity.get("menu_order"),
        "categories": entity.get("categories", []),
        "tags": entity.get("tags", []),
    }


# =========================================================================
# 接続確認
# =========================================================================

@mcp.tool()
def check_connection(site: str = DEFAULT_SITE) -> dict[str, Any]:
    """WordPress への接続と認証が正しいか確認する。

    Args:
        site: 接続先サイト名(.env の WP_<NAME>_* で登録した名前)。省略時は既定サイト。

    Returns:
        サイト情報とログインユーザー名。エラー時は例外を投げる。
    """
    cfg = _require_credentials(site)
    site_resp = requests.get(
        f"{cfg['base_url']}/wp-json",
        auth=(cfg["username"], cfg["password"]),
        timeout=TIMEOUT,
    )
    site_resp.raise_for_status()
    site_info = site_resp.json()
    me = _request("GET", "/users/me", site=site)
    return {
        "site": site,
        "site_name": site_info.get("name"),
        "site_url": site_info.get("url") or site_info.get("home"),
        "logged_in_as": me.get("name"),
        "user_id": me.get("id"),
        "roles": me.get("roles"),
        "available_sites": _discover_sites(),
    }


# =========================================================================
# Posts (投稿)
# =========================================================================

@mcp.tool()
def create_post(
    title: str,
    content: str,
    status: str = "draft",
    excerpt: str = "",
    slug: str = "",
    categories: list[str | int] | None = None,
    tags: list[str | int] | None = None,
    date: str = "",
    content_is_markdown: bool = False,
    meta: dict[str, Any] | None = None,
    featured_media: int = 0,
    site: str = DEFAULT_SITE,
) -> dict[str, Any]:
    """新しい投稿を WordPress に作成する。

    Args:
        title: 投稿タイトル。
        content: 本文。HTML を想定。Markdown を渡す場合は content_is_markdown=True にする。
        status: "draft" (下書き / デフォルト), "publish" (公開), "future" (予約投稿), "pending", "private" のいずれか。
        excerpt: 抜粋(任意)。
        slug: URL スラッグ(任意)。
        categories: カテゴリ名または ID の配列。名前が存在しなければ新規作成する。
        tags: タグ名または ID の配列。名前が存在しなければ新規作成する。
        date: 予約投稿用の日時 (ISO8601, 例 "2026-06-01T10:00:00")。status="future" 時に指定。
        content_is_markdown: True なら content を Markdown として HTML に変換してから投稿する。
        meta: post_meta に書き込むキー/値の辞書(例 {"ssp_meta_description": "..."})。キーは REST 公開されている必要あり。
        featured_media: アイキャッチ画像のメディア ID(upload_media で取得した id)。0(デフォルト)なら設定しない。
        site: 対象サイト名(.env の WP_<NAME>_* で登録した名前)。省略時は既定サイト。

    Returns:
        作成された投稿の概要。
    """
    if content_is_markdown:
        content = _markdown_to_html(content)
    payload: dict[str, Any] = {"title": title, "content": content, "status": status}
    if excerpt:
        payload["excerpt"] = excerpt
    if slug:
        payload["slug"] = slug
    if categories:
        payload["categories"] = _resolve_terms("categories", categories, site=site)
    if tags:
        payload["tags"] = _resolve_terms("tags", tags, site=site)
    if date:
        payload["date"] = date
    if meta:
        payload["meta"] = meta
    if featured_media:
        payload["featured_media"] = featured_media
    author_id = _default_author(site)
    if author_id:
        payload["author"] = author_id
    post = _request("POST", "/posts", site=site, json=payload)
    return _entity_summary(post)


@mcp.tool()
def update_post(
    post_id: int,
    title: str = "",
    content: str = "",
    status: str = "",
    excerpt: str = "",
    slug: str = "",
    categories: list[str | int] | None = None,
    tags: list[str | int] | None = None,
    date: str = "",
    content_is_markdown: bool = False,
    meta: dict[str, Any] | None = None,
    featured_media: int = 0,
    site: str = DEFAULT_SITE,
) -> dict[str, Any]:
    """既存投稿を更新する。空文字を渡したフィールドは変更されない。

    Args:
        post_id: 更新対象の投稿 ID。
        featured_media: アイキャッチ画像のメディア ID(upload_media で取得した id)。0(デフォルト)なら変更しない。
        site: 対象サイト名(.env の WP_<NAME>_* で登録した名前)。省略時は既定サイト。
        その他: create_post と同じ。

    Returns:
        更新後の投稿の概要。
    """
    payload: dict[str, Any] = {}
    if title:
        payload["title"] = title
    if content:
        payload["content"] = _markdown_to_html(content) if content_is_markdown else content
    if status:
        payload["status"] = status
    if excerpt:
        payload["excerpt"] = excerpt
    if slug:
        payload["slug"] = slug
    if categories is not None:
        payload["categories"] = _resolve_terms("categories", categories, site=site)
    if tags is not None:
        payload["tags"] = _resolve_terms("tags", tags, site=site)
    if date:
        payload["date"] = date
    if meta is not None:
        payload["meta"] = meta
    if featured_media:
        payload["featured_media"] = featured_media
    if not payload:
        raise ValueError("更新項目が指定されていません")
    post = _request("POST", f"/posts/{post_id}", site=site, json=payload)
    return _entity_summary(post)


@mcp.tool()
def get_post(post_id: int, context: str = "edit", site: str = DEFAULT_SITE) -> dict[str, Any]:
    """投稿 1 件を取得する。

    Args:
        post_id: 取得する投稿 ID。
        context: "view" または "edit" (デフォルト)。
        site: 対象サイト名(.env の WP_<NAME>_* で登録した名前)。省略時は既定サイト。

    Returns:
        投稿の全フィールド。
    """
    return _request("GET", f"/posts/{post_id}", site=site, params={"context": context})


@mcp.tool()
def list_posts(
    status: str = "any",
    search: str = "",
    per_page: int = 20,
    page: int = 1,
    category_ids: list[int] | None = None,
    tag_ids: list[int] | None = None,
    orderby: str = "date",
    order: str = "desc",
    site: str = DEFAULT_SITE,
) -> list[dict[str, Any]]:
    """投稿を一覧する。

    Args:
        site: 対象サイト名(.env の WP_<NAME>_* で登録した名前)。省略時は既定サイト。
        他引数: 従来通り。
    """
    params: dict[str, Any] = {
        "status": status,
        "per_page": min(max(per_page, 1), 100),
        "page": page,
        "orderby": orderby,
        "order": order,
        "context": "edit",
    }
    if search:
        params["search"] = search
    if category_ids:
        params["categories"] = ",".join(str(i) for i in category_ids)
    if tag_ids:
        params["tags"] = ",".join(str(i) for i in tag_ids)
    posts = _request("GET", "/posts", site=site, params=params)
    return [_entity_summary(p) for p in posts]


@mcp.tool()
def delete_post(post_id: int, force: bool = False, site: str = DEFAULT_SITE) -> dict[str, Any]:
    """投稿を削除する。デフォルトはゴミ箱へ移動。

    Args:
        post_id: 削除する投稿 ID。
        force: True なら完全削除、False (デフォルト) ならゴミ箱へ移動。
        site: 対象サイト名(.env の WP_<NAME>_* で登録した名前)。省略時は既定サイト。
    """
    params = {"force": "true"} if force else {}
    result = _request("DELETE", f"/posts/{post_id}", site=site, params=params)
    return {"deleted": True, "force": force, "result": result}


# =========================================================================
# Pages (固定ページ) - /app/ 中心に使用
# =========================================================================

@mcp.tool()
def create_page(
    title: str,
    content: str,
    status: str = "draft",
    excerpt: str = "",
    slug: str = "",
    parent: int = 0,
    menu_order: int = 0,
    date: str = "",
    content_is_markdown: bool = False,
    meta: dict[str, Any] | None = None,
    site: str = DEFAULT_SITE,
) -> dict[str, Any]:
    """新しい固定ページを WordPress に作成する。

    Args:
        title: ページタイトル。
        content: 本文。HTML を想定。content_is_markdown=True なら Markdown を HTML 変換。
        status: "draft" (下書き / デフォルト) / "publish" / "private" / "pending"。
        excerpt: 抜粋(任意)。
        slug: URL スラッグ(任意)。/app/{slug}/ になる。
        parent: 親ページ ID。階層構造を作る場合に指定 (0 ならトップレベル)。
        menu_order: 並び順(数字が小さいほど上)。
        date: 公開日時 (ISO8601)。
        content_is_markdown: True なら content を Markdown→HTML 変換。
        meta: post_meta に書き込む辞書。独自メタキーは事前に register_post_meta(show_in_rest=true) で REST 公開されている必要あり。
        site: 対象サイト名(.env の WP_<NAME>_* で登録した名前)。省略時は既定サイト。

    Returns:
        作成された固定ページの概要。
    """
    if content_is_markdown:
        content = _markdown_to_html(content)
    payload: dict[str, Any] = {"title": title, "content": content, "status": status}
    if excerpt:
        payload["excerpt"] = excerpt
    if slug:
        payload["slug"] = slug
    if parent:
        payload["parent"] = int(parent)
    if menu_order:
        payload["menu_order"] = int(menu_order)
    if date:
        payload["date"] = date
    if meta:
        payload["meta"] = meta
    author_id = _default_author(site)
    if author_id:
        payload["author"] = author_id
    page = _request("POST", "/pages", site=site, json=payload)
    return _entity_summary(page)


@mcp.tool()
def update_page(
    page_id: int,
    title: str = "",
    content: str = "",
    status: str = "",
    excerpt: str = "",
    slug: str = "",
    parent: int | None = None,
    menu_order: int | None = None,
    date: str = "",
    content_is_markdown: bool = False,
    meta: dict[str, Any] | None = None,
    site: str = DEFAULT_SITE,
) -> dict[str, Any]:
    """既存固定ページを更新する。空文字 / None を渡したフィールドは変更されない。

    Args:
        page_id: 更新対象の固定ページ ID。
        parent: 親ページ ID を変更する場合に指定(明示的に 0 にすればトップレベル化)。
        menu_order: 並び順を変更する場合に指定。
        site: 対象サイト名(.env の WP_<NAME>_* で登録した名前)。省略時は既定サイト。
        その他: create_page と同じ。

    Returns:
        更新後の固定ページの概要。
    """
    payload: dict[str, Any] = {}
    if title:
        payload["title"] = title
    if content:
        payload["content"] = _markdown_to_html(content) if content_is_markdown else content
    if status:
        payload["status"] = status
    if excerpt:
        payload["excerpt"] = excerpt
    if slug:
        payload["slug"] = slug
    if parent is not None:
        payload["parent"] = int(parent)
    if menu_order is not None:
        payload["menu_order"] = int(menu_order)
    if date:
        payload["date"] = date
    if meta is not None:
        payload["meta"] = meta
    if not payload:
        raise ValueError("更新項目が指定されていません")
    page = _request("POST", f"/pages/{page_id}", site=site, json=payload)
    return _entity_summary(page)


@mcp.tool()
def get_page(page_id: int, context: str = "edit", site: str = DEFAULT_SITE) -> dict[str, Any]:
    """固定ページ 1 件を取得する。

    Args:
        page_id: 取得する固定ページ ID。
        context: "view" (公開ビュー) または "edit" (デフォルト、編集用の raw 値を含む)。
        site: 取得先サイト。

    Returns:
        固定ページの全フィールド。
    """
    return _request("GET", f"/pages/{page_id}", site=site, params={"context": context})


@mcp.tool()
def list_pages(
    status: str = "any",
    search: str = "",
    parent: int | None = None,
    per_page: int = 50,
    page: int = 1,
    orderby: str = "menu_order",
    order: str = "asc",
    site: str = DEFAULT_SITE,
) -> list[dict[str, Any]]:
    """固定ページを一覧する。

    Args:
        status: "any", "publish", "draft", "future", "pending", "private"。
        search: タイトル/本文の部分検索キーワード。
        parent: 親ページ ID で絞り込み(0 を指定するとトップレベルのみ取得)。
        per_page: 1 ページあたりの件数 (最大 100)。
        page: ページ番号。
        orderby: "menu_order" (デフォルト・並び順)、"date"、"modified"、"title"、"id"。
        order: "asc" (デフォルト) または "desc"。
        site: 対象サイト名(.env の WP_<NAME>_* で登録した名前)。省略時は既定サイト。

    Returns:
        固定ページ概要の配列(id / slug / title / link / parent / menu_order など)。
    """
    params: dict[str, Any] = {
        "status": status,
        "per_page": min(max(per_page, 1), 100),
        "page": page,
        "orderby": orderby,
        "order": order,
        "context": "edit",
    }
    if search:
        params["search"] = search
    if parent is not None:
        params["parent"] = parent
    pages = _request("GET", "/pages", site=site, params=params)
    return [_entity_summary(p) for p in pages]


@mcp.tool()
def delete_page(page_id: int, force: bool = False, site: str = DEFAULT_SITE) -> dict[str, Any]:
    """固定ページを削除する。デフォルトはゴミ箱へ移動。

    Args:
        page_id: 削除する固定ページ ID。
        force: True なら完全削除、False ならゴミ箱へ。
        site: 対象サイト。
    """
    params = {"force": "true"} if force else {}
    result = _request("DELETE", f"/pages/{page_id}", site=site, params=params)
    return {"deleted": True, "force": force, "result": result}


# =========================================================================
# Categories / Tags
# =========================================================================

@mcp.tool()
def list_categories(per_page: int = 100, search: str = "", site: str = DEFAULT_SITE) -> list[dict[str, Any]]:
    """カテゴリを一覧する。"""
    params: dict[str, Any] = {"per_page": min(max(per_page, 1), 100)}
    if search:
        params["search"] = search
    cats = _request("GET", "/categories", site=site, params=params)
    return [
        {"id": c["id"], "name": c["name"], "slug": c["slug"], "count": c.get("count", 0), "parent": c.get("parent", 0)}
        for c in cats
    ]


@mcp.tool()
def create_category(
    name: str, slug: str = "", description: str = "", parent: int = 0, site: str = DEFAULT_SITE
) -> dict[str, Any]:
    """カテゴリを新規作成する。"""
    payload: dict[str, Any] = {"name": name}
    if slug:
        payload["slug"] = slug
    if description:
        payload["description"] = description
    if parent:
        payload["parent"] = parent
    cat = _request("POST", "/categories", site=site, json=payload)
    return {"id": cat["id"], "name": cat["name"], "slug": cat["slug"]}


@mcp.tool()
def list_tags(per_page: int = 100, search: str = "", site: str = DEFAULT_SITE) -> list[dict[str, Any]]:
    """タグを一覧する。"""
    params: dict[str, Any] = {"per_page": min(max(per_page, 1), 100)}
    if search:
        params["search"] = search
    tags = _request("GET", "/tags", site=site, params=params)
    return [
        {"id": t["id"], "name": t["name"], "slug": t["slug"], "count": t.get("count", 0)}
        for t in tags
    ]


@mcp.tool()
def create_tag(name: str, slug: str = "", description: str = "", site: str = DEFAULT_SITE) -> dict[str, Any]:
    """タグを新規作成する。"""
    payload: dict[str, Any] = {"name": name}
    if slug:
        payload["slug"] = slug
    if description:
        payload["description"] = description
    tag = _request("POST", "/tags", site=site, json=payload)
    return {"id": tag["id"], "name": tag["name"], "slug": tag["slug"]}


# =========================================================================
# Markdown ファイル経由(投稿/固定ページ両対応)
# =========================================================================

@mcp.tool()
def post_from_markdown_file(
    file_path: str, override_status: str = "", override_site: str = ""
) -> dict[str, Any]:
    """Markdown ファイル(frontmatter 付き)から投稿または固定ページを作成または更新する。

    frontmatter の `id` フィールドがあれば update、なければ create。
    create 後は元ファイルの frontmatter に `id` を書き戻して、次回以降が update になる。

    frontmatter 共通キー:
      title (str, 必須), status ("draft"/"publish"/...), excerpt, slug,
      date (ISO8601), id (int, 更新用), format ("markdown"/"gutenberg"/"html"),
      meta_description (str, post meta へ書き込む。キーは WP_META_DESCRIPTION_KEY で指定、既定 ssp_meta_description=SEO SIMPLE PACK),
      meta (dict, 任意の post_meta を直接指定。キーは REST 公開されている必要あり),
      site (str, 投稿先サイト名。.env の WP_<NAME>_* で登録した名前)

    投稿(post)用キー(post_type 未指定または "post"):
      categories (list), tags (list)
      featured_media (int): アイキャッチ画像のメディア ID(upload_media で取得した id)。
        指定すると create_post/update_post に渡り投稿に紐づく。未指定なら変更しない。
        ※ 固定ページ(page)側は create_page/update_page 未対応のため非対応。

    固定ページ(page)用キー(post_type / type に "page" を指定):
      parent (int): 親ページ ID
      menu_order (int): 並び順

    Args:
        file_path: 対象 Markdown ファイルの絶対または相対パス。
        override_status: 指定すると frontmatter の status を上書きする。
        override_site: 指定すると frontmatter の site を上書きする。

    Returns:
        作成/更新された投稿または固定ページの概要。
    """
    path = Path(file_path)
    if not path.is_absolute():
        path = ROOT / path
    if not path.exists():
        raise FileNotFoundError(f"ファイルが見つかりません: {path}")

    doc = frontmatter.load(path)
    fm = doc.metadata
    body = doc.content

    title = fm.get("title")
    if not title:
        raise ValueError("frontmatter に title が必要です")

    status = override_status or fm.get("status") or "draft"
    site = (override_site or fm.get("site") or DEFAULT_SITE).strip().lower()
    post_type = (fm.get("post_type") or fm.get("type") or "post").strip().lower()

    fmt = (fm.get("format") or "markdown").lower()
    html = body if fmt in ("html", "gutenberg", "raw") else _markdown_to_html(body)

    post_meta: dict[str, Any] = {}
    # meta_description は _meta_description_key(site) のキーに書き込む
    # (既定は SEO SIMPLE PACK の ssp_meta_description、環境変数 WP_META_DESCRIPTION_KEY で変更可・空文字で無効化)
    # ※ 書き込み先キーは REST 公開されている必要がある(同梱の mu-plugins/ssp-meta-rest.php 参照)
    meta_key = _meta_description_key(site)
    if fm.get("meta_description") and meta_key:
        post_meta[meta_key] = fm["meta_description"]
    # 任意の post_meta を直接指定したい場合は frontmatter の meta(dict)を使う
    if isinstance(fm.get("meta"), dict):
        post_meta.update(fm["meta"])

    import datetime as _dt
    date_val = fm.get("date") or ""
    if isinstance(date_val, (_dt.datetime, _dt.date)):
        date_val = date_val.isoformat()
    elif date_val and not isinstance(date_val, str):
        date_val = str(date_val)

    record_id = fm.get("id")
    excerpt_val = fm.get("excerpt", "") or ""
    slug_val = fm.get("slug", "") or ""
    featured_media_val = int(fm["featured_media"]) if fm.get("featured_media") else 0

    if post_type == "page":
        if record_id:
            result = update_page(
                page_id=int(record_id),
                title=title,
                content=html,
                status=status,
                excerpt=excerpt_val,
                slug=slug_val,
                parent=int(fm["parent"]) if fm.get("parent") is not None else None,
                menu_order=int(fm["menu_order"]) if fm.get("menu_order") is not None else None,
                date=date_val,
                meta=post_meta or None,
                site=site,
            )
        else:
            result = create_page(
                title=title,
                content=html,
                status=status,
                excerpt=excerpt_val,
                slug=slug_val,
                parent=int(fm.get("parent") or 0),
                menu_order=int(fm.get("menu_order") or 0),
                date=date_val,
                meta=post_meta or None,
                site=site,
            )
            doc.metadata["id"] = result["id"]
            path.write_text(frontmatter.dumps(doc), encoding="utf-8")
    else:
        if record_id:
            result = update_post(
                post_id=int(record_id),
                title=title,
                content=html,
                status=status,
                excerpt=excerpt_val,
                slug=slug_val,
                categories=fm.get("categories"),
                tags=fm.get("tags"),
                date=date_val,
                meta=post_meta or None,
                featured_media=featured_media_val,
                site=site,
            )
        else:
            result = create_post(
                title=title,
                content=html,
                status=status,
                excerpt=excerpt_val,
                slug=slug_val,
                categories=fm.get("categories"),
                tags=fm.get("tags"),
                date=date_val,
                meta=post_meta or None,
                featured_media=featured_media_val,
                site=site,
            )
            doc.metadata["id"] = result["id"]
            path.write_text(frontmatter.dumps(doc), encoding="utf-8")

    return result


@mcp.tool()
def markdown_to_html(text: str) -> str:
    """Markdown を WordPress 投稿向けの HTML に変換する(プレビュー用)。"""
    return _markdown_to_html(text)


# =========================================================================
# メディア(画像)アップロード
# =========================================================================

@mcp.tool()
def upload_media(
    file_path: str,
    filename: str = "",
    title: str = "",
    alt_text: str = "",
    site: str = DEFAULT_SITE,
) -> dict[str, Any]:
    """ローカルの画像ファイルを WordPress のメディアライブラリにアップロードする。

    Args:
        file_path: アップロードする画像の絶対/相対パス。
        filename: メディアのファイル名(省略時は元ファイル名)。★Content-Disposition は ASCII 推奨なので、
                  日本語ファイル名のときは英数字+ハイフンの名前(例 "screenshot-01.png")を明示指定する。
        title: メディアのタイトル(任意)。
        alt_text: 代替テキスト(任意、SEO/アクセシビリティ用)。
        site: 対象サイト名(.env の WP_<NAME>_* で登録した名前)。省略時は既定サイト。

    Returns:
        {id, source_url, mime_type, title}。source_url を wp:image ブロックの src に使う。
    """
    import mimetypes

    cfg = _require_credentials(site)
    path = Path(file_path)
    if not path.is_absolute():
        path = ROOT / path
    if not path.exists():
        raise FileNotFoundError(f"ファイルが見つかりません: {path}")

    name = filename or path.name
    ctype = mimetypes.guess_type(name)[0] or mimetypes.guess_type(str(path))[0] or "application/octet-stream"
    data = path.read_bytes()

    resp = requests.post(
        f"{cfg['base_url']}/wp-json/wp/v2/media",
        auth=(cfg["username"], cfg["password"]),
        headers={
            "Content-Disposition": f'attachment; filename="{name}"',
            "Content-Type": ctype,
        },
        data=data,
        timeout=TIMEOUT * 3,
    )
    if not resp.ok:
        raise RuntimeError(
            f"media upload failed [{site}]: HTTP {resp.status_code} - {resp.text[:500]}"
        )
    m = resp.json()

    if title or alt_text:
        patch: dict[str, Any] = {}
        if title:
            patch["title"] = title
        if alt_text:
            patch["alt_text"] = alt_text
        m = _request("POST", f"/media/{m['id']}", site=site, json=patch)

    title_val = m.get("title")
    if isinstance(title_val, dict):
        title_val = title_val.get("rendered") or title_val.get("raw")
    return {
        "id": m.get("id"),
        "source_url": m.get("source_url"),
        "mime_type": m.get("mime_type"),
        "title": title_val,
    }


if __name__ == "__main__":
    try:
        mcp.run()
    except Exception as e:
        print(f"MCP server error: {e}", file=sys.stderr)
        raise

補助プラグイン(mu-plugins 用)

置き場所は /wp-content/mu-plugins/ssp-meta-rest.php、mu-plugins(must-use plugins)フォルダが無ければ wp-content/ の下に自分で作ります
ここに置くと管理画面に項目も出ないまま自動で有効になるので、有効化のクリックは不要
テーマやプラグインを更新しても消えないので、自作の小さな仕組みを常駐させるのにちょうど良い場所

JavaScriptを書き込む機能だけは、あえて公開していません
認証情報が漏れたときに、好きなスクリプトを埋め込まれかねないからです

補助プラグイン全文(コピペ用)
<?php
/**
 * Plugin Name: SEO SIMPLE PACK Meta REST API Support
 * Description: SEO SIMPLE PACK のメタフィールド + SWELL の投稿別カスタムCSS を WP REST API 経由で読み書きできるようにする補助プラグイン。投稿(post)・固定ページ(page)の両方に対応(/app/ のアプリ庫サイトは固定ページ運用のため page 必須)。
 * Author: Local helper
 * Version: 1.2.0
 *
 * 配置場所: /wp-content/mu-plugins/ssp-meta-rest.php
 *   (mu-pluginsフォルダが無ければ作成すること。配置するだけで自動有効化される)
 *
 * 変更履歴:
 *   1.0.0 - 初版(post のみ対応)
 *   1.1.0 - page 型にも対応(/app/ サイト用)
 *   1.2.0 - SWELL の投稿別カスタムCSS(swell_meta_css)を追加して MCP から記事ごとのCSSを書けるようにした。
 *           ※ JS(swell_meta_js)は「あえて公開しない」: 認証情報(アプリパスワード)が漏れた場合に
 *             サイト訪問者へ任意の JavaScript を注入できてしまう(実質 XSS)リスクがあるため。
 *           ※ そのまま出力フラグ(swell_meta_css_plane / swell_meta_js_plane)も、操作面・事故面を
 *             増やさないため公開しない(CSS は既定=<style> で囲む、で十分)。
 *
 * メタキーの根拠:
 *   - SEO SIMPLE PACK の post meta: ssp_meta_description / ssp_meta_robots(先頭アンダースコアなし)。
 *   - SWELL の投稿別CSS: swell_meta_css(投稿編集画面のメタボックス textarea name="swell_meta_css" より確認)。
 *   いずれも標準では REST 非公開のため、本プラグインで show_in_rest 登録する。
 *   ※ 一括書き戻し前に1記事で読み書きを検証することを推奨。
 */

add_action('init', function () {
    // REST 公開するメタキー(値はすべて文字列)
    $rest_meta_keys = [
        'ssp_meta_description',   // SSP メタディスクリプション(書き戻しの本命)
        'ssp_meta_robots',        // SSP robots(noindex 等。必要な記事のみ)
        'swell_meta_css',         // SWELL 投稿別カスタムCSS(wp_head で出力)。JS はあえて非公開
    ];

    // 対象 post type:post(blog 用)+ page(/app/ 用、SWELL 固定ページ運用)
    $target_post_types = ['post', 'page'];

    foreach ($rest_meta_keys as $key) {
        foreach ($target_post_types as $post_type) {
            register_post_meta($post_type, $key, [
                'type'          => 'string',
                'single'        => true,
                'show_in_rest'  => true,
                'auth_callback' => function () use ($post_type) {
                    // post なら edit_posts、page なら edit_pages の権限チェック
                    return $post_type === 'page'
                        ? current_user_can('edit_pages')
                        : current_user_can('edit_posts');
                },
            ]);
        }
    }
});

更新版は javeo2022/wordpress-mcp-server にも置いています

📚 Claude・生成AIを学べる本PR
実践Claude Code入門

実践Claude Code入門

西見公宏・吉田真吾・大嶋勇樹

Claude CodeによるAI駆動開発入門

Claude CodeによるAI駆動開発入門

平川知秀

開発効率をアップする! Claude Code 実用入門

開発効率をアップする! Claude Code 実用入門

大澤文孝

私のおすすめからランダムで3冊を表示しています


WORK

ツール作成のご依頼を受けています

クラウドワークス・ココナラで受注200件以上
会社員の副業なので、対応は平日夜と土日です

頼めることと料金の目安を見る →

ココナラが初めての方は、紹介コード 78E62K で1,000円分のポイントがもらえます

はじめての方の会員登録(PR):クラウドワークス / ココナラ

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

VBAとPythonを中心にユーザー側でできるITを自己学習しているので備忘録半分、学習履歴を残して同じ道を辿る人の参考になればとブログを始めました

副業でスクレイピングツール作成を中心にできることを色々やっていますのでご相談いただけるとありがたいです!


クラウドワークスのページへ


ココナラのページへ

コメント

コメントする

目次