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.
Core principles
Section titled “Core principles”1. Stateless sessions
Section titled “1. Stateless sessions”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.
2. Streamable HTTP transport
Section titled “2. Streamable HTTP transport”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.).
4. Projects are internal
Section titled “4. Projects are internal”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.
Authentication model
Section titled “Authentication model”Two phases
Section titled “Two phases”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
Personal vs project keys
Section titled “Personal vs project keys”| 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.
Authorization model
Section titled “Authorization model”Per-call containment
Section titled “Per-call containment”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_subjector OAuthsub) - Has an active
wp_user_connectionsrow 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_abilitiesfrom the shared cache (under account scope), with onlyrun_site_abilityrequiring a WordPress account connection
Tool surface
Section titled “Tool surface”MVP tools
Section titled “MVP tools”| 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.
Why stateless tools?
Section titled “Why stateless tools?”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.
Cached ability catalogs
Section titled “Cached ability catalogs”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_abilitiesis called, forcing a sync
search_abilities returns degraded_sites when catalogs are stale; the client can refresh them explicitly.
Identifiers
Section titled “Identifiers”site_id, not client_id
Section titled “site_id, not client_id”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.
Future directions
Section titled “Future directions”OAuth integration
Section titled “OAuth integration”When OAuth support is added, the gateway will:
- Serve RFC 9728 metadata at
/.well-known/oauth-protected-resource/v1beta/mcp - Return
401 + WWW-Authenticatefor 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).
KB search
Section titled “KB search”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.
Streaming
Section titled “Streaming”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.
Design constraints
Section titled “Design constraints”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.