 {"id":522146,"date":"2026-08-13T19:57:14","date_gmt":"2026-08-14T02:57:14","guid":{"rendered":"https:\/\/jorgep.com\/blog\/?p=522146"},"modified":"2026-09-07T20:17:11","modified_gmt":"2026-09-08T03:17:11","slug":"how-i-connected-chatgpt-to-wordpress-with-a-secure-self-hosted-mcp-server","status":"publish","type":"post","link":"https:\/\/jorgep.com\/blog\/how-i-connected-chatgpt-to-wordpress-with-a-secure-self-hosted-mcp-server\/","title":{"rendered":"How I Connected ChatGPT to WordPress with a Secure, Self-Hosted MCP Server"},"content":{"rendered":"<style>.wp-block-kadence-advancedheading.kt-adv-heading407818_afcbba-c7, .wp-block-kadence-advancedheading.kt-adv-heading407818_afcbba-c7[data-kb-block=\"kb-adv-heading407818_afcbba-c7\"]{font-size:var(--global-kb-font-size-sm, 0.9rem);font-style:normal;}.wp-block-kadence-advancedheading.kt-adv-heading407818_afcbba-c7 mark.kt-highlight, .wp-block-kadence-advancedheading.kt-adv-heading407818_afcbba-c7[data-kb-block=\"kb-adv-heading407818_afcbba-c7\"] mark.kt-highlight{font-style:normal;color:#f76a0c;-webkit-box-decoration-break:clone;box-decoration-break:clone;padding-top:0px;padding-right:0px;padding-bottom:0px;padding-left:0px;}.wp-block-kadence-advancedheading.kt-adv-heading407818_afcbba-c7 img.kb-inline-image, .wp-block-kadence-advancedheading.kt-adv-heading407818_afcbba-c7[data-kb-block=\"kb-adv-heading407818_afcbba-c7\"] img.kb-inline-image{width:150px;vertical-align:baseline;}<\/style>\n<p class=\"kt-adv-heading407818_afcbba-c7 wp-block-kadence-advancedheading\" data-kb-block=\"kb-adv-heading407818_afcbba-c7\">Have questions, ideas to share, or just want to connect? I\u2019d love to hear from you! Check out my <a href=\"https:\/\/jorgep.com\/blog\/about\/\">About Page<\/a> to learn more about me or connect with me.<\/p>\n\n\n\n<div id=\"model-response-message-contentr_5b2d0e136917eb53\" class=\"markdown markdown-main-panel md-content enable-luminous-fast-follows enable-updated-hr-color tutor-markdown-rendering\" dir=\"ltr\" aria-busy=\"false\" aria-live=\"polite\">\n<div>I recently wanted a faster and more consistent way to work with my WordPress sites from ChatGPT.<\/div>\n<br>\n<div>Using the WordPress editor in a browser works, of course, but it becomes repetitive when the workflow is always the same: prepare an article, create the post, make sure it remains a draft, select the right category, and then open WordPress to review everything manually.<\/div>\n<br>\n<div>I also work from more than one computer. A local script with credentials stored on one machine would solve only part of the problem. I wanted something available wherever I used ChatGPT, without copying WordPress passwords between computers or giving an AI unrestricted access to publish content.<\/div>\n<br>\n<div>The solution was a small, self-hosted Model Context Protocol server\u2014an MCP server\u2014that sits between ChatGPT and WordPress.<\/div>\n<br>\n<div>The result is a private WordPress manager that can:<\/div>\n<br>\n<ul data-path-to-node=\"5\">\n<li>\n<div>list the WordPress sites I have configured;<\/div>\n<\/li>\n<li>\n<div>verify a site&#8217;s WordPress connection;<\/div>\n<\/li>\n<li>\n<div>retrieve categories;<\/div>\n<\/li>\n<li>\n<div>create new posts as drafts only;<\/div>\n<\/li>\n<li>\n<div>preview category changes before applying them;<\/div>\n<\/li>\n<li>\n<div>support multiple WordPress sites through one secure endpoint.<\/div>\n<\/li>\n<\/ul>\n<div>Publishing is deliberately not implemented. Even if I accidentally ask ChatGPT to publish something, the server simply has no publishing function available.<\/div>\n<br>\n<h3 data-path-to-node=\"7\">Why an MCP server?<\/h3>\n<div>MCP provides a standard way for ChatGPT and other compatible AI clients to discover and call tools exposed by a remote service. Instead of giving ChatGPT a general-purpose shell, direct database access, or my WordPress administrator password, I can expose a small set of carefully designed actions.<\/div>\n<br>\n<div>That separation matters.<\/div>\n<br>\n<div>ChatGPT decides when an available tool may help with a request, but the MCP server remains responsible for authentication, authorization, validation, and the actual WordPress operation. OpenAI&#8217;s guidance similarly emphasizes that authorization must be enforced by the MCP server rather than left to the model.<\/div>\n<br>\n<div>For my implementation, every operation is intentionally narrow. The <code data-path-to-node=\"11\" data-index-in-node=\"68\">create_draft<\/code> tool always sends <code data-path-to-node=\"11\" data-index-in-node=\"99\">status: draft<\/code> to WordPress. The taxonomy workflow separates previewing a change from applying it. Credentials remain on my server and are never returned to ChatGPT.<\/div>\n<br>\n<h3 data-path-to-node=\"12\">The options I considered<\/h3>\n<div>There are several reasonable ways to connect an AI assistant to WordPress.<\/div>\n<br>\n<div><b data-path-to-node=\"14\" data-index-in-node=\"0\">Use the WordPress browser interface<\/b><\/div>\n<div>This is the simplest option and requires no development. It is ideal for occasional use, but it is slower for repeatable workflows and still requires manually moving content between ChatGPT and WordPress.<\/div>\n<br>\n<div><b data-path-to-node=\"15\" data-index-in-node=\"0\">Call the WordPress REST API from a local script<\/b><\/div>\n<div>A local Python, PowerShell, or command-line script can create drafts very efficiently. WordPress Application Passwords make this possible without using the account&#8217;s normal login password.<\/div>\n<br>\n<div>The limitation is portability. Credentials and scripts must be configured separately on every computer, and a web-based ChatGPT session cannot automatically access environment variables stored on a different machine.<\/div>\n<br>\n<div><b data-path-to-node=\"17\" data-index-in-node=\"0\">Use an automation platform<\/b><\/div>\n<div>Tools such as n8n, Make, or Zapier can receive content and send it to WordPress. This is a good choice for fixed workflows, scheduled jobs, and integrations involving several services. It can become less natural when I want an interactive conversation in which the assistant first reads categories, asks for confirmation, and then performs one controlled action.<\/div>\n<br>\n<div><b data-path-to-node=\"18\" data-index-in-node=\"0\">Host a private MCP server<\/b><\/div>\n<div>This requires the most initial setup, but it provides the experience I wanted:<\/div>\n<br>\n<ul data-path-to-node=\"19\">\n<li>\n<div>one HTTPS endpoint available from multiple computers;<\/div>\n<\/li>\n<li>\n<div>one service capable of managing multiple WordPress sites;<\/div>\n<\/li>\n<li>\n<div>OAuth login instead of sharing WordPress credentials with ChatGPT;<\/div>\n<\/li>\n<li>\n<div>narrowly defined read and write tools;<\/div>\n<\/li>\n<li>\n<div>draft-only safeguards enforced in code;<\/div>\n<\/li>\n<li>\n<div>centralized logging and configuration.<\/div>\n<\/li>\n<\/ul>\n<div>For my situation, the hosted MCP approach was the best long-term fit.<\/div>\n<br>\n<h3 data-path-to-node=\"21\">The architecture<\/h3>\n<div>The finished service uses these components:<\/div>\n<br>\n<ul data-path-to-node=\"23\">\n<li>\n<div><b data-path-to-node=\"23,0,0\" data-index-in-node=\"0\">ChatGPT Developer Mode<\/b> connects to the public MCP endpoint.<\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"23,1,0\" data-index-in-node=\"0\">Auth0<\/b> provides OAuth authentication and Google sign-in.<\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"23,2,0\" data-index-in-node=\"0\">Nginx Proxy Manager<\/b> terminates HTTPS and forwards requests to the MCP container.<\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"23,3,0\" data-index-in-node=\"0\">FastMCP<\/b> implements the MCP protocol and exposes the WordPress tools.<\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"23,4,0\" data-index-in-node=\"0\">Redis<\/b> stores encrypted OAuth client and session state.<\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"23,5,0\" data-index-in-node=\"0\">WordPress REST API<\/b> performs the final site operations using an Application Password.<\/div>\n<\/li>\n<\/ul>\n<div>The request path looks like this:<\/div>\n<div><code data-path-to-node=\"24\" data-index-in-node=\"34\">ChatGPT \u2192 HTTPS MCP endpoint \u2192 Auth0 authentication \u2192 FastMCP tool \u2192 WordPress REST API<\/code><\/div>\n<br>\n<div>The MCP and Redis containers share a private Docker network. Only Nginx Proxy Manager can reach the MCP service from outside that internal network. Redis is never exposed publicly.<\/div>\n<br>\n<h3 data-path-to-node=\"26\">What you need before starting<\/h3>\n<div>You will need:<\/div>\n<br>\n<ul data-path-to-node=\"28\">\n<li>\n<div>a server capable of running Docker Compose;<\/div>\n<\/li>\n<li>\n<div>a domain or subdomain pointed to that server;<\/div>\n<\/li>\n<li>\n<div>Nginx Proxy Manager or another HTTPS reverse proxy;<\/div>\n<\/li>\n<li>\n<div>an Auth0 tenant;<\/div>\n<\/li>\n<li>\n<div>administrator access to each WordPress site during setup;<\/div>\n<\/li>\n<li>\n<div>a WordPress user with only the permissions the integration needs;<\/div>\n<\/li>\n<li>\n<div>ChatGPT access that supports Developer Mode and custom MCP apps.<\/div>\n<\/li>\n<\/ul>\n<div>I used a dedicated subdomain similar to <code data-path-to-node=\"29\" data-index-in-node=\"40\">wpmcp.example.com<\/code>. The examples below use that placeholder\u2014replace it with your own hostname.<\/div>\n<br>\n<h3 data-path-to-node=\"30\">Step 1: Prepare WordPress access<\/h3>\n<div>WordPress includes Application Passwords for API authentication. An Application Password is separate from the user&#8217;s regular WordPress password and can be revoked independently.<\/div>\n<br>\n<div>In WordPress:<\/div>\n<br>\n<ol start=\"1\" data-path-to-node=\"33\">\n<li>\n<div>Create or select the user the integration will use.<\/div>\n<\/li>\n<li>\n<div>Give that user only the role and capabilities required for the intended operations.<\/div>\n<\/li>\n<li>\n<div>Open the user&#8217;s profile.<\/div>\n<\/li>\n<li>\n<div>Find Application Passwords.<\/div>\n<\/li>\n<li>\n<div>Create a password named something recognizable, such as <code data-path-to-node=\"33,4,0\" data-index-in-node=\"56\">WordPress MCP<\/code>.<\/div>\n<\/li>\n<li>\n<div>Store the generated value securely.<\/div>\n<\/li>\n<\/ol>\n<div>Do not place this password in source control, a public document, or a ChatGPT prompt. Repeat this process for each WordPress site you want the MCP server to manage.<\/div>\n<br>\n<h3 data-path-to-node=\"35\">Step 2: Create the DNS record<\/h3>\n<div>Create an <code data-path-to-node=\"36\" data-index-in-node=\"10\">A<\/code> record for the MCP hostname and point it to the public IP address of the Docker server.<\/div>\n<br>\n<div>For example:<\/div>\n<br>\n<ul data-path-to-node=\"38\">\n<li>\n<div><b data-path-to-node=\"38,0,0\" data-index-in-node=\"0\">Type:<\/b> A<\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"38,1,0\" data-index-in-node=\"0\">Name:<\/b> wpmcp<\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"38,2,0\" data-index-in-node=\"0\">Value:<\/b> YOUR_SERVER_IP<\/div>\n<\/li>\n<\/ul>\n<div>Wait until the hostname resolves publicly before requesting the TLS certificate.<\/div>\n<br>\n<h3 data-path-to-node=\"40\">Step 3: Configure Auth0<\/h3>\n<div>Auth0 acts as the identity provider. The WordPress credentials never pass through Auth0; Auth0 only determines who is allowed to use the MCP service.<\/div>\n<br>\n<div>Create a custom Auth0 API with an identifier matching the public MCP origin, for example:<\/div>\n<div><code data-path-to-node=\"42\" data-index-in-node=\"90\">[https:\/\/wpmcp.example.com](https:\/\/wpmcp.example.com)<\/code><\/div>\n<br>\n<div>I defined these application permissions to document the service&#8217;s intended capabilities:<\/div>\n<br>\n<ul data-path-to-node=\"44\">\n<li>\n<div><code data-path-to-node=\"44,0,0\" data-index-in-node=\"0\">wordpress:read<\/code><\/div>\n<\/li>\n<li>\n<div><code data-path-to-node=\"44,1,0\" data-index-in-node=\"0\">wordpress:draft<\/code><\/div>\n<\/li>\n<li>\n<div><code data-path-to-node=\"44,2,0\" data-index-in-node=\"0\">wordpress:taxonomy<\/code><\/div>\n<\/li>\n<\/ul>\n<div>Next, create a Regular Web Application for the MCP server. A server-side application is appropriate because the MCP service securely stores an Auth0 Client Secret.<\/div>\n<br>\n<div>Configure these URLs:<\/div>\n<br>\n<ul data-path-to-node=\"47\">\n<li>\n<div><b data-path-to-node=\"47,0,0\" data-index-in-node=\"0\">Allowed Callback URL:<\/b> <code data-path-to-node=\"47,0,0\" data-index-in-node=\"22\">[https:\/\/wpmcp.example.com\/auth\/callback](https:\/\/wpmcp.example.com\/auth\/callback)<\/code><\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"47,1,0\" data-index-in-node=\"0\">Allowed Logout URL:<\/b> <code data-path-to-node=\"47,1,0\" data-index-in-node=\"20\">[https:\/\/wpmcp.example.com](https:\/\/wpmcp.example.com)<\/code><\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"47,2,0\" data-index-in-node=\"0\">Allowed Web Origin:<\/b> <code data-path-to-node=\"47,2,0\" data-index-in-node=\"20\">[https:\/\/wpmcp.example.com](https:\/\/wpmcp.example.com)<\/code><\/div>\n<\/li>\n<\/ul>\n<div>Enable the Authorization Code flow and refresh tokens. I also enabled Google as an Auth0 social connection, although another supported identity provider could be used.<\/div>\n<br>\n<div>Finally, identify the immutable Auth0 <code data-path-to-node=\"49\" data-index-in-node=\"38\">sub<\/code> value for each person who should be allowed to use the service. The server uses these subject IDs as an explicit allowlist. This is more reliable than authorizing by a changeable email address.<\/div>\n<br>\n<h3 data-path-to-node=\"50\">Step 4: Create the multi-site configuration<\/h3>\n<div>The MCP server reads its WordPress sites from a JSON configuration file mounted read-only into the container.<\/div>\n<br>\n<div>Here is a simplified example:<\/div>\n<br>\n<div class=\"code-block ng-tns-c3845250377-24 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation\" data-hveid=\"0\" data-ved=\"0CAAQhtANahgKEwi6oujn_t2WAxUAAAAAHQAAAAAQkAE\">\n<div class=\"formatted-code-block-internal-container ng-tns-c3845250377-24\">\n<div class=\"animated-opacity ng-tns-c3845250377-24\">\n<div class=\"code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c3845250377-24 ng-star-inserted\"><span class=\"ng-tns-c3845250377-24\">JSON<\/span>\n<div class=\"buttons ng-tns-c3845250377-24 ng-star-inserted\">&nbsp;<\/div>\n<\/div>\n<pre class=\"ng-tns-c3845250377-24\"><code class=\"code-container formatted ng-tns-c3845250377-24\" role=\"text\" data-test-id=\"code-content\">{\n  <span class=\"hljs-attr\">\"sites\"<\/span>: [\n    {\n      <span class=\"hljs-attr\">\"id\"<\/span>: <span class=\"hljs-string\">\"main-blog\"<\/span>,\n      <span class=\"hljs-attr\">\"label\"<\/span>: <span class=\"hljs-string\">\"My Main Blog\"<\/span>,\n      <span class=\"hljs-attr\">\"base_url\"<\/span>: <span class=\"hljs-string\">\"https:\/\/example.com\/blog\"<\/span>,\n      <span class=\"hljs-attr\">\"username\"<\/span>: <span class=\"hljs-string\">\"api-user\"<\/span>,\n      <span class=\"hljs-attr\">\"app_password\"<\/span>: <span class=\"hljs-string\">\"WORDPRESS_APPLICATION_PASSWORD\"<\/span>,\n      <span class=\"hljs-attr\">\"primary_categories\"<\/span>: {\n        <span class=\"hljs-attr\">\"AI &amp; Emerging Technology\"<\/span>: <span class=\"hljs-number\">101<\/span>,\n        <span class=\"hljs-attr\">\"Modern Workplace &amp; IT\"<\/span>: <span class=\"hljs-number\">102<\/span>,\n        <span class=\"hljs-attr\">\"Perspectives\"<\/span>: <span class=\"hljs-number\">103<\/span>,\n        <span class=\"hljs-attr\">\"Guides &amp; Resources\"<\/span>: <span class=\"hljs-number\">104<\/span>,\n        <span class=\"hljs-attr\">\"My Journey\"<\/span>: <span class=\"hljs-number\">105<\/span>\n      }\n    }\n  ]\n}\n<\/code><\/pre>\n<\/div>\n<\/div>\n<\/div>\n<div>Each site has a short <code data-path-to-node=\"54\" data-index-in-node=\"22\">id<\/code> used in tool calls, a human-readable label, its WordPress base URL, its API credentials, and its approved primary categories.<\/div>\n<br>\n<div>The category map also lets the server enforce a one-primary-category rule. When a primary category changes, the server removes any other category from the designated primary set before adding the selected one.<\/div>\n<br>\n<div>Protect this file carefully:<\/div>\n<br>\n<div class=\"code-block ng-tns-c3845250377-25 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation\" data-hveid=\"0\" data-ved=\"0CAAQhtANahgKEwi6oujn_t2WAxUAAAAAHQAAAAAQkQE\">\n<div class=\"formatted-code-block-internal-container ng-tns-c3845250377-25\">\n<div class=\"animated-opacity ng-tns-c3845250377-25\">\n<div class=\"code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c3845250377-25 ng-star-inserted\"><span class=\"ng-tns-c3845250377-25\">Bash<\/span>\n<div class=\"buttons ng-tns-c3845250377-25 ng-star-inserted\">&nbsp;<\/div>\n<\/div>\n<pre class=\"ng-tns-c3845250377-25\"><code class=\"code-container formatted ng-tns-c3845250377-25\" role=\"text\" data-test-id=\"code-content\">chmod 600 config\/sites.json\n<\/code><\/pre>\n<\/div>\n<\/div>\n<\/div>\n<h3 data-path-to-node=\"58\">Step 5: Configure the environment<\/h3>\n<div>Keep operational secrets in a local <code data-path-to-node=\"59\" data-index-in-node=\"36\">.env<\/code> file that is never committed to Git.<\/div>\n<br>\n<div>The configuration includes:<\/div>\n<br>\n<div class=\"code-block ng-tns-c3845250377-26 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation\" data-hveid=\"0\" data-ved=\"0CAAQhtANahgKEwi6oujn_t2WAxUAAAAAHQAAAAAQkgE\">\n<div class=\"formatted-code-block-internal-container ng-tns-c3845250377-26\">\n<div class=\"animated-opacity ng-tns-c3845250377-26\">\n<div class=\"code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c3845250377-26 ng-star-inserted\"><span class=\"ng-tns-c3845250377-26\">Code snippet<\/span>\n<div class=\"buttons ng-tns-c3845250377-26 ng-star-inserted\">&nbsp;<\/div>\n<\/div>\n<pre class=\"ng-tns-c3845250377-26\"><code class=\"code-container formatted ng-tns-c3845250377-26\" role=\"text\" data-test-id=\"code-content\">AUTH0_DOMAIN=YOUR_TENANT.us.auth0.com\nAUTH0_CLIENT_ID=YOUR_AUTH0_CLIENT_ID\nAUTH0_CLIENT_SECRET=YOUR_AUTH0_CLIENT_SECRET\nAUTH0_AUDIENCE=https:\/\/wpmcp.example.com\nPUBLIC_BASE_URL=https:\/\/wpmcp.example.com\nAUTHORIZED_SUBJECTS=AUTH0_SUBJECT_ID_1,AUTH0_SUBJECT_ID_2\n\nJWT_SIGNING_KEY=GENERATED_RANDOM_VALUE\nSTORAGE_ENCRYPTION_KEY=GENERATED_FERNET_KEY\nPREVIEW_SIGNING_KEY=GENERATED_RANDOM_VALUE\n<\/code><\/pre>\n<\/div>\n<\/div>\n<\/div>\n<div>Generate each cryptographic value independently. The JWT key signs the tokens issued by the MCP authorization proxy, the storage key encrypts OAuth state stored in Redis, and the preview key signs short-lived taxonomy confirmation tokens.<\/div>\n<br>\n<div>Protect the environment file:<\/div>\n<br>\n<div class=\"code-block ng-tns-c3845250377-27 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation\" data-hveid=\"0\" data-ved=\"0CAAQhtANahgKEwi6oujn_t2WAxUAAAAAHQAAAAAQkwE\">\n<div class=\"formatted-code-block-internal-container ng-tns-c3845250377-27\">\n<div class=\"animated-opacity ng-tns-c3845250377-27\">\n<div class=\"code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c3845250377-27 ng-star-inserted\"><span class=\"ng-tns-c3845250377-27\">Bash<\/span>\n<div class=\"buttons ng-tns-c3845250377-27 ng-star-inserted\">&nbsp;<\/div>\n<\/div>\n<pre class=\"ng-tns-c3845250377-27\"><code class=\"code-container formatted ng-tns-c3845250377-27\" role=\"text\" data-test-id=\"code-content\">chmod 600 .env\n<\/code><\/pre>\n<\/div>\n<\/div>\n<\/div>\n<h3 data-path-to-node=\"65\">Step 6: Run the service with Docker Compose<\/h3>\n<div>My Docker Compose stack contains two services:<\/div>\n<br>\n<ul data-path-to-node=\"67\">\n<li>\n<div><code data-path-to-node=\"67,0,0\" data-index-in-node=\"0\">wpmcp-server<\/code>, running the Python\/FastMCP application;<\/div>\n<\/li>\n<li>\n<div><code data-path-to-node=\"67,1,0\" data-index-in-node=\"0\">wpmcp-redis<\/code>, storing encrypted OAuth state.<\/div>\n<\/li>\n<\/ul>\n<div>The MCP service listens internally on port 8134. You may use another unused port, but the reverse proxy and health check must match it.<\/div>\n<br>\n<div>The important deployment characteristics are:<\/div>\n<br>\n<ul data-path-to-node=\"70\">\n<li>\n<div>Redis is attached only to an internal Docker network.<\/div>\n<\/li>\n<li>\n<div>The MCP server joins both the internal network and the existing Nginx Proxy Manager network.<\/div>\n<\/li>\n<li>\n<div>No MCP port is published directly on the Docker host.<\/div>\n<\/li>\n<li>\n<div>The WordPress site configuration is mounted read-only.<\/div>\n<\/li>\n<li>\n<div>The containers run with restricted Linux capabilities.<\/div>\n<\/li>\n<li>\n<div>Health checks verify both Redis and the MCP application.<\/div>\n<\/li>\n<\/ul>\n<div>Start the stack:<\/div>\n<br>\n<div class=\"code-block ng-tns-c3845250377-28 ng-animate-disabled ng-trigger ng-trigger-codeBlockRevealAnimation\" data-hveid=\"0\" data-ved=\"0CAAQhtANahgKEwi6oujn_t2WAxUAAAAAHQAAAAAQlAE\">\n<div class=\"formatted-code-block-internal-container ng-tns-c3845250377-28\">\n<div class=\"animated-opacity ng-tns-c3845250377-28\">\n<div class=\"code-block-decoration header-formatted gds-emphasized-body-m ng-tns-c3845250377-28 ng-star-inserted\"><span class=\"ng-tns-c3845250377-28\">Bash<\/span>\n<div class=\"buttons ng-tns-c3845250377-28 ng-star-inserted\">&nbsp;<\/div>\n<\/div>\n<pre class=\"ng-tns-c3845250377-28\"><code class=\"code-container formatted ng-tns-c3845250377-28\" role=\"text\" data-test-id=\"code-content\">docker compose config\ndocker compose up -d --build\ndocker compose ps\n<\/code><\/pre>\n<\/div>\n<\/div>\n<\/div>\n<div>The MCP health endpoint should return a small JSON response indicating that the service is healthy.<\/div>\n<br>\n<h3 data-path-to-node=\"74\">Step 7: Add the proxy host in Nginx Proxy Manager<\/h3>\n<div>In Nginx Proxy Manager, create a new Proxy Host:<\/div>\n<br>\n<ul data-path-to-node=\"76\">\n<li>\n<div><b data-path-to-node=\"76,0,0\" data-index-in-node=\"0\">Domain:<\/b> <code data-path-to-node=\"76,0,0\" data-index-in-node=\"8\">wpmcp.example.com<\/code><\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"76,1,0\" data-index-in-node=\"0\">Scheme:<\/b> <code data-path-to-node=\"76,1,0\" data-index-in-node=\"8\">http<\/code><\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"76,2,0\" data-index-in-node=\"0\">Forward Hostname:<\/b> <code data-path-to-node=\"76,2,0\" data-index-in-node=\"18\">wpmcp-server<\/code><\/div>\n<\/li>\n<li>\n<div><b data-path-to-node=\"76,3,0\" data-index-in-node=\"0\">Forward Port:<\/b> <code data-path-to-node=\"76,3,0\" data-index-in-node=\"14\">8134<\/code><\/div>\n<\/li>\n<\/ul>\n<div>Enable WebSocket support and the usual protection options. Request a Let&#8217;s Encrypt certificate, then enable Force SSL and HTTP\/2.<\/div>\n<br>\n<div>The public endpoints will include:<\/div>\n<br>\n<ul data-path-to-node=\"79\">\n<li>\n<div><code data-path-to-node=\"79,0,0\" data-index-in-node=\"0\">[https:\/\/wpmcp.example.com\/health](https:\/\/wpmcp.example.com\/health)<\/code><\/div>\n<\/li>\n<li>\n<div><code data-path-to-node=\"79,1,0\" data-index-in-node=\"0\">[https:\/\/wpmcp.example.com\/mcp](https:\/\/wpmcp.example.com\/mcp)<\/code><\/div>\n<\/li>\n<\/ul>\n<div>Only the <code data-path-to-node=\"80\" data-index-in-node=\"9\">\/mcp<\/code> endpoint is entered into ChatGPT.<\/div>\n<br>\n<h3 data-path-to-node=\"81\">Step 8: Design tools with safety built in<\/h3>\n<div>The server exposes six focused tools:<\/div>\n<br>\n<ul data-path-to-node=\"83\">\n<li>\n<div><code data-path-to-node=\"83,0,0\" data-index-in-node=\"0\">list_sites<\/code><\/div>\n<\/li>\n<li>\n<div><code data-path-to-node=\"83,1,0\" data-index-in-node=\"0\">test_site<\/code><\/div>\n<\/li>\n<li>\n<div><code data-path-to-node=\"83,2,0\" data-index-in-node=\"0\">list_categories<\/code><\/div>\n<\/li>\n<li>\n<div><code data-path-to-node=\"83,3,0\" data-index-in-node=\"0\">create_draft<\/code><\/div>\n<\/li>\n<li>\n<div><code data-path-to-node=\"83,4,0\" data-index-in-node=\"0\">preview_taxonomy<\/code><\/div>\n<\/li>\n<li>\n<div><code data-path-to-node=\"83,5,0\" data-index-in-node=\"0\">apply_taxonomy<\/code><\/div>\n<\/li>\n<\/ul>\n<div>The safety design is as important as the functionality.<\/div>\n<br>\n<ul data-path-to-node=\"85\">\n<li>\n<div><code data-path-to-node=\"85,0,0\" data-index-in-node=\"0\">list_sites<\/code> never returns credentials.<\/div>\n<\/li>\n<li>\n<div><code data-path-to-node=\"85,1,0\" data-index-in-node=\"0\">test_site<\/code> performs only a read operation.<\/div>\n<\/li>\n<li>\n<div><code data-path-to-node=\"85,2,0\" data-index-in-node=\"0\">create_draft<\/code> hard-codes the WordPress status to <code data-path-to-node=\"85,2,0\" data-index-in-node=\"48\">draft<\/code> and verifies the status returned by WordPress. There is no publish tool.<\/div>\n<\/li>\n<\/ul>\n<div>Taxonomy changes use a two-stage process. <code data-path-to-node=\"86\" data-index-in-node=\"42\">preview_taxonomy<\/code> reads the current post, calculates the proposed categories, and returns a signed token that expires after a short period. <code data-path-to-node=\"86\" data-index-in-node=\"181\">apply_taxonomy<\/code> accepts only that token, verifies that it belongs to the same authenticated user, and refuses to proceed if the post changed after the preview.<\/div>\n<br>\n<div>Every write is also recorded in a server-side audit log.<\/div>\n<br>\n<h3 data-path-to-node=\"88\">Step 9: Connect the MCP server to ChatGPT<\/h3>\n<div>In ChatGPT on the web:<\/div>\n<br>\n<ol start=\"1\" data-path-to-node=\"90\">\n<li>\n<div>Enable Developer Mode in Settings.<\/div>\n<\/li>\n<li>\n<div>Open the Plugins area.<\/div>\n<\/li>\n<li>\n<div>Create a custom app using the MCP URL: <code data-path-to-node=\"90,2,0\" data-index-in-node=\"39\">[https:\/\/wpmcp.example.com\/mcp](https:\/\/wpmcp.example.com\/mcp)<\/code><\/div>\n<\/li>\n<li>\n<div>Select OAuth authentication.<\/div>\n<\/li>\n<li>\n<div>Complete the Auth0 sign-in and consent process.<\/div>\n<\/li>\n<li>\n<div>Allow ChatGPT to scan the MCP tools.<\/div>\n<\/li>\n<li>\n<div>Confirm that the six expected actions are displayed.<\/div>\n<\/li>\n<li>\n<div>Create or save the custom app.<\/div>\n<\/li>\n<\/ol>\n<div>The custom WordPress manager can then be selected in a Work conversation with an <code data-path-to-node=\"91\" data-index-in-node=\"81\">@<\/code> mention.<\/div>\n<br>\n<h3 data-path-to-node=\"92\">Step 10: Validate safely<\/h3>\n<div>Begin with a read-only request:<\/div>\n<br>\n<blockquote data-path-to-node=\"94\"><div>List my configured WordPress sites. Do not create or change anything.<\/div><\/blockquote>\n<div>Then test the WordPress connection for one site and list its categories. Only after those read-only checks succeed should you create a disposable draft.<\/div>\n<br>\n<div>Confirm in WordPress that:<\/div>\n<br>\n<ul data-path-to-node=\"97\">\n<li>\n<div>the post status is Draft;<\/div>\n<\/li>\n<li>\n<div>the title and content are correct;<\/div>\n<\/li>\n<li>\n<div>only the intended primary category is selected;<\/div>\n<\/li>\n<li>\n<div>no post was published;<\/div>\n<\/li>\n<li>\n<div>the write appears in the MCP audit log.<\/div>\n<\/li>\n<\/ul>\n<h3 data-path-to-node=\"98\">What I gained from this approach<\/h3>\n<div>The biggest benefit is not merely speed. It is having a repeatable boundary between conversation and action.<\/div>\n<br>\n<div>I can discuss and refine an article in ChatGPT, then ask the same assistant to create a draft without copying credentials or opening the WordPress editor. I can work from another computer because the integration lives on my server rather than one workstation. I can add more WordPress sites by extending one protected configuration file.<\/div>\n<br>\n<div>Most importantly, the server\u2014not the prompt\u2014enforces the rules that matter to me. Publishing is unavailable. Credentials stay private. Taxonomy changes require confirmation. Access is restricted to approved Auth0 identities. Writes are logged.<\/div>\n<br>\n<div>That is the real value of MCP for me: not giving AI unlimited access, but giving it a small set of useful, well-governed tools.<\/div>\n<\/div>\n\n\n\n<h2 class=\"wp-block-heading\">Reference documentation<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/developers.openai.com\/plugins\/build\/auth\">OpenAI: Authenticate users in MCP plugins<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developers.openai.com\/plugins\/build\/mcp-server\">OpenAI: Build an MCP server<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.wordpress.org\/rest-api\/using-the-rest-api\/authentication\/\">WordPress REST API: Authentication<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.wordpress.org\/rest-api\/reference\/posts\/\">WordPress REST API: Posts<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/gofastmcp.com\/servers\/auth\/oidc-proxy\">FastMCP: OIDC Proxy<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/auth0.com\/docs\/\">Auth0 documentation<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>How I built a secure, self-hosted MCP server that connects ChatGPT to multiple WordPress sites while enforcing OAuth authentication, draft-only publishing, controlled taxonomy changes, and centralized configuration.<\/p>\n","protected":false},"author":20,"featured_media":461826,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_kad_blocks_custom_css":"","_kad_blocks_head_custom_js":"","_kad_blocks_body_custom_js":"","_kad_blocks_footer_custom_js":"","ngg_post_thumbnail":0,"episode_type":"","audio_file":"","podmotor_file_id":"","podmotor_episode_id":"","cover_image":"","cover_image_id":"","duration":"","filesize":"","filesize_raw":"","date_recorded":"","explicit":"","block":"","itunes_episode_number":"","itunes_title":"","itunes_season_number":"","itunes_episode_type":"","_kad_post_transparent":"","_kad_post_title":"","_kad_post_layout":"","_kad_post_sidebar_id":"","_kad_post_content_style":"","_kad_post_vertical_padding":"","_kad_post_feature":"","_kad_post_feature_position":"","_kad_post_header":false,"_kad_post_footer":false,"_kad_post_classname":"","footnotes":""},"categories":[1071],"tags":[930,1079,842,1078,989,569],"class_list":["post-522146","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-guides-resources","tag-ai-series","tag-ai-skills","tag-chatgpt","tag-mcp","tag-vibe-coding","tag-wordpress"],"taxonomy_info":{"category":[{"value":1071,"label":"Guides &amp; Resources"}],"post_tag":[{"value":930,"label":"AI Series"},{"value":1079,"label":"AI Skills"},{"value":842,"label":"ChatGPT"},{"value":1078,"label":"mcp"},{"value":989,"label":"Vibe Coding"},{"value":569,"label":"WordPress"}]},"featured_image_src_large":["https:\/\/jorgep.com\/blog\/wp-content\/uploads\/jorgep-BlogPostGeneric.jpg?t=1789045868",1024,512,false],"author_info":{"display_name":"Assistant 01","author_link":"https:\/\/jorgep.com\/blog\/author\/chatgptplus01\/"},"comment_info":0,"category_info":[{"term_id":1071,"name":"Guides &amp; Resources","slug":"guides-resources","term_group":0,"term_taxonomy_id":1081,"taxonomy":"category","description":"","parent":0,"count":167,"filter":"raw","cat_ID":1071,"category_count":167,"category_description":"","cat_name":"Guides &amp; Resources","category_nicename":"guides-resources","category_parent":0}],"tag_info":[{"term_id":930,"name":"AI Series","slug":"ai-series","term_group":0,"term_taxonomy_id":940,"taxonomy":"post_tag","description":"","parent":0,"count":244,"filter":"raw"},{"term_id":1079,"name":"AI Skills","slug":"ai-skills","term_group":0,"term_taxonomy_id":1089,"taxonomy":"post_tag","description":"","parent":0,"count":1,"filter":"raw"},{"term_id":842,"name":"ChatGPT","slug":"chatgpt","term_group":0,"term_taxonomy_id":852,"taxonomy":"post_tag","description":"","parent":0,"count":20,"filter":"raw"},{"term_id":1078,"name":"mcp","slug":"mcp","term_group":0,"term_taxonomy_id":1088,"taxonomy":"post_tag","description":"","parent":0,"count":1,"filter":"raw"},{"term_id":989,"name":"Vibe Coding","slug":"vibe-coding","term_group":0,"term_taxonomy_id":999,"taxonomy":"post_tag","description":"","parent":0,"count":17,"filter":"raw"},{"term_id":569,"name":"WordPress","slug":"wordpress","term_group":0,"term_taxonomy_id":579,"taxonomy":"post_tag","description":"","parent":0,"count":49,"filter":"raw"}],"_links":{"self":[{"href":"https:\/\/jorgep.com\/blog\/wp-json\/wp\/v2\/posts\/522146","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/jorgep.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/jorgep.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/jorgep.com\/blog\/wp-json\/wp\/v2\/users\/20"}],"replies":[{"embeddable":true,"href":"https:\/\/jorgep.com\/blog\/wp-json\/wp\/v2\/comments?post=522146"}],"version-history":[{"count":2,"href":"https:\/\/jorgep.com\/blog\/wp-json\/wp\/v2\/posts\/522146\/revisions"}],"predecessor-version":[{"id":522149,"href":"https:\/\/jorgep.com\/blog\/wp-json\/wp\/v2\/posts\/522146\/revisions\/522149"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/jorgep.com\/blog\/wp-json\/wp\/v2\/media\/461826"}],"wp:attachment":[{"href":"https:\/\/jorgep.com\/blog\/wp-json\/wp\/v2\/media?parent=522146"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/jorgep.com\/blog\/wp-json\/wp\/v2\/categories?post=522146"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/jorgep.com\/blog\/wp-json\/wp\/v2\/tags?post=522146"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}