Voici le premier outil MCP que presque tout le monde écrit :
class TicketTool extends Tool
{
public function handle(Request $request): Response
{
return Response::structured(Ticket::find($request->get('id')));
}
}
Il fonctionne, et il contient trois failles de sécurité distinctes qui ne sont pas visibles dans le code.
Un : il renvoie toutes les colonnes
Ticket::find() vous donne le modèle. Sérialiser le modèle donne tous les attributs de la ligne.
Cela inclut internal_notes. Cela inclut le card_last_four ajouté pour un écran de support. Cela inclut la colonne ajoutée la semaine dernière pour une fonctionnalité pas encore livrée.
C'est la plus discrète des trois parce que rien n'a l'air anormal. Vos vues Blade affichent quatre champs, donc vous pensez au modèle comme ayant quatre champs. L'agent les reçoit tous, et le modèle explique volontiers leur contenu à qui le demande.
Deux : il ignore qui pose la question
Il n'y a aucun utilisateur dans ce code. La requête n'est pas filtrée, donc l'outil récupère n'importe quel ticket par identifiant, pour quiconque peut l'appeler.
Ajouter ->where('user_id', auth()->id()) corrige le cas immédiat et en crée un plus subtil : le filtre vit désormais dans l'outil et non dans vos policies. Quand quelqu'un ajoutera un deuxième outil, une relation ou un scope, il faudra s'en souvenir. On ne s'en souviendra pas.
Trois : rien n'empêche l'énumération
find($id) avec un identifiant auto-incrémenté est une invitation. Un agent qui peut appeler l'outil peut l'appeler avec 1, 2, 3, et continuer. Même une fois la policy ajoutée, la forme de l'échec fuit : un enregistrement inexistant et un enregistrement interdit répondent en général différemment, et cette différence cartographie les identifiants existants.
Ce que fait laravel/mcp, et ce qu'il ne fait pas
Rien de tout cela n'est une critique de laravel/mcp. Il compte 34,5 millions d'installations, il est officiel, et il fait très bien son travail. Son travail, c'est le protocole.
Ce qu'il ne fait délibérément pas, c'est décider ce que votre outil a le droit de renvoyer. C'est une préoccupation applicative, et il serait malvenu qu'un package de protocole la devine.
C'est aussi une préoccupation dont la forme est identique dans toutes les applications, ce qui en fait un bon candidat pour un package plutôt que quelque chose que chaque équipe réinvente un vendredi à 17 h.
Déclarer l'exposition
Ce que j'ai retenu, c'est un attribut sur le modèle :
#[AgentResource(
fields: ['id', 'subject', 'status'],
searchable: ['subject'],
filterable: ['status'],
relations: ['comments', 'author'],
maxResults: 25,
)]
class Ticket extends Model {}
C'est toute la configuration, et chaque argument ferme une des trois failles.
fields est une liste blanche. La projection travaille depuis cette liste seule et n'inspecte jamais le modèle pour décider quoi inclure, donc ajouter une colonne ne peut pas élargir l'exposition.
maxResults est un plafond appliqué par-dessus ce que demande l'agent, avec un second plafond global en configuration.
L'autorisation passe par vos policies existantes, enregistrement par enregistrement.
Ce que je défends le plus
Chacun de ces mécanismes échoue en se fermant.
Un modèle sans policy lève une exception. Oublier d'écrire une policy est l'erreur la plus probable de tout le processus, donc le comportement par défaut doit être le refus.
Un get refusé est indiscernable d'un enregistrement inexistant. Les deux renvoient null, donc l'outil ne peut pas servir à découvrir quels identifiants existent.
Les lignes refusées dans une liste sont signalées, pas supprimées. Si un outil retire silencieusement des lignes, l'agent croit la liste complète, puis répond avec assurance à une question à son sujet. Le compte revient donc :
['rows' => [...], 'denied' => 2, 'truncated' => false]
Ne pas écrire les outils du tout
Puisque l'attribut décrit déjà tout ce dont un outil a besoin, les outils sont générés :
php artisan agent-kit:mcp
Une classe d'outil laravel/mcp par capacité déclarée, avec le schéma d'entrée dérivé du même attribut. Le handler généré délègue à la ressource et ne fait rien d'autre, ce que le fichier généré indique en en-tête : toutes les garanties vivent dans la ressource, et tout ce qu'on ajoute à un outil s'exécute en dehors d'elles.
composer require catidegla/laravel-agent-kit
58 tests sur PHP 8.2, 8.3 et 8.4, dont un qui charge une classe générée et la fait passer par le sérialiseur de laravel/mcp. Le dépôt est ici.
Top comments (0)