Files
emcp-tools-mapping/docs/09-STOCK-IMAGES-CLOUD-OAUTH.md
T
Claude Code 8ada367bd0 docs: mapeamento completo do EMCP Tools (11 docs, ~6070 linhas)
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).
2026-08-19 06:41:04 +01:00

655 lines
46 KiB
Markdown

# 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:
```php
// 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]` ou `WP_Error`:
- se `$requested` não vazio: valida que existe no mapa e que tem chave, senão erro
`unknown_provider` / `no_api_key` (mensagem inclui o link para obter chave grátis).
- se vazio: usa o primeiro de `available()`, ou `no_api_key` se nenhum estiver configurado.
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):
```php
// 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()` — constante `EMCP_TOOLS_CLOUD_URL` > option `emcp_tools_cloud_base_url` >
default `https://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
primeiro `get_option()` e persistido em `emcp_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 option
`emcp_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 o
`SCOPE='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:
1. `register_client()` — DCR: `POST {cloud}/api/auth/oauth2/register` com
`redirect_uris=[redirect_uri()]` (= `admin-post.php?action=emcp_tools_cloud_callback`),
`token_endpoint_auth_method=none`, `client_name=bloginfo('name')`.
2. `authorize_url($client_id, $verifier, $csrf)` — constrói o URL de autorização com
`code_challenge` S256 derivado do `$verifier`, e `state` = base64url de um JSON
`{site_uuid, name, csrf}` (o `$csrf` embutido no state protege contra CSRF sem precisar
de um cookie de sessão — porque o browser navega para outro domínio e volta).
3. `handle_connect()` (admin-post, nonce-protegido) — faz DCR, gera verifier+csrf, guarda
num **transient de 600s** (`emcp_tools_cloud_pending`), e redireciona o browser. Como
`wp_safe_redirect()` bloqueia hosts externos por omissão, adiciona o host da Cloud a
`allowed_redirect_hosts` só para este redirect deliberado.
4. `handle_callback()` (admin-post) — valida o `state` devolvido em **tempo constante**
(`EMCP_Tools_OAuth_Util::secure_equals`) contra o CSRF guardado, troca o `code` por
tokens (`exchange_code()`), e — se o utilizador marcou o opt-in de gateway no formulário
— provisiona automaticamente uma `EMCP_Tools_Gateway_Credential` (§2.2.6), best-effort
(uma falha aqui nunca transforma a ligação Cloud, já bem-sucedida, num erro visível).
5. `refresh()` — ver bloco dedicado abaixo, é a peça mais elaborada do ficheiro.
6. `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` (via `is_ssl()`, `home_url()` a começar por `https://`, ou
host local `localhost`/`127.0.0.1`/`::1`/`*.test`/`*.local`/`*.localhost`), filtrável via
`emcp_tools_oauth_available` (para hosting atrás de um proxy que termina TLS antes do PHP).
- `option_enabled()` — option `emcp_tools_oauth_enabled`: se nunca definida explicitamente,
o default é **ON sempre que `is_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`, se `is_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. Se `is_enabled()`:
regista os documentos de discovery, as rotas REST, o endpoint de authorize, e um filtro
em `rest_post_dispatch` que emite o desafio `WWW-Authenticate` (§3.3.6).
- `base_url()` usa `EMCP_Tools_Site_Context::rest_endpoint()` (não `rest_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), valida `client_id` match, `redirect_uri` match, e PKCE S256
(`hash_equals` sobre o `code_verifier` recomputado) — tudo em `validate_code_exchange()`
(pura, testável isoladamente). Emite par access+refresh.
- **`refresh_token`**: procura o token, valida `client_id` match, 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 via `emcp_tools_oauth_refresh_grace` ou constante
`EMCP_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 de `invalid_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`):
```sql
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)`:
1. Extrai o Bearer do header `Authorization` (com fallback para `HTTP_AUTHORIZATION` /
`REDIRECT_HTTP_AUTHORIZATION` de `$_SERVER`, para hosts que despem o header antes de
chegar ao PHP — comum em CGI/FastCGI).
2. Se presente: `gc_throttled()`, procura o access token na store; se válido faz
`wp_set_current_user()` e devolve `true`; se inválido/expirado devolve `false`
(**401 fail-closed — não cai para outro método de auth**).
3. Se **ausente**: cai para o comportamento default do adapter (`Application Password` /
cookie), via o filtro `mcp_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 no
`sideload-image`) — valida esquema http(s), usa `wp_http_validate_url()` do core como
primeira camada, e complementa com um `gethostbyname()` + `filter_var(...,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)` porque o core **não cobre**
`169.254.0.0/16` (link-local — inclui o endpoint de metadata de cloud
`169.254.169.254`, um alvo clássico de SSRF) nem endereços IPv6 internos.
`safe_download()` força `reject_unsafe_urls=true` (WP_Http revalida CADA hop de
redirect) e limita `redirection` a 2 hops.
- **`validate()`** (versão "estrita", adicionada em 3.2.0 para a tool de AI Chat
`web_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, como `gethostbyname()` 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/10` CGNAT, `127/8`, `169.254/16`,
`172.16/12`, `192.168/16`, `224/4` multicast, `240/4` reservado) e IPv6
(`::/128`, `::1/128`, `fc00::/7` ULA, `fe80::/10` link-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:**
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.
2. **`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.
3. **`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.
4. **`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).
5. **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):**
1. **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 por
`search-images`. O código resolve isto automaticamente (`resolve_download()`, §1.3) e
`sideload-image` tem 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).
2. **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.
3. **A mesma lição, ao contrário, no servidor próprio.** `OAuth_Store::rotate_out_refresh()`
com `REFRESH_GRACE` explicitamente 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.
4. **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 ao `Gateway_Credential`
(§2.2.6), que idem reusa o client `"EMCP Gateway"` por nome.
5. **`/authorize` como 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.
6. **Ordem de validação anti-open-redirect.** Em `OAuth_Authorize::handle_get()`,
`client_id`+`redirect_uri` são validados **antes** de qualquer outra coisa,
especificamente para nunca usar um `redirect_uri` não confiável como alvo de um
redirect de erro — protecção directa contra um vector de open-redirect via um
`client_id` malicioso ou inexistente.
7. **`normalize_result()` (doc 00 §5) protege também as tools Cloud.** O output de
`cloud-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-totals` devolve array de topo) caso a API da Cloud alguma vez faça o
mesmo. Confirma que a camada de `class-schema-compat.php` deve 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.php`
- `includes/class-stock-image-providers.php`
- `includes/class-unsplash-client.php`
- `includes/class-pexels-client.php`
- `includes/class-pixabay-client.php`
- `includes/abilities/class-cloud-abilities.php`
- `includes/cloud/class-cloud.php`
- `includes/cloud/class-cloud-connect.php`
- `includes/cloud/class-cloud-http.php`
- `includes/cloud/class-cloud-sync.php`
- `includes/cloud/class-gateway-credential.php`
- `includes/cloud/class-cloud-client.php`
- `includes/cloud/class-settings-sync.php`
- `includes/modules/class-cloud-module.php`
- `includes/oauth/class-oauth-metadata.php`
- `includes/oauth/class-oauth-authorize.php`
- `includes/oauth/class-oauth-server.php`
- `includes/oauth/class-oauth-store.php`
- `includes/oauth/class-oauth-bearer.php`
- `includes/oauth/class-oauth-util.php`
- `includes/oauth/class-oauth-clients.php`
- `includes/oauth/class-oauth-token.php`
- `includes/class-secret.php`
- `includes/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.