If you've pointed an AI coding agent at a real Laravel app, you've watched it do this: reference a model attribute that doesn't exist, call a relationship by a name you never gave it, or cite a route it half-remembers. It isn't lying. It's working from its training data, which is a million other people's apps, and your app is not in there. No amount of prompt-wrangling fixes a knowledge problem. The fix is to let the agent ask your actual app.
That's what the Model Context Protocol is for, and it's why I built an MCP server for Laravel. It turns a running app into a set of read-only tools an agent can call: list the models, describe one, map the relationship graph, read the schema, run a read-only query. The agent stops guessing and starts looking. Two of those tools are worth pulling apart, because they're where the interesting decisions live.
Reading the relationship graph without touching the database
The tool I like most maps every model and its relationships into a graph. The naive way to discover a model's relations is to call each of its methods and see which ones return a Relation. That's a genuinely bad idea: calling methods has side effects, and a "describe my app" tool that fires queries, or worse, while it introspects is a tool you can't trust. So I detect relations by reflecting on the return type, and never by calling:
protected function relationMethods(string $class): array
{
$methods = [];
foreach ((new ReflectionClass($class))->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->class !== $class || $method->getNumberOfParameters() > 0) {
continue;
}
$type = $method->getReturnType();
if ($type instanceof ReflectionNamedType
&& ! $type->isBuiltin()
&& is_subclass_of($type->getName(), Relation::class)) {
$methods[$method->getName()] = $type->getName();
}
}
return $methods;
}
To find the model on the other end of a relation, I build the relation object, which constructs it but runs no query, and read its target:
protected function relatedModel(object $model, string $method): ?string
{
try {
return $model->{$method}()->getRelated()::class;
} catch (Throwable) {
return null;
}
}
Here's the honest limit, and I document it in the tool itself: an untyped relation method is invisible to this. If you wrote public function posts() { return $this->hasMany(Post::class); } with no : HasMany return type, the reflection can't see it, and the only way to find out would be to call it, which is exactly what I refuse to do. Calling everything to be thorough trades a correctness gap for a safety hole, and for a tool an autonomous agent drives, that's the wrong trade.
Letting an LLM run SQL without letting it hurt you
The most powerful tool is "run a query," and it's also the one that can ruin an afternoon. An agent that can run arbitrary SQL against your database is a liability you've handed the keys to. So database_query is read-only by design, and defended in layers because any single layer can be fooled.
First, a heuristic that rejects anything that isn't a single read-only statement:
protected function assertReadOnlySql(string $sql): void
{
if (str_contains(rtrim($sql, "; \t\n\r"), ';')) {
throw new InvalidArgumentException('Only a single statement is allowed (no semicolons).');
}
if (! preg_match('/^\s*(select|with)\b/i', $sql)) {
throw new InvalidArgumentException('Only SELECT (or WITH ... SELECT) queries are allowed.');
}
if (preg_match('/\b('.implode('|', self::FORBIDDEN).')\b/i', $sql)) {
throw new InvalidArgumentException('Query contains a forbidden keyword; only read-only queries are allowed.');
}
}
Then, even a query that clears the heuristic runs inside a transaction that is always rolled back:
$connection->beginTransaction();
try {
$rows = $connection->select($sql);
} finally {
$connection->rollBack();
}
And the third layer is a sentence, not code, in the tool's own docblock: this is not a substitute for pointing the tool at a least-privilege, read-only database user. The heuristic is a keyword matcher, not a SQL parser, and a weird dialect or a determined input could slip past it. The rollback catches what the heuristic misses. The read-only database user catches what the rollback misses. Defense in depth only works if you assume each layer is fallible, which means writing down that they are.
The honesty is the feature
That's the throughline of both tools, and it's the part I'd most want another engineer to take away. A tool that an AI agent calls on its own cannot oversell its own safety. If the SQL tool said "validates that queries are read-only" and stopped, someone would treat that as a guarantee and get burned by the gap between a regex and a parser. The docblock that says "this is a heuristic, pair it with a read-only user" is doing more for that person's safety than the regex is. Same with the relationship tool openly admitting it can't see untyped relations.
So the server moves the agent from confidently working off a million other apps to actually reading yours: real models, real relationships, real schema, real read-only queries. It still won't know everything. But its blind spots are written down instead of hidden, and for a tool you hand to something that acts without asking, a documented edge is the most you can honestly offer.