Un permission_callback che ritorna true trasforma una funzione interna del tuo sito in un’azione che chiunque possieda un Application Password valido può eseguire dall’esterno, con validazione automatica dell’input e risposta JSON pulita. È l’errore più facile da commettere con l’Abilities API, il registro di funzionalità che WordPress ha portato in core con la 6.9: il codice funziona al primo tentativo, quindi nessuno lo rilegge.

Vale la pena capirla bene, perché questa API cambia il modo in cui un sito WordPress parla con gli strumenti AI. Non è l’ennesimo endpoint REST da documentare a mano: è un elenco che un agente può interrogare da solo per sapere cosa il sito sa fare.

Cosa è davvero un’ability, e perché non è un endpoint REST

La documentazione ufficiale la definisce così: l’Abilities API fornisce “un modo standardizzato per registrare e scoprire unità distinte di funzionalità all’interno di un sito WordPress”. Ogni unità ha un nome nel formato namespace/nome-ability, una descrizione leggibile, uno schema di input e uno di output in JSON Schema, una categoria, un controllo permessi e una callback che esegue il lavoro.

La differenza con una route REST personalizzata sta tutta nella parola scoprire. Una route custom la conosci solo se hai letto il codice del plugin che la registra: nome, parametri accettati, forma della risposta. Un’ability porta con sé queste informazioni in forma leggibile da una macchina. Un agente AI può chiedere al sito l’elenco delle ability disponibili, leggere lo schema di ciascuna e decidere quale usare, senza che nessuno gli abbia scritto un’integrazione su misura.

Il registro è centrale e unico: un singleton WP_Abilities_Registry tiene tutte le ability registrate, mentre WP_Abilities_Category_Registry gestisce le categorie. Ogni ability appartiene a esattamente una categoria, e la categoria va registrata prima, altrimenti la registrazione dell’ability fallisce.

Il requisito minimo: WordPress 6.9

L’handbook è esplicito: “The Abilities API is only available for WordPress 6.9 and above”. Su un sito fermo alla 6.8 nulla di quanto segue esiste. Prima di pianificare qualsiasi integrazione agentica, quindi, il controllo di versione viene prima del codice: è il motivo più banale per cui una prova non parte.

Registrare la prima ability: anatomia completa

Il flusso è in due tempi, con due hook distinti. Prima le categorie, su wp_abilities_api_categories_init, poi le ability, su wp_abilities_api_init.

add_action( 'wp_abilities_api_categories_init', 'lr_registra_categorie_ability' );
/**
 * Registra la categoria che raggruppa le ability del sito.
 */
function lr_registra_categorie_ability() {
    wp_register_ability_category(
        'content-management',
        array(
            'label'       => __( 'Gestione contenuti', 'lr-agent' ),
            'description' => __( 'Ability per gestire e organizzare i contenuti.', 'lr-agent' ),
        )
    );
}

Poi l’ability vera e propria. Questo è l’esempio canonico della dev note di WordPress 6.9, che conta i contenuti pubblicati di un tipo:

add_action( 'wp_abilities_api_init', 'lr_registra_ability' );
/**
 * Registra le ability esposte dal sito.
 */
function lr_registra_ability() {
    wp_register_ability(
        'lr-agent/get-post-count',
        array(
            'label'               => __( 'Conta contenuti', 'lr-agent' ),
            'description'         => __( 'Restituisce il numero di contenuti pubblicati.', 'lr-agent' ),
            'category'            => 'content-management',
            'input_schema'        => array(
                'type'        => 'string',
                'description' => __( 'Il tipo di contenuto da contare.', 'lr-agent' ),
                'default'     => 'post',
            ),
            'output_schema'       => array(
                'type'        => 'integer',
                'description' => __( 'Numero di contenuti pubblicati.', 'lr-agent' ),
            ),
            'execute_callback'    => 'lr_get_post_count',
            'permission_callback' => function() {
                return current_user_can( 'read' );
            },
        )
    );
}

/**
 * Callback di esecuzione: conta i contenuti pubblicati.
 */
function lr_get_post_count( $input ) {
    $post_type = $input ?? 'post';
    $count     = wp_count_posts( $post_type );

    return (int) $count->publish;
}

Quattro elementi fanno il lavoro pesante e conviene guardarli uno per uno.

input_schema e output_schema

Sono JSON Schema, non decorazione. L’infrastruttura valida l’input in ingresso prima di chiamare la callback e valida l’output prima di restituirlo. Significa che la tua funzione riceve dati già conformi al tipo dichiarato e che una risposta malformata viene intercettata prima di arrivare al client. È validazione al confine, gratis, nel punto giusto.

permission_callback

