Especificação funcional do plugin emcp-tools v3.12.1 (msrbuilds/elementor-mcp, GPL-2.0-or-later) via leitura directa do código-fonte em emanuelalmeida.pt. - 00: arquitectura (bootstrap, ability registrar, dispatcher, MCP adapter) - 01: Elementor classico (paginas, layout, widgets, templates, globals) - 02: Elementor Atomic v4 + Gutenberg - 03: WordPress core (conteudo, media, settings, temas) - 04: Themer (CPT, condicoes, render, PHP templates) - 05: Redirects + change ledger unificado (rollback) - 06: Sandbox PHP snippets + custom widgets - 07: Filesystem/DB/WP-CLI/Security/Performance (maior risco) - 08: Integracoes terceiros (ACF, Meta Box, forms, SEO) - 09: Stock images + Cloud + OAuth - 10: Sistema de modulos + inventario Pro-only (30 classes) - INDEX: sintese, sequencia de construcao, tabela de risco Produzido por 10 subagentes code-explorer em paralelo + revisao cruzada de consistencia. Cada doc inclui blueprint de replica (copiar/simplificar/omitir).
46 KiB
09 — Stock Images, EMCP Cloud e Servidor OAuth
Fonte: leitura directa do código-fonte emcp-tools v3.12.1 (build Free), instalado em
emanuelalmeida.pt (/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/),
19-08-2026. Cobre 3 subsistemas relacionados mas independentes entre si: (1) busca e
sideload de imagens de stock, (2) sincronização com o serviço SaaS "EMCP Cloud"
(emcptools.com), e (3) um servidor de autorização OAuth 2.1 completo, próprio do plugin,
que serve de mecanismo de autenticação alternativo ao Application Password para clientes
MCP remotos. Este terceiro subsistema não é exclusivo do Cloud — é infra-estrutura MCP
genérica, e o Cloud é apenas um dos consumidores (via EMCP_Tools_Gateway_Credential, ver
§2.2.6).
1. Stock Images — busca e sideload de imagens (Unsplash / Pexels / Pixabay)
1.1 EMCP_Tools_Stock_Image_Abilities — includes/abilities/class-stock-image-abilities.php
Condição de registo (excerto exacto de class-ability-registrar.php) — as 2 tools de
provider registam-se sempre (não dependem do Elementor); a 3ª só quando o Elementor está
activo:
// Stock-image provider tools (search-images + sideload-image) — pure WP core
// (a stock-provider search + a Media Library sideload), no Elementor needed,
// so they register on any site. add-stock-image (adds a widget) is gated below.
$stock_images = new EMCP_Tools_Stock_Image_Abilities( $this->data, $this->factory );
$stock_images->register_provider_tools();
$this->ability_names = array_merge( $this->ability_names, $stock_images->provider_tool_names() );
// ---- Elementor-dependent groups: only when Elementor is active ----
if ( $elementor_active ) {
// ... (outros grupos) ...
// Stock images: the add-stock-image widget tool (provider search + sideload
// registered unconditionally above); this one adds an image widget so it
// needs Elementor.
$stock_images->register_widget_tool();
$this->ability_names[] = 'emcp-tools/add-stock-image';
}
| Ability | input_schema (resumido) |
O que faz | permission_callback |
Anotações |
|---|---|---|---|---|
search-images |
query (str, obrig.), provider (enum unsplash|pexels|pixabay, opcional — omitido usa o 1º provider com chave configurada), page (int), page_size (int), aspect_ratio (enum tall|wide|square) |
Pesquisa um provider de stock e devolve resultados normalizados: {provider, result_count, page, page_count, results:[{id,title,url,thumbnail,width,height,creator,creator_url,license,license_url,attribution,source,foreign_landing_url}]}. Delega em EMCP_Tools_Stock_Image_Providers::resolve() para escolher o cliente, depois num run_search() privado partilhado com add-stock-image que mapeia o resultado do provider para a forma estável do output_schema. |
check_read_permission() → current_user_can('edit_posts') |
readonly:true, destructive:false, idempotent:true |
sideload-image |
url (str, obrig. — deve ser o URL exacto devolvido por search-images, nunca construído/editado à mão), title, alt_text, caption, attribution, convert_webp (bool) |
Descarrega um URL externo para a Media Library via EMCP_Tools_Url_Guard::safe_download() (SSRF-guarded — ver §4.2) e devolve {attachment_id, url, title}. Antes de descarregar, se o URL for o endpoint de tracking do Unsplash (api.unsplash.com/.../download), resolve-o automaticamente para o URL de imagem real via EMCP_Tools_Unsplash_Client::resolve_download() — ver gotcha nº1 no blueprint. Em caso de falha, a mensagem de erro inclui uma sugestão accionável específica (URL da API do Unsplash / 404 / 401-403) para corrigir o comportamento de um agente que insista no mesmo URL errado. convert_webp:false desactiva a compressão/WebP do módulo Image Optimization só para este upload (via filtro emcp_tools_optimize_attachment). |
check_upload_permission() → current_user_can('upload_files') |
readonly:false, destructive:false, idempotent:false |
add-stock-image |
post_id (int, obrig.), parent_id (str, obrig. — ID do container Elementor), query (str, obrig.), provider, index (int, 0=melhor resultado), position (int, -1=append), image_size (enum), align (enum left|center|right), caption, aspect_ratio (default wide), alt_text, link_to (enum none|file|custom), convert_webp |
Compõe search-images → escolhe o resultado no index → dispara trigger_download() do provider (guideline Unsplash) → sideload-image → cria um widget image Elementor e insere-o em parent_id via EMCP_Tools_Data::insert_element() + save_page_data(). Devolve {attachment_id, image_url, element_id, original_url, attribution, provider}. Por omissão filtra por aspect_ratio=wide (paisagem) — melhor compatibilidade de layout. |
check_combined_permission() → edit_posts e upload_files e (se post_id presente) edit_post($post_id) |
readonly:false, destructive:false, idempotent:false |
1.2 EMCP_Tools_Stock_Image_Providers — includes/class-stock-image-providers.php
Registry + resolver estático, sem estado próprio. map() define os 3 providers e a
ordem de prioridade de fallback (unsplash → pexels → pixabay) quando nenhum é
pedido explicitamente. Cada entrada do mapa só tem label + nome da classe cliente.
has_key($id)→ delega em{Client}::has_key()estático.available()→ lista de ids com chave configurada, pela ordem do mapa.resolve($requested = '')→ devolve[id, client]ouWP_Error:- se
$requestednão vazio: valida que existe no mapa e que tem chave, senão errounknown_provider/no_api_key(mensagem inclui o link para obter chave grátis). - se vazio: usa o primeiro de
available(), ouno_api_keyse nenhum estiver configurado.
- se
Este é o único ponto de acoplamento entre as 3 tools e os 3 clientes — trocar/adicionar um
provider é adicionar uma entrada ao map().
1.3 Clientes HTTP dos 3 providers — includes/class-{unsplash,pexels,pixabay}-client.php
Todos seguem o mesmo contrato implícito (duck-typed, sem interface PHP formal):
const OPTION (nome da option em wp_options), access_key() estático (constante PHP
vence, senão a option — sempre decifrada via EMCP_Tools_Secret::decrypt_if_needed(),
ver §4.1), has_key(), instância search_images(array $params), instância
trigger_download(string $download_location).
| Cliente | Endpoint base | Auth header | Opção wp_options / constante PHP | orientation/aspect_ratio mapping |
Particularidades |
|---|---|---|---|---|---|
EMCP_Tools_Unsplash_Client |
https://api.unsplash.com |
Authorization: Client-ID <key> |
emcp_tools_unsplash_access_key / EMCP_TOOLS_UNSPLASH_ACCESS_KEY |
wide→landscape, tall→portrait, square→squarish |
Único com trigger_download() REAL (guideline obrigatória da API Unsplash: disparar o links.download_location do resultado escolhido). resolve_download() resolve o endpoint de tracking api.unsplash.com/photos/<id>/download para o URL de imagem real (ver gotcha §Blueprint). url devolvido é urls.regular (~1080px), não full/raw. Attribution obrigatória: creator_url recebe sempre ?utm_source=emcp_tools&utm_medium=referral (guideline Unsplash). 401→invalid_key; 403→rate_limited ("demo apps allow 50 requests/hour"). |
EMCP_Tools_Pexels_Client |
https://api.pexels.com/v1 |
Authorization: <key> (SEM prefixo Bearer) |
emcp_tools_pexels_api_key / EMCP_TOOLS_PEXELS_API_KEY |
wide→landscape, tall→portrait, square→square |
trigger_download() é no-op (Pexels não tem endpoint de tracking). url = src.large2x (fallback large/original). 401/403→invalid_key; 429→rate_limited ("200 requests/hour on the free tier"). |
EMCP_Tools_Pixabay_Client |
https://pixabay.com/api/ |
chave na query string (key=), não em header |
emcp_tools_pixabay_api_key / EMCP_TOOLS_PIXABAY_API_KEY |
wide→horizontal, tall→vertical (sem square — Pixabay não tem essa orientação) |
per_page restrito a 3-200 (min 3, diferente dos outros 2). Fixa sempre image_type=photo&safesearch=true. trigger_download() no-op; nota no cabeçalho do ficheiro: "Pixabay's terms require downloading/caching images rather than hotlinking — the sideload step satisfies that." 400/401→invalid_key; 429→rate_limited ("100 requests/minute"). |
Todos: TIMEOUT=15, user-agent: Elementor-MCP/<versão> (WordPress/<versão>), resposta
normalizada para a mesma forma de campo (normalize_photo() privado por classe) antes de
chegar às abilities — as abilities nunca lidam com a forma nativa de nenhuma API.
2. EMCP Cloud — sincronização remota com emcptools.com
2.1 EMCP_Tools_Cloud_Abilities — includes/abilities/class-cloud-abilities.php
Cabeçalho do próprio ficheiro: "Free tree. Registered only when the site is connected to EMCP Cloud (the Cloud module is active and a token bundle is stored)." — ou seja, ao contrário da maioria dos outros grupos Pro-only deste plugin, isto é uma feature Free, só gated pelo estado de ligação, não por licença.
Condição de registo (excerto exacto):
// EMCP Cloud sync tools — only when the site is connected to a cloud account.
if ( class_exists( 'EMCP_Tools_Cloud_Abilities' ) && class_exists( 'EMCP_Tools_Cloud_Module' )
&& EMCP_Tools_Cloud_Module::is_enabled() && EMCP_Tools_Cloud::is_connected() ) {
$cloud_sync = new EMCP_Tools_Cloud_Abilities();
$cloud_sync->register();
$this->ability_names = array_merge( $this->ability_names, $cloud_sync->get_ability_names() );
}
EMCP_Tools_Cloud_Module::is_enabled() lê emcp_tools_active_modules (módulo cloud,
default_active()=true — activo por omissão em todos os sites). EMCP_Tools_Cloud::is_connected()
exige um token bundle guardado (ver §2.2.1) — nos 3 sites do ecossistema Descomplicar
verificados em skill://emcp-tools, nenhum está ligado a uma conta Cloud, logo estas 7
tools nunca aparecem na prática nesse ambiente.
| Ability | input_schema (resumido) |
O que faz | Anotações |
|---|---|---|---|
cloud-status |
{} |
GET /api/cloud/v1/me — plano, limites e uso da conta ligada. |
readonly:true, destructive:false |
cloud-backup |
kind (enum block|widget|snippet, obrig.), id (int, obrig.) |
Serializa um artefacto de sandbox local (ver doc 06) num "bundle" com checksum e faz PUT /api/cloud/v1/artifacts. |
readonly:false, destructive:false, idempotent:true |
cloud-list |
kind (opcional) |
GET /api/cloud/v1/artifacts[?kind=] — lista artefactos já guardados na conta. |
readonly:true, destructive:false |
cloud-pull |
artifact_uuid (str, obrig.), kind (opcional) |
GET /api/cloud/v1/artifacts/{uuid}, decodifica o bundle e importa-o como novo draft local inactivo (nunca substitui um artefacto existente). |
readonly:false, destructive:false |
cloud-config-sync |
type (enum settings|brand_kit|tool_toggles, obrig.), direction (enum push|pull, obrig.), data (objecto, só para push) |
Push/pull de um blob de configuração arbitrário via /api/cloud/v1/config/{type}. Usado internamente também por EMCP_Tools_Settings_Sync (§2.2.7) com type=settings. |
readonly:false, destructive:false |
cloud-marketplace-list |
category (opcional) |
GET /api/cloud/v1/marketplace[?category=] — navega listings publicados (públicos, não exige ligação embora a tool em si exija manage_options). |
readonly:true, destructive:false |
cloud-marketplace-install |
slug (str, obrig.) |
POST /api/cloud/v1/marketplace/{slug}/install, decodifica o bundle devolvido e importa como novo draft local. |
readonly:false, destructive:false |
Todas as 7 exigem current_user_can('manage_options'). Todas passam pelo padrão comum
execute_*($input) → EMCP_Tools_Cloud_Sync::<método>() → is_wp_error() ? $r : (array) $r.
2.2 Serviços de suporte (includes/cloud/)
2.2.1 EMCP_Tools_Cloud — class-cloud.php (config + storage estático, sem rede)
base_url()— constanteEMCP_TOOLS_CLOUD_URL> optionemcp_tools_cloud_base_url> defaulthttps://emcptools.com; filtrável (emcp_tools_cloud_base_url) para staging/self-host.site_uuid()— UUID v4 estável por site,wp_generate_uuid4(), mintado lazy no primeiroget_option()e persistido ememcp_tools_site_uuid.save_connection(array $bundle)/get_connection()— o bundle{access_token, refresh_token, access_expires_at, client_id, connected_at}é serializado em JSON e guardado cifrado (EMCP_Tools_Secret::encrypt(), §4.1) na optionemcp_tools_cloud_connection.is_connected()—!empty(access_token) || !empty(refresh_token).SCOPES = 'openid cloud offline_access'— pedidos ao IdP da Cloud (não confundir com oSCOPE='mcp'do servidor OAuth deste plugin, §3 — são dois sistemas OAuth distintos: um em que este site é cliente do IdP da Cloud, outro em que este site é o servidor).
2.2.2 EMCP_Tools_Cloud_Connect — class-cloud-connect.php (cliente OAuth do site contra a Cloud como IdP)
Este site actua como cliente PKCE público contra o servidor OAuth de emcptools.com
(o mesmo padrão — DCR → authorize → PKCE S256 → token — usado por EMCP_Tools_OAuth_*
quando o papel é o inverso, ver §3). Sequência completa:
register_client()— DCR:POST {cloud}/api/auth/oauth2/registercomredirect_uris=[redirect_uri()](=admin-post.php?action=emcp_tools_cloud_callback),token_endpoint_auth_method=none,client_name=bloginfo('name').authorize_url($client_id, $verifier, $csrf)— constrói o URL de autorização comcode_challengeS256 derivado do$verifier, estate= base64url de um JSON{site_uuid, name, csrf}(o$csrfembutido no state protege contra CSRF sem precisar de um cookie de sessão — porque o browser navega para outro domínio e volta).handle_connect()(admin-post, nonce-protegido) — faz DCR, gera verifier+csrf, guarda num transient de 600s (emcp_tools_cloud_pending), e redireciona o browser. Comowp_safe_redirect()bloqueia hosts externos por omissão, adiciona o host da Cloud aallowed_redirect_hostssó para este redirect deliberado.handle_callback()(admin-post) — valida ostatedevolvido em tempo constante (EMCP_Tools_OAuth_Util::secure_equals) contra o CSRF guardado, troca ocodepor tokens (exchange_code()), e — se o utilizador marcou o opt-in de gateway no formulário — provisiona automaticamente umaEMCP_Tools_Gateway_Credential(§2.2.6), best-effort (uma falha aqui nunca transforma a ligação Cloud, já bem-sucedida, num erro visível).refresh()— ver bloco dedicado abaixo, é a peça mais elaborada do ficheiro.handle_disconnect()— desprovisiona o gateway, revoga remotamente (revoke_remote()), limpa a ligação local.
refresh() — mitigação de corrida em rotação de refresh token. Comentário extenso no
próprio código explica o problema: o IdP da Cloud (Better Auth) rota o refresh token a
cada uso — cada sucesso emite um novo refresh token e invalida o anterior. Duas requests
WordPress concorrentes (segundo separador de admin, um heartbeat, uma chamada MCP) que
ambas vejam o access token expirado apresentariam o MESMO refresh token; a primeira ganha e
rota-o, a segunda é rejeitada com invalid_grant — e ingenuamente marcaria a ligação como
"unhealthy", sobrescrevendo o bundle recém-rodado com o token morto (bug real que se
manifestaria como "Reconnect needed" espúrio). Mitigação em 4 camadas:
(1) mutex best-effort via SELECT GET_LOCK() do MySQL (db_lock/db_unlock, degradação
graciosa para no-op se $wpdb ausente — testes unitários); (2) double-checked locking —
volta a ler o bundle depois de obter o lock e sai cedo se outra request já refrescou;
(3) uma rejeição de auth que coincide com uma rotação concorrente (refresh_token guardado
mudou, ou o access token voltou a estar fresco) é tratada como sucesso, nunca
sobrescreve o bundle bom; (4) falhas de rede/5xx são tratadas como transitórias e NUNCA
marcam a ligação como unhealthy.
2.2.3 EMCP_Tools_Cloud_Http — class-cloud-http.php (transporte fino)
post_json(), post_form(), request($method,...) — todos delegam num send() privado
que usa wp_remote_post/wp_remote_request (timeout=20, sslverify=true). Tem um seam de
teste injectável (set_transport(callable)) que permite mockar toda a rede em testes
unitários sem tocar em wp_remote_*.
2.2.4 EMCP_Tools_Cloud_Sync — class-cloud-sync.php (camada aplicacional)
Traduz operações de negócio para chamadas ao Cloud_Client autenticado (§2.2.5):
status(), list_remote(), backup(), pull(), push_config(), pull_config(),
marketplace_list(), marketplace_install(), marketplace_publish(),
marketplace_state(), push_update(). Note-se abilities(): EMCP_Tools_Sandbox_Cloud_Abilities
— um nome confuso por semelhança: esta classe (documentada em detalhe no doc 06, não
aqui) não é uma ability MCP, é o helper local de serialização de bundle (to_bundle(),
apply_bundle(), uuid()) partilhado entre as tools locais export-sandbox-artifact/
import-sandbox-artifact (sem rede, doc 06) E as tools de rede cloud-backup/cloud-pull
(este doc) — o mesmo formato de bundle serve os dois casos de uso.
bulk_backup(array $kinds = []) — o contraparte em massa de backup(); itera todos os
posts das 3 CPTs de sandbox (kind_post_types(): snippet/widget/block →
emcp_php_snippet/emcp_widget/emcp_block) e chama backup() um a um. Não está
exposta como MCP tool (não há cloud-bulk-backup em Cloud_Abilities) — só é usada pela
UI de admin.
marketplace_publish()/marketplace_state()/push_update() também não estão expostas
como MCP tools — só marketplace_list/marketplace_install o estão; o resto é
funcionalidade de admin (submissão de listings ao marketplace).
2.2.5 EMCP_Tools_Cloud_Client — class-cloud-client.php (REST autenticado)
valid_access_token() — devolve o access token guardado, refrescando primeiro
(Cloud_Connect::refresh()) se estiver a menos de LEEWAY=60s de expirar. get/put/delete/ request() genéricos, todos Authorization: Bearer <token> + JSON. Nota no código: "Astro's
form-CSRF guard exempts JSON, so no Origin header is needed here (unlike the token
endpoint)" — ou seja o Cloud_Connect usa Origin header nos POSTs form-encoded ao token
endpoint, mas este cliente usa corpo JSON e não precisa. put_gateway_credential()/
delete_gateway_credential() são específicos do fluxo Gateway (§2.2.6).
2.2.6 EMCP_Tools_Gateway_Credential — class-gateway-credential.php ("Phase 1 hosted multi-site gateway")
Esta é a ponte directa entre o subsistema Cloud e o subsistema OAuth (§3). Comentário no cabeçalho: "Phase 1 of the hosted multi-site gateway: each site can self-issue a revocable refresh token against its OWN OAuth server, bound to a single, idempotently-provisioned client... Reuses the existing OAuth persistence layer (EMCP_Tools_OAuth_Store)."
Mecanismo: o site cria (ou reusa, se já existir por nome+redirect_uris — mesmo padrão de
dedup de create_client(), §3.3.5) um client OAuth estável chamado "EMCP Gateway" no
seu próprio servidor OAuth (o de §3, não o da Cloud), e auto-emite um refresh token de
longuíssima duração (REFRESH_TTL = 315360000 — 10 anos, "efectivamente não-expirante")
ligado a um utilizador WordPress específico, através de EMCP_Tools_OAuth_Store::issue_token()
directamente (sem passar pelo fluxo /authorize normal — é um self-issue administrativo).
Depois faz upload desse {client_id, refresh_token, site_uuid, token_endpoint} para a Cloud
via PUT /api/cloud/v1/gateway/credential. Objectivo (Phase 2, ainda do lado da Cloud, não
implementado neste build): a Cloud poder actuar como um gateway multi-site que troca
este refresh token pelos seus próprios access tokens contra o servidor OAuth de CADA site
ligado, sem o site ter de expor Application Passwords a um serviço terceiro.
provision($user_id) é best-effort e limpo: se o upload para a Cloud falhar, revoga
imediatamente o token recém-emitido localmente (não deixa um token órfão vivo). deprovision()
faz o inverso (delete remoto best-effort + revoke local incondicional — "offline-proof kill
switch"). handle_client_revoked($client_id) é chamado pelo painel "Authorized Apps" do
próprio site (revogação manual de qualquer client OAuth) para também limpar o lado Cloud se
o client revogado for justamente o Gateway.
2.2.7 EMCP_Tools_Settings_Sync — class-settings-sync.php (feature paga, "Turnkey settings sync")
entitled() — gate por entitlement syncSettings do plano Cloud ligado (lê
Cloud_Sync::status(), cacheado estaticamente por request). sync_keys() — allowlist
explícita e filtrável (emcp_tools_settings_sync_keys) de 10 chaves wp_options
sincronizáveis: emcp_tools_disabled_tools, elementor_mcp_disabled_tools (chave legacy),
emcp_tools_active_modules, emcp_tools_dispatcher_mode, emcp_tools_strict_schemas,
emcp_tools_content_mirror_enabled, emcp_tools_context_settings,
emcp_tools_module_themer_force_render, emcp_tools_memory_require_approval,
emcp_tools_memory_auto_summarize. Comentário no cabeçalho: "Never touches secrets or
site-specific keys." — nunca inclui tokens, a ligação Cloud, o UUID do site, logs de
auditoria, ou estado de notificações. apply() só escreve chaves da allowlist (defesa
contra um blob adulterado). push()/pull_and_apply() delegam em
Cloud_Sync::push_config()/pull_config() com type='settings' — reusa o MESMO endpoint
genérico que cloud-config-sync expõe como MCP tool, mas esta classe não tem tool MCP
própria (é só UI de admin).
2.2.8 EMCP_Tools_Cloud_Module — includes/modules/class-cloud-module.php (gating)
id()='cloud', tier()='free', default_active()=true. register() só chama
EMCP_Tools_Cloud_Connect::init() (regista os 3 handlers admin_post_*). is_enabled()
estático lê directamente a option emcp_tools_active_modules (evita depender do boot do
módulo em init:5, porque as abilities registam-se em wp_abilities_api_init, que pode
correr antes).
3. Servidor OAuth — infra-estrutura MCP genérica (includes/oauth/)
Não é uma peça do EMCP Cloud. É um servidor de autorização OAuth 2.1 completo
(Authorization Code + PKCE obrigatório, só clientes públicos — sem client secret),
implementado inteiramente dentro do WordPress, que serve de alternativa ao Application
Password para qualquer cliente MCP remoto (Claude Desktop, VS Code, Cursor, CLIs como
OpenClaw) se ligar ao endpoint MCP deste site sem o utilizador ter de gerar e colar uma
Application Password manualmente. O único consumidor interno do plugin é
EMCP_Tools_Gateway_Credential (§2.2.6) — tudo o resto é para clientes MCP externos
genéricos.
3.1 Endpoints
| Documento/endpoint | Método | Rota | Tipo | RFC |
|---|---|---|---|---|
| Protected Resource Metadata | GET | /.well-known/oauth-protected-resource |
não-REST (parse_request) |
RFC 9728 |
| Authorization Server Metadata | GET | /.well-known/oauth-authorization-server |
não-REST (parse_request) |
RFC 8414 |
| Authorize + consentimento | GET/POST | /emcp-oauth/authorize |
não-REST (parse_request, front-end normal) |
RFC 6749 §4.1.1 |
| Dynamic Client Registration | POST | /wp-json/emcp-tools/oauth/v1/register |
REST (permission_callback: __return_true) |
RFC 7591 |
| Token (code exchange + refresh) | POST | /wp-json/emcp-tools/oauth/v1/token |
REST (__return_true) |
RFC 6749 |
| Revoke | POST | /wp-json/emcp-tools/oauth/v1/revoke |
REST (__return_true) |
RFC 7009 |
3.2 EMCP_Tools_OAuth_Server — class-oauth-server.php (orquestrador)
is_available()—HTTPS OK(viais_ssl(),home_url()a começar porhttps://, ou host locallocalhost/127.0.0.1/::1/*.test/*.local/*.localhost), filtrável viaemcp_tools_oauth_available(para hosting atrás de um proxy que termina TLS antes do PHP).option_enabled()— optionemcp_tools_oauth_enabled: se nunca definida explicitamente, o default é ON sempre queis_available()(decisão de produto documentada em comentário: "OAuth sign-in is a free, core connectivity feature... enabled wherever it is available").is_enabled() = is_available() && option_enabled()— o gate único que todo o resto do subsistema verifica.- Em
init:20, seis_available(): instala as tabelas (OAuth_Store::maybe_install()) e agenda um WP-Cron diário de garbage-collection (gc_hook) — corre mesmo que o toggle esteja OFF, para limpar tokens residuais de quando esteve ligado. Seis_enabled(): regista os documentos de discovery, as rotas REST, o endpoint de authorize, e um filtro emrest_post_dispatchque emite o desafioWWW-Authenticate(§3.3.6). base_url()usaEMCP_Tools_Site_Context::rest_endpoint()(nãorest_url()cru) — honra um eventual override de "Server URL" no admin, para manter issuer/resource/token consistentes num site atrás de um domínio provisório/proxy.
3.3 Fluxo completo
3.3.1 Discovery — class-oauth-metadata.php
issuer()/resource() usam EMCP_Tools_Site_Context::public_base_url() (mesmo motivo do
base_url() acima). path_matches() aceita tanto o path exacto do well-known como uma
variante "resource-scoped" (RFC 9728 §3.1) — ex.
/.well-known/oauth-protected-resource/wp-json/mcp/emcp-tools-server — porque clientes MCP
reais fazem esse pedido específico (comentário: "Match... the resource-scoped variant
clients build by appending the resource path"; sem isto o discovery falha silenciosamente
para esses clientes). Documento devolvido com Access-Control-Allow-Origin: * + cache 1h —
é discovery público, sem dados sensíveis.
3.3.2 Dynamic Client Registration — class-oauth-clients.php
POST /register totalmente aberto (permission_callback: __return_true — RFC 7591 prevê
registo aberto para clientes públicos). Validação de redirect_uris: array não vazio, cada
URI https absoluto, OU http só em loopback (127.0.0.1/::1/localhost, RFC 8252
§7.3), OU um esquema custom de app privada (ex. claude://, RFC 8252 §7.1 — aceite sem
restrição adicional, para clientes nativos). Nunca aceita URI com fragment component (RFC
6749 §3.1.2). client_name default 'MCP Client' se ausente. Resposta:
{client_id, client_name, redirect_uris, token_endpoint_auth_method:'none', grant_types: ['authorization_code','refresh_token'], response_types:['code'], client_id_issued_at}.
3.3.3 Authorize + consentimento — class-oauth-authorize.php
Decisão de design não óbvia: este endpoint é servido como um pedido front-end normal
via parse_request (prioridade 0) — deliberadamente NÃO é uma rota REST. Motivo (do
próprio código): uma rota REST precisaria de um nonce que o browser do cliente MCP (que abre
este URL numa aba/janela) não tem forma de fornecer, e a autenticação por cookie de sessão
WordPress só funciona no fluxo normal de página.
GET: valida client_id+redirect_uri PRIMEIRO, antes de confiar em redirect_uri
como alvo de qualquer redirect de erro — protecção contra open-redirect via um client_id
malicioso ou desconhecido. Só depois valida response_type=code e
code_challenge_method=S256 (obrigatório; plain nunca é aceite). Se não autenticado,
redirige para wp_login_url() com retorno para si mesmo. Exige current_user_can('manage_options')
(filtrável via emcp_tools_oauth_authorize_cap) para poder aprovar — só administradores
autorizam ligações MCP. Renderiza um ecrã de consentimento HTML autónomo (CSS inline, sem
dependências do tema), mostrando o nome do client, o site, e o utilizador autenticado.
POST: valida nonce _emcp_oauth_nonce (acção emcp_oauth_consent), revalida
client+redirect, se action != 'approve' redirige com error=access_denied. Se aprovado,
emite o código via OAuth_Store::issue_code() (payload:
{client_id, user_id, redirect_uri, code_challenge, scopes}) e redirige de volta com
?code=&state=.
3.3.4 Token + Revoke — class-oauth-token.php
POST /token dispatcher por grant_type:
authorization_code:OAuth_Store::consume_code()(single-use, apaga o transient no consumo), validaclient_idmatch,redirect_urimatch, e PKCE S256 (hash_equalssobre ocode_verifierrecomputado) — tudo emvalidate_code_exchange()(pura, testável isoladamente). Emite par access+refresh.refresh_token: procura o token, validaclient_idmatch, e rota o refresh token velho — mas NÃO com apagamento imediato:OAuth_Store::rotate_out_refresh($id, self::refresh_grace())— o access token antigo ligado a esse refresh não é cascade-deletado (ao contrário de uma revogação explícita); sobrevive até expirar pela sua própria TTL. Justificação no código: "an in-flight MCP request may still be carrying [it]... invalidating it 401s those requests the instant the client refreshes... surfaced as connections dropping mid-chat" — cita RFC 6749 §1.5, que permite explicitamente este comportamento.- o refresh token rodado fica ainda utilizável por uma janela de graça
(
REFRESH_GRACE=120s, filtrável viaemcp_tools_oauth_refresh_graceou constanteEMCP_TOOLS_OAUTH_REFRESH_GRACE) em vez de morrer instantaneamente — cobre o caso de um cliente cuja resposta do refresh anterior se perdeu na rede e reenvia o mesmo refresh token: em vez deinvalid_grant, roda de novo com sucesso. ACCESS_TTL=3600s(1h, filtrável/constante — útil para testar o fluxo de refresh em minutos em vez de esperar uma hora),REFRESH_TTL=2592000s(30 dias).
POST /revoke — sempre devolve 200, mesmo para um token desconhecido (RFC 7009,
previne enumeração). Procura o token como access OU refresh e revoga a linha encontrada.
3.3.5 Persistência — class-oauth-store.php
2 tabelas próprias criadas via dbDelta (idempotente, DB_VERSION=2 — v2 mudou os
timestamps para BIGINT "2038-safe" e adicionou índice em refresh_of):
CREATE TABLE wp_emcp_oauth_clients (
client_id VARCHAR(64) NOT NULL,
client_name VARCHAR(191) NOT NULL,
redirect_uris TEXT NOT NULL,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
PRIMARY KEY (client_id)
);
CREATE TABLE wp_emcp_oauth_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
token_hash CHAR(64) NOT NULL, -- SHA-256, ÚNICO
token_type VARCHAR(10) NOT NULL, -- 'access' | 'refresh'
client_id VARCHAR(64) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
scopes VARCHAR(191) NOT NULL DEFAULT '',
expires_at BIGINT NOT NULL,
refresh_of BIGINT UNSIGNED NULL DEFAULT NULL, -- liga o access token ao seu refresh
created_at BIGINT NOT NULL,
PRIMARY KEY (id), UNIQUE KEY token_hash (token_hash),
KEY client_id (client_id), KEY user_id (user_id),
KEY expires_at (expires_at), KEY refresh_of (refresh_of)
);
Tokens nunca guardados em claro — só o hash SHA-256 (sem sal; justificado porque os
tokens já têm 32 bytes de entropia aleatória, um hash simples é indexável e adequado).
Códigos de autorização vivem em transients, não na tabela (CODE_TTL=300s — 5 min, mais
generoso que os ~60s típicos porque clientes CLI como OpenClaw exigem copy-paste manual do
código e 60s era fácil de perder).
create_client() é deduplicado: find_client_by_registration() procura por
nome+redirect_uris normalizados antes de criar — comentário: "MCP clients (Claude, Codex)
re-run Dynamic Client Registration each time they connect; without this the clients table
grows unbounded (one dead row per connect)". Seguro porque são clientes públicos (sem
segredo) e os tokens ficam ligados ao utilizador autorizador, não ao client em si.
gc() — apaga tokens expirados, depois clients órfãos (sem nenhum token e criados há mais
de ORPHAN_CLIENT_GRACE=1 dia — protege um client recém-registado que ainda não terminou o
fluxo de autorização). gc_throttled() corre gc() no máximo 1x por
GC_THROTTLE_INTERVAL=900s (transient-guarded), e é chamado directamente do hot-path de
validação Bearer (§3.3.6) — "clean at validation time": qualquer site com tráfego MCP
activo mantém as tabelas limpas em minutos, sem depender só do cron diário como backstop.
3.3.6 Bearer — class-oauth-bearer.php (validação no transporte MCP)
Ligado como transport_permission_callback do servidor MCP (Plugin::register_mcp_server(),
doc 00 §3) — é aqui que o servidor OAuth se conecta ao resto do plugin.
permission_callback($request):
- Extrai o Bearer do header
Authorization(com fallback paraHTTP_AUTHORIZATION/REDIRECT_HTTP_AUTHORIZATIONde$_SERVER, para hosts que despem o header antes de chegar ao PHP — comum em CGI/FastCGI). - Se presente:
gc_throttled(), procura o access token na store; se válido fazwp_set_current_user()e devolvetrue; se inválido/expirado devolvefalse(401 fail-closed — não cai para outro método de auth). - Se ausente: cai para o comportamento default do adapter (
Application Password/ cookie), via o filtromcp_adapter_default_transport_permission_user_capability— os dois métodos de auth coexistem sem se excluir mutuamente.
maybe_challenge() (hook rest_post_dispatch) — em qualquer resposta 401/403 na rota
mcp/emcp-tools-server, adiciona WWW-Authenticate: Bearer resource_metadata="<url>"
(RFC 9728 §5.1) — é o mecanismo pelo qual um cliente MCP genérico descobre
automaticamente, sem configuração manual, que este site suporta OAuth e onde começar o
fluxo de discovery.
3.3.7 Primitivas puras — class-oauth-util.php
Zero dependências de WordPress/BD — testável isoladamente. base64url_encode/decode
(RFC 4648 §5, sem padding), generate_token() (32 bytes aleatórios → 43 chars),
generate_code_verifier() (mesmo gerador), code_challenge_s256(), generate_client_id()
('emcp_' + 24 hex), hash_token() (SHA-256 simples), verify_pkce() (só aceita S256,
nunca plain; exige verifier de 43-128 chars RFC 7636 §4.1; comparação em tempo constante),
secure_equals() (hash_equals wrapper), redirect_uri_matches() — match exacto OU a
excepção de loopback nativo (RFC 8252 §7.3): para http://127.0.0.1/http://[::1]/
http://localhost, a porta pode diferir entre o registado e o apresentado, porque
aplicações nativas fazem bind a uma porta local efémera.
4. Serviços transversais (usados pelos 3 subsistemas)
4.1 EMCP_Tools_Secret — includes/class-secret.php
Encriptação simétrica genérica para segredos em repouso — usada pelas 3 chaves API de stock
image (§1.3) e pelo bundle de ligação Cloud (§2.2.1). Chave de 32 bytes derivada por-site
a partir de AUTH_KEY+SECURE_AUTH_KEY (salts do wp-config.php) via
sodium_crypto_generichash (fallback SHA-256) — nunca guardada na base de dados, deriva
sempre em runtime. Prefere libsodium (secretbox, bundled desde PHP 7.2), fallback
OpenSSL AES-256-GCM. Prefixo emcps1: marca valores encriptados, permitindo pass-through
transparente de valores legacy/constantes em claro (decrypt() devolve o valor original se
não tiver o prefixo). decrypt_if_needed() desembrulha repetidamente (guard de 8 iterações)
para tolerar um bug de dupla-encriptação (ex. um callback de sanitização do Settings API que
dispare duas vezes).
Consequência prática: um dump da base de dados sozinho nunca expõe uma chave API de
stock-image nem um refresh token Cloud — precisa também do wp-config.php.
4.2 EMCP_Tools_Url_Guard — includes/class-url-guard.php
Guarda anti-SSRF usada por sideload-image (§1.1) e por outras tools que descarregam
conteúdo remoto. Duas APIs distintas para dois níveis de rigor:
is_safe_remote_url()+safe_download()(versão "leniente", usada nosideload-image) — valida esquema http(s), usawp_http_validate_url()do core como primeira camada, e complementa com umgethostbyname()+filter_var(..., FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)porque o core não cobre169.254.0.0/16(link-local — inclui o endpoint de metadata de cloud169.254.169.254, um alvo clássico de SSRF) nem endereços IPv6 internos.safe_download()forçareject_unsafe_urls=true(WP_Http revalida CADA hop de redirect) e limitaredirectiona 2 hops.validate()(versão "estrita", adicionada em 3.2.0 para a tool de AI Chatweb_fetch— não coberta por esta série de docs, é Pro) — resolve TODOS os registos A e AAAA de um host (não só o primeiro, comogethostbyname()faz) e bloqueia se qualquer um for privado — cobre o caso de um host que publica um endereço público e um interno em simultâneo. Bloqueia também portas fora de{80,443}e URLs com credenciais embutidas (user:pass@host). Listas CIDR bloqueadas explícitas para IPv4 (0.0.0.0/8,10/8,100.64/10CGNAT,127/8,169.254/16,172.16/12,192.168/16,224/4multicast,240/4reservado) e IPv6 (::/128,::1/128,fc00::/7ULA,fe80::/10link-local), incluindo o caso de endereço IPv4-mapped em IPv6 (::ffff:127.0.0.1).
Nota de honestidade técnica no próprio código: mesmo a versão estrita mantém uma janela
TOCTOU entre a validação DNS e o connect TCP real (DNS rebinding) — WordPress liga por
nome de host, não por IP pinado; fechar completamente exigiria CURLOPT_RESOLVE.
Blueprint para réplica
Reutilizar quase 1:1:
- O servidor OAuth inteiro (§3) — ~1500 linhas de infra-estrutura MCP genérica, sem NENHUMA dependência do EMCP Cloud. É a peça de maior valor deste documento para uma réplica: dá autenticação Bearer remota sem obrigar o utilizador a gerar/colar Application Passwords manualmente, e resolve sozinho toda a dança de discovery RFC 9728/8414 que clientes MCP modernos esperam.
EMCP_Tools_Secret(§4.1) — padrão de encriptação at-rest derivado dos salts do próprio site é elegante e trivial de copiar; não precisa de gestão de chave adicional nem de um KMS externo.EMCP_Tools_Url_Guard(§4.2) — a distinção entre modo "leniente" (sideload) e "estrito" (fetch de conteúdo para um modelo) é uma lição de segurança valiosa por si só; copiar tal e qual, incluindo a lista de CIDRs bloqueados e a nota honesta sobre o TOCTOU window de DNS rebinding.EMCP_Tools_Stock_Image_Providers(§1.2) — o padrão registry+resolver com fallback automático por ordem de prioridade é um bom desenho, trivialmente extensível a mais providers (ex. Openverse, que este plugin usava antes da v3.1.0 segundo os comentários dos clientes).- O padrão de rotação de refresh token com janela de graça (
OAuth_Token::REFRESH_GRACE, §3.3.4) e o padrão de mutex/anti-corrida (Cloud_Connect::refresh(), §2.2.2) — ambos resolvem bugs reais de concorrência já vividos em produção pelo autor original; aplicáveis a qualquer implementação própria de OAuth, tanto do lado servidor como cliente.
Simplificar:
- Todo o subsistema Cloud (§2.2) está acoplado a um SaaS de terceiros
(
emcptools.com) que uma réplica não vai ter por omissão. Vale a pena extrair só o padrão arquitectural, não o código ligado ao domínio: (a)Cloud_Connecté um bom template de "como SER cliente PKCE de outro servidor OAuth" (complementar ao §3, que documenta "como SER o servidor"); (b) o padrão de bundle+checksum para export/import de artefactos é reutilizável mesmo sem nuvem nenhuma, só para backup/restore local (ver doc 06). Sem um serviço cloud próprio planeado, esta secção inteira (~1200 linhas) é dispensável. EMCP_Tools_Gateway_Credential(§2.2.6) é explicitamente uma feature "Phase 1" incompleta do lado deles (o próprio comentário do código diz "Phase 2 concern" para o lado da Cloud) — não vale a pena replicar sem um caso de uso concreto de "gateway multi-site" próprio.
Deixar de fora:
cloud-marketplace-*(list/install/publish) — depende inteiramente de um marketplace SaaS de terceiros.EMCP_Tools_Settings_Sync— feature paga ("Turnkey settings sync"), sem sentido numa réplica sem modelo de billing.
Gotchas não óbvios (achados de código, não de documentação):
- Heurística Unsplash download-tracking. Agentes de IA passam frequentemente a URL
api.unsplash.com/photos/<id>/download(parece um URL de imagem, mas é o endpoint de tracking que exige API key e devolve 401 sem ela) em vez do URL directo devolvido porsearch-images. O código resolve isto automaticamente (resolve_download(), §1.3) esideload-imagetem lógica dedicada de mensagem de erro para este caso específico — o próprio comentário chama-lhe "a common weak-model loop". Vale a pena replicar esta heurística de correcção accionável de erro, e generalizar o padrão (detectar a classe de erro mais comum de um agente e devolver uma sugestão específica, não só a mensagem crua da API). - Rotação de refresh token que se auto-sabota sem cuidado. O comentário em
Cloud_Connect::refresh()explica que um IdP com rotação estrita (Better Auth) invalida TODA a família de tokens se detectar reuso de um refresh token já rodado — o que transforma uma simples corrida entre duas requests concorrentes numa desconexão completa e forçada ("Reconnect needed"). A mitigação de 4 camadas (mutex, double-check, tratar rejeição concorrente como sucesso, nunca marcar unhealthy em falha transitória) é um padrão geral aplicável a qualquer cliente OAuth contra qualquer IdP com rotação. - A mesma lição, ao contrário, no servidor próprio.
OAuth_Store::rotate_out_refresh()comREFRESH_GRACEexplicitamente NÃO apaga o access token antigo na rotação (evita 401 a meio de uma conversa MCP em curso) e mantém o refresh token rodado utilizável por 120s extra (evita 401 num retry de resposta perdida). Cita RFC 6749 §1.5 como justificação — é o tipo de detalhe que só se aprende com utilizadores reais a queixarem-se de ligações a cair a meio. - Dedup de client OAuth em DCR.
create_client()reusa um client existente com o mesmo nome+redirect_uris em vez de criar sempre um novo — sem isto, clientes MCP que refazem DCR a cada ligação (confirmado no código: "Claude, Codex re-run Dynamic Client Registration each time they connect") fariam crescer a tabela de clients sem limite. Aplica-se tanto ao servidor OAuth do plugin (§3.3.5) como aoGateway_Credential(§2.2.6), que idem reusa o client"EMCP Gateway"por nome. /authorizecomo request front-end, não REST. Decisão deliberada e não óbvia (§3.3.3) — uma rota REST exigiria um nonce que o browser do cliente MCP não tem como fornecer, e cookie auth de sessão só funciona no fluxo normal de navegação de página. Preservar esta decisão tal e qual numa réplica.- Ordem de validação anti-open-redirect. Em
OAuth_Authorize::handle_get(),client_id+redirect_urisão validados antes de qualquer outra coisa, especificamente para nunca usar umredirect_urinão confiável como alvo de um redirect de erro — protecção directa contra um vector de open-redirect via umclient_idmalicioso ou inexistente. normalize_result()(doc 00 §5) protege também as tools Cloud. O output decloud-status/cloud-list(devolvido "as array" directamente do JSON decodificado da API remota) passa pelo mesmo wrapper de normalização de resultado do ability registrar — o que protege contra a mesma classe de bug documentada para o WooCommerce (report-products-totalsdevolve array de topo) caso a API da Cloud alguma vez faça o mesmo. Confirma que a camada declass-schema-compat.phpdeve ser aplicada universalmente, mesmo a tools que parecem "seguras" por delegarem numa API JSON bem-comportada.
Fonte
Leitura directa (19-08-2026) de, em /home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/:
includes/abilities/class-ability-registrar.php(gating de todos os grupos)includes/abilities/class-stock-image-abilities.phpincludes/class-stock-image-providers.phpincludes/class-unsplash-client.phpincludes/class-pexels-client.phpincludes/class-pixabay-client.phpincludes/abilities/class-cloud-abilities.phpincludes/cloud/class-cloud.phpincludes/cloud/class-cloud-connect.phpincludes/cloud/class-cloud-http.phpincludes/cloud/class-cloud-sync.phpincludes/cloud/class-gateway-credential.phpincludes/cloud/class-cloud-client.phpincludes/cloud/class-settings-sync.phpincludes/modules/class-cloud-module.phpincludes/oauth/class-oauth-metadata.phpincludes/oauth/class-oauth-authorize.phpincludes/oauth/class-oauth-server.phpincludes/oauth/class-oauth-store.phpincludes/oauth/class-oauth-bearer.phpincludes/oauth/class-oauth-util.phpincludes/oauth/class-oauth-clients.phpincludes/oauth/class-oauth-token.phpincludes/class-secret.phpincludes/class-url-guard.php
Cruzado com docs/00-ARQUITECTURA.md (arquitectura geral do plugin, cadeia de arranque,
emcp_tools_register_ability()) e skill://emcp-tools (auditoria de postura de segurança,
16-08-2026) para contexto de gating e postura por site.