# 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 ` | `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//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: ` (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/ (WordPress/)`, 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::() → 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 ` + 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=""` (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//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.