È il perimetro di sicurezza dell’intera faccenda, e torniamo sull’apertura di questo pezzo. La callback riceve gli stessi dati dell’esecuzione e deve rispondere con true, false o un WP_Error quando serve spiegare il motivo del rifiuto. Il punto non è scriverla: è scriverla con la capability giusta. Un current_user_can( 'read' ) su un’ability che legge il conteggio dei post è corretto. Lo stesso controllo su un’ability che cancella contenuti è un incidente in attesa di una richiesta.

execute_callback e gli errori

La callback restituisce il risultato oppure un oggetto WP_Error. La dev note mostra il pattern con un’ability di cancellazione:

function lr_delete_post( $input ) {
    $post_id = $input['post_id'];

    if ( ! get_post( $post_id ) ) {
        return new WP_Error(
            'post_not_found',
            __( 'Il contenuto indicato non esiste.', 'lr-agent' )
        );
    }

    $result = wp_delete_post( $post_id, true );

    if ( ! $result ) {
        return new WP_Error(
            'deletion_failed',
            __( 'Cancellazione non riuscita.', 'lr-agent' )
        );
    }

    return array(
        'success' => true,
        'post_id' => $post_id,
    );
}

Restituire WP_Error invece di sollevare un’eccezione o restituire false nudo ha un effetto pratico sugli agenti: il modello legge un codice e un messaggio, capisce se ritentare con input diversi o fermarsi. Un false senza contesto produce quasi sempre un secondo tentativo identico.

Eseguire un’ability da PHP

Serve recuperarla dal registro e poi chiamarla, da init in avanti:

$ability = wp_get_ability( 'lr-agent/get-post-count' );
$result  = $ability->execute();

Le altre funzioni del registro sono wp_unregister_ability(), wp_has_ability(), wp_get_abilities(), con la coppia equivalente per le categorie.

Esporre le ability via REST: un flag, molte conseguenze

Le ability sono un registro interno finché non aggiungi un campo:

'meta' => array(
    'show_in_rest' => true,
),

Con quel flag, l’ability compare nel namespace wp-abilities/v1, dove la documentazione elenca gli endpoint per categorie, elenco ability, singola ability e la rotta di esecuzione GET|POST|DELETE /wp-abilities/v1/abilities/{name}/run. La chiamata reale assomiglia a questa:

curl -u 'UTENTE:APPLICATION_PASSWORD' \
  -X POST https://esempio.it/wp-json/wp-abilities/v1/abilities/lr-agent/get-post-count/run \
  -H "Content-Type: application/json" \
  -d '{"input": "page"}'

L’accesso a tutti gli endpoint richiede un utente autenticato, e la catena è sempre la stessa: validazione dell’input contro lo schema, controllo del permission_callback, esecuzione, validazione dell’output, risposta JSON.

Qui sta la decisione operativa che conta più di tutto il codice visto finora. Un Application Password vale quanto l’utente a cui appartiene: se quell’utente è amministratore, ogni ability il cui permission_callback accetta un amministratore diventa eseguibile da chiunque abbia quella stringa. E le Application Password vivono nei file di configurazione dei client AI, in chiaro, sul portatile di chi lavora. La documentazione del client Claude Desktop mostra esattamente questo: username e password applicativa dentro il JSON di configurazione.

Il ponte verso gli agenti: l’MCP Adapter

Il registro da solo non parla con i modelli. A farlo è l’MCP Adapter, mantenuto dal Core AI team e distribuibile come plugin dalla pagina delle release del repository WordPress/mcp-adapter. Una volta attivo registra un server predefinito chiamato mcp-adapter-default-server e tre ability proprie:

  • mcp-adapter/discover-abilities, per elencare cosa c’è;
  • mcp-adapter/get-ability-info, per leggere lo schema di una singola ability;
  • mcp-adapter/execute-ability, per eseguirla.

Attenzione però: le tue ability non compaiono automaticamente. La documentazione è netta, “By default, Abilities are only available via the MCP Adapter default server if they are explicitly marked as public for MCP access”, e il flag da aggiungere alla registrazione è questo:

'meta' => array(
    'mcp' => array(
        'public' => true,
    ),
)

È il secondo cancello, ed è una buona notizia: un’ability registrata resta invisibile agli agenti finché non decidi tu il contrario, indipendentemente da show_in_rest. Per le ability di core esiste il filtro wp_register_ability_args, con cui abilitare selettivamente voci come core/get-site-info o core/get-user-info senza toccare il core.

Le tre ability dell’adapter sono esposte anche come strumenti MCP, e l’ordine non è casuale: l’agente scopre, legge, poi agisce. È lo stesso schema a strati che si usa per non riversare cinquanta tool nel contesto di un modello, e ha il vantaggio pratico di mantenere costante il numero di strumenti visibili quando le ability del sito crescono.

