Skip to content
WP EngineDocumentation

MCP server design

The Power MCP service is designed for stateless, account-scoped access to WordPress sites via Model Context Protocol. This guide explains the key architectural decisions and their tradeoffs.

The MCP server retains no state between requests. Each tools/call is independent:

  • No server-side session storage
  • No connection pooling or persistent WebSocket state
  • The client passes all context (Bearer token, tool arguments) on every request

Why stateless?

  • Horizontal scaling — any gateway instance can serve any request; no sticky sessions
  • Simpler failure model — no session cleanup, no stale state, no “reconnect” flows
  • MCP spec alignment — Streamable HTTP is inherently stateless; GET/DELETE are optional

Tradeoff: Clients must include full context on every call (token, site_id, etc.). This is negligible overhead for typical MCP workflows.

The server uses Streamable HTTP (POST-only, JSON-RPC over HTTP) instead of SSE or stdio.

Why Streamable HTTP?

  • Firewall-friendly — standard HTTPS, no custom protocols or persistent connections
  • CDN-compatible — requests are cacheable (though we don’t cache them in practice)
  • Language-agnostic — any HTTP client works; no special runtime dependencies
  • MCP native — one of the three MCP-specified transports, alongside SSE and stdio

Tradeoff: No server-initiated messages. The client must poll for updates (not relevant for our tool surface, which is synchronous).

3. Account-scoped connection, per-call site IDs

Section titled “3. Account-scoped connection, per-call site IDs”

The MCP connection is scoped to an account (or project, for API keys). Sites are per-call tool arguments, not pinned at connect time.

Example: Jira MCP model — you connect to “Jira”, then pass a project key on each search_issues call. We connect to “Power”, then pass site_id on each run_site_ability call.

Alternative rejected: Notion MCP model — one connection per database; switching databases means a new initialize handshake. This would require separate MCP server entries per site or a stateful set_site command.

Why per-call site IDs?

  • Multi-site workflows — create a post on site A, then site B, in the same conversation
  • Simpler client config — one server entry in claude_desktop_config.json, not one per site
  • OAuth alignment — OAuth tokens are account-scoped; they naturally surface all sites at once

Tradeoff: Every tool call must include site_id. This is standard for resource-oriented APIs and matches user mental models (Jira, GitHub, etc.).

The public API surface exposes accounts and sites, not projects. Projects are internal plumbing for:

  • API key scoping
  • Resource organization (future)
  • Billing (future)

Why hide projects?

  • Simpler UX — users think in “my sites” and “my account”, not “my project’s sites”
  • OAuth compatibility — OAuth tokens are account-scoped; adding a project picker would add complexity
  • Fewer concepts — MCP clients don’t need to understand projects until they’re user-facing in the Power console

Tradeoff: API keys are project-scoped, so a multi-project account sees different sites with different keys. OAuth (when it lands) will surface all sites account-wide.

Phase 1 (now): API keys

  • Personal keys (bound to minting user’s Okta sub) for ability execution
  • Project keys (no acting user) for navigation tools only
  • No external dependencies; ships immediately

Phase 2 (future): OAuth 2.1

  • Human interactive consent with account picker
  • RFC 9728 protected-resource metadata (/.well-known/oauth-protected-resource/v1beta/mcp)
  • MCP clients discover the authorization server and perform DCR + PKCE

Why API keys first?

  • No cross-team blocker — Phase 1 uses existing key infrastructure
  • Dogfood abilities now — ability execution requires acting user, but personal keys bind to one
  • OAuth is the target UX — native clients (Claude Desktop) benefit from interactive consent; API keys are a stepping stone
Type Acting user Can run abilities Intended use
Personal Key owner’s Okta sub Humans using MCP clients
Project None Service accounts, read-only automation

Why personal keys for abilities?

  • Audit trail — abilities log who executed them (the key owner)
  • Least privilege — a shared project key doesn’t inherit the minter’s personal WordPress account connections
  • Blast radius — revoking a personal key affects one user, not a whole service

Tradeoff: Users must mint a personal key (not just use a project key). This is acceptable for human workflows and aligns with GitHub PATs.

Every tool verifies the target site belongs to the caller’s authorized scope:

  • API keys: site.project_id = key.project_id AND site.account_id = key.account_id
  • OAuth (future): site.account_id = token.account_id