Per il trasporto ci sono due strade. In locale si usa STDIO via WP-CLI, con una configurazione del client che invoca wp mcp-adapter serve puntando al percorso dell’installazione e all’utente con cui autenticarsi. Per i siti raggiungibili in rete si passa da HTTP, nel caso di Claude Desktop tramite il pacchetto @automattic/mcp-wordpress-remote, con WP_API_URL, WP_API_USERNAME e WP_API_PASSWORD nelle variabili d’ambiente.

Chi sviluppa un plugin proprio può installare l’adapter come dipendenza con composer require wordpress/mcp-adapter e creare un server MCP dedicato agganciandosi all’azione mcp_adapter_init. Un dettaglio che risparmia una serata: se più plugin sullo stesso sito possono dipendere dall’adapter o dall’Abilities API, la documentazione ufficiale raccomanda il Jetpack Autoloader, perché due copie di versioni diverse della stessa libreria caricate da Composer si rompono in modo poco leggibile.

Come lo imposterei su un sito di produzione

La tentazione, davanti a un registro così comodo, è esporre tutto e vedere che succede. La sequenza che mi sembra difendibile su un sito che ha clienti veri dall’altra parte è un’altra, ed è fatta di quattro decisioni.

Primo: ability in sola lettura per iniziare. Conteggi, elenchi, stati, ricerche. Nessuna scrittura nel primo giro. Un agente che sbaglia una lettura produce una risposta sbagliata; un agente che sbaglia una scrittura produce un lavoro di recupero.

Secondo: un utente dedicato con un ruolo dedicato. Non l’amministratore che usi tu. Un utente il cui ruolo abbia esattamente le capability richieste dalle ability che vuoi esporre, e nient’altro. Così il permission_callback smette di essere l’unica linea di difesa e diventa il secondo dei due controlli, dopo il ruolo.

Terzo: schemi stretti. Un input_schema con enum dei valori ammessi vale più di qualsiasi controllo scritto dentro la callback, perché agisce prima e in modo dichiarativo. Se l’ability deve contare solo post e page, scrivilo nello schema invece di accettare una stringa qualsiasi e filtrarla dopo.

Quarto: un registro degli accessi. Gli endpoint /run sono chiamate autenticate come tutte le altre, quindi finiscono nei log del server web. Averli distinguibili, e sapere quale credenziale ha eseguito cosa, è l’unica cosa che rende ricostruibile un incidente. Lo stesso ragionamento che vale per i webhook che scrivono su WordPress vale qui, con l’aggravante che dall’altra parte c’è un modello che decide da solo.

La take: il valore sta negli schemi, non nell’integrazione AI

L’aspetto più utile dell’Abilities API non riguarda gli agenti. Riguarda il fatto che ti costringe a dichiarare, per ogni pezzo di logica del sito, tre cose che di solito restano implicite: cosa accetta, cosa restituisce, chi può eseguirlo. In un plugin su misura tipico queste tre informazioni vivono sparse tra una funzione, un hook e un controllo di capability infilato a metà del corpo, e nessuna delle tre è leggibile dall’esterno.

Chi registra le proprie funzioni come ability si ritrova una superficie documentata anche se il progetto AI non parte mai. È lo stesso motivo per cui conviene definire i confini prima di dare permessi a un agente: il lavoro di delimitazione ha valore per conto proprio, perché costringe a rispondere a domande che il codice normalmente lascia aperte.

La previsione che mi sento di fare è che nei prossimi mesi la differenza tra i plugin non la farà il supporto all’AI dichiarato nella scheda, ma la qualità dei permission_callback che ci sono dentro. Un plugin che registra quindici ability con controlli permissivi è un problema di sicurezza distribuito su ogni sito che lo installa, e la superficie è peggiore di una route REST custom, perché è anche scopribile per costruzione. Quando valuti un plugin che dichiara supporto alle ability, la domanda da fare è una sola: quali capability chiede per eseguirle.

Cosa fare ora

In pratica, la sequenza minima per arrivare a qualcosa di verificabile nella prossima mezza giornata di lavoro:

  1. verifica che il sito sia su WordPress 6.9 o superiore, altrimenti l’API non esiste;
  2. registra una sola ability in sola lettura su un ambiente di staging, senza show_in_rest, ed eseguila da PHP con wp_get_ability();
  3. attiva show_in_rest e prova la rotta /run con una Application Password di un utente non amministratore;
  4. solo dopo installa l’MCP Adapter, marca l’ability con meta.mcp.public e collega un client partendo dal trasporto STDIO in locale, dove non esponi niente in rete;
  5. rileggi ogni permission_callback chiedendoti chi può passare quel controllo, non se il codice funziona.

Il tempo speso al punto cinque è quello che conta. Gli altri quattro passaggi funzionano quasi sempre al primo colpo, ed è esattamente per questo che si rischia di saltare il quinto. Chi sta valutando anche dove far girare gli agenti ha una decisione in più da prendere, ma l’ordine resta lo stesso: prima il perimetro, poi l’automazione.

Fonti