This is the IDOR guard. A site ID from outside your scope returns site_not_found (same as a missing site, to avoid cross-account disclosure).

Gating on the WordPress account connection

Section titled “Gating on the WordPress account connection”

Ability tools (list_site_abilities, run_site_ability) additionally require:

  • The authenticated user (from personal key owner_subject or OAuth sub)
  • Has an active wp_user_connections row for the target site
  • The connection is not stale (needs_reconnect)

This ensures you’ve explicitly authorized the gateway to act on your behalf for that site.

Why gate list_site_abilities (not just run_site_ability)?

  • Avoids exposing the ability surface to users who can’t act on it
  • Sidesteps empty-catalog UX — the shared ability cache is first populated when someone connects their WordPress account, so there may be nothing to list before anyone has
  • Possible relaxation: serve list_site_abilities from the shared cache (under account scope), with only run_site_ability requiring a WordPress account connection
Tool Auth level Purpose
ping Bearer token only Health check
list_account_sites Site connection Navigate sites
search_abilities WordPress account connection (per-site) Find abilities by keyword
list_site_abilities WordPress account connection List all abilities for a site
run_site_ability WordPress account connection Execute a WordPress ability

No list_projects, list_collections, or search_kb in MVP. Projects stay internal; collections are resolved via site.kb_collection_id when KB tools land.

Each tool is a pure function of its arguments and the Bearer token. No “select site” or “set project” commands; every call is self-contained.

Benefits:

  • No forgotten state — you can’t “forget” which site is active
  • Parallel calls — run abilities on site A and site B concurrently
  • Simpler client logic — no session management, no stale-state bugs

Tradeoff: Slightly more verbose calls (every call includes site_id). This is idiomatic for resource-oriented APIs.

Ability catalogs (the list of available abilities on a site) are cached on the gateway once someone connects their WordPress account. search_abilities reads the cache; it never syncs a site inline.

Why cached?

  • Fast search — keyword scoring across 100 sites in milliseconds
  • Predictable latency — search doesn’t depend on WordPress site response time
  • Reduced load — WordPress sites aren’t hit on every search

Tradeoff: Stale catalogs. If a site’s ability surface changes (new plugin installed), the cache is stale until:

  • The site pushes an update (future webhook)
  • list_site_abilities is called, forcing a sync

search_abilities returns degraded_sites when catalogs are stale; the client can refresh them explicitly.

Sites are identified by site_id, returned by list_account_sites. Pass it to every site-scoped tool.

client_id — the OAuth DCR client ID — used to be the identifier here. It named the credential, not the site: disconnecting and reconnecting a site issues a new client_id, so the same WordPress install came back as a different resource. site_id survives that, which makes it the identifier you can store.

The published schema requires site_id. The tools still accept client_id as an optional deprecated argument, and still accept either identifier under either argument name, so an integration that sends a credential id as site_id keeps working. Do not use client_id in new code.

Resolution is by value, not by argument name. A site_-prefixed value is looked up as a site first and falls back to a credential lookup; anything else is looked up as a credential. client_id is issued by the OAuth server with no format guarantee, so a client_id that happens to look like a site_id still resolves correctly. When a call sends both, site_id wins.

When OAuth support is added, the gateway will:

  • Serve RFC 9728 metadata at /.well-known/oauth-protected-resource/v1beta/mcp
  • Return 401 + WWW-Authenticate for unauthenticated requests
  • Verify OAuth tokens with the existing verification system

Account-scoped OAuth sessions will see all sites in the account (unlike project-scoped API keys).

search_kb(site_id, query) will resolve the site’s kb_collection_id and query Elasticsearch. The caller never sees collection IDs; they’re internal.

Future tools (long-running abilities, incremental search) may use SSE for progress updates. The transport will remain Streamable HTTP; SSE is a response format, not a connection type.

The MCP server is built within these constraints:

  • No session state — every request is independent
  • Single account endpoint — no per-project or per-site URLs
  • OAuth resource server only — the MCP server verifies tokens but does not issue them
  • Stateless transport — Streamable HTTP, no persistent WebSocket or SSE connections
  • Projects are internal — users see accounts and sites, not projects

These constraints enforce a simple, scalable, firewall-friendly architecture that aligns with MCP conventions and OAuth 2.1 patterns.

Last updated: