Skip to main content
Glama
0mandrock1

mcp-artisan

by 0mandrock1
README.md
# mcp-artisan

Point an AI agent at a Laravel codebase and ask it to explain the routes, the
data model, the queued jobs, or a config value — without giving it any way to
change the code, run a console command, or read a secret. `mcp-artisan` is a
Model Context Protocol server that exposes a Laravel project to an agent
**read-only**. The interesting part is not the list of things it can report; it
is that the things it *cannot* do are enforced structurally, not by convention.

The server runs over stdio, speaks MCP, and needs no framework installed and no
LLM API key. It works against a project whether or not PHP is present on the
machine.

## The problem

An agent is genuinely useful on a large PHP application: "which routes are
unauthenticated?", "what does the `orders` table look like?", "which jobs run on
the `payments` queue?". But the moment you hand a general-purpose agent a shell
and a project directory to answer those questions, you have also handed it the
ability to run `php artisan migrate:fresh`, read `.env`, or edit a controller.
On a production or legacy codebase that trade is unacceptable, so the questions
go unanswered or a human does the archaeology by hand.

The useful shape is a tool that can only look. `mcp-artisan` gives an agent a
faithful, bounded map of the application and makes mutation impossible by
construction — there is no write path and no arbitrary-command path to abuse.

## Threat model

The assumed adversary is the agent itself: a capable, possibly-confused, or
prompt-injected LLM that will call any tool it is offered with any argument it
can construct. The design goal is that no sequence of tool calls can change the
project, exfiltrate a secret, or read a file outside the project root.

What that means in practice, and where each guarantee lives:

- **No mutation.** The server exposes no tool that writes. The static backend
  only ever opens files for reading; the artisan backend only ever runs verbs
  from a frozen allowlist (`route:list`, `migrate:status`, `about`), all of
  which are read-only. The allowlist is checked *before* a subprocess is
  created (`mcp_artisan/artisan_backend.py`), so a request for `migrate` or
  `tinker` never reaches `php`.
- **No arbitrary commands.** Artisan verbs are constants in the code, never
  built from tool input. The subprocess is invoked with a fixed argument vector
  (no shell), so there is nothing to inject into, and each call has a hard
  wall-clock timeout.
- **No secret disclosure.** `.env` is never read. Config values come from
  `config/*.php`, and every value is passed through key-based redaction
  (`mcp_artisan/redaction.py`): a key matching `password`, `secret`, `token`,
  `key`, `dsn`, and similar returns `<redacted>`, including nested keys inside a
  returned subtree. Redaction is deliberately broad — it prefers hiding a
  harmless value to leaking a credential.
- **No path escape.** Every filesystem read is funnelled through one
  containment check (`mcp_artisan/paths.py`) that resolves the real path and
  rejects anything outside the configured project root, including via a
  crafted config key or a symlink that points out of the tree.

Every one of these is covered by a negative test; see
`tests/test_paths.py`, `tests/test_config_redaction.py`,
`tests/test_artisan_backend.py`, and `tests/test_server_protocol.py`.

What is explicitly **out of scope**: this is not a sandbox for the agent's other
tools, and it does not defend against a compromised host or a malicious PHP
runtime. It hardens the one surface it owns — introspection of the codebase.

## Tools and resources

Tools (all read-only):

| Tool | Returns |
|------|---------|
| `project_summary` | Counts and versions, in one call, for cheap orientation. |
| `list_routes` | Every route: methods, URI, name, middleware, `controller@action`. |
| `describe_route(name_or_uri)` | One route by name or URI. |
| `list_migrations` | Migration files on disk, plus applied/pending state when a database is reachable. |
| `list_models` | Eloquent models: table, `$fillable`, `$casts`, relations. |
| `list_jobs_and_queues` | Queued job classes with `$queue`, `$tries`, `$timeout`. |
| `config_get(key)` | Dotted config lookup with mandatory secret redaction. |
| `composer_deps` | Direct requires with versions, plus Laravel and PHP versions. |

Resources: `laravel://routes`, `laravel://schema`, `laravel://composer` — the
same data as JSON documents, for clients that prefer resources to tool calls.

## Two backends, auto-detected

1. **static** (default, and the only one used in CI). Pure parsing of
   `composer.json`, `routes/*.php`, `database/migrations/*.php`,
   `app/Models/*.php`, `app/Jobs/*.php`, and `config/*.php`. Requires no PHP.
2. **artisan**. Used only when `php` is on `PATH` *and* `php artisan about`
   succeeds. Routes and migration status then come from `route:list --json` and
   `migrate:status`, which are exact where the static parser is best-effort.
   Models, jobs, config, and composer data stay static even here — no read-only
   artisan verb exposes them.

The backend is chosen once at startup and reported in every response so an agent
knows how much to trust the data. Set `MCP_ARTISAN_STATIC_ONLY=1` to force the
static backend even when PHP is available.

## Usage

Install and run against a project:

```bash
pip install -e .
MCP_ARTISAN_PROJECT=/path/to/laravel-app python -m mcp_artisan
```

Or register it with an MCP client (the entry point is the `mcp-artisan`
console script):

```json
{
  "mcpServers": {
    "artisan": {
      "command": "mcp-artisan",
      "env": { "MCP_ARTISAN_PROJECT": "/path/to/laravel-app" }
    }
  }
}
```

The project root defaults to the current working directory if
`MCP_ARTISAN_PROJECT` is unset.

## Reproducing the numbers

This repository ships a small hand-built fixture Laravel app under
`tests/fixtures/laravel-app` (no framework was downloaded). Every count quoted
below is produced by a command here, not asserted in prose.

```bash
python scripts/demo.py
```

prints, for the fixture: **8 tools exposed**, and a `project_summary` of
**6 routes, 2 models, 2 migrations, 2 jobs, 3 direct dependencies**, on the
static backend — followed by a redacted database password to show the masking.

The artisan allowlist is three verbs:

```bash
python -c "from mcp_artisan.artisan_backend import ALLOWED_VERBS; print(sorted(ALLOWED_VERBS))"
# ['about', 'migrate:status', 'route:list']
```

The full test suite (unit tests for both backends, plus protocol-level tests
that drive the server through an in-memory MCP client) runs with:

```bash
pip install -e ".[dev]"
pytest
```

CI runs exactly this on every push (`.github/workflows/ci.yml`), with no PHP
installed and no secrets.

## Design notes

Choices made in the interest of a smaller, safer, more predictable server. Where
a fuller behaviour was possible, the simpler option was taken and recorded here.

- **The static route parser does not resolve `Route::group` prefixes/middleware,
  `Route::resource` expansion, or closures' internals.** It parses top-level
  `Route::<verb>` statements and their chained `->name()`/`->middleware()`. This
  is a best-effort map; where exactness matters, the artisan backend provides
  the real route table. Closures are reported with an action of `Closure`.
- **The static backend cannot know which migrations have run** — that needs a
  database connection. It lists the files and reports `applied`/`pending` as
  unknown rather than guessing. The artisan backend fills in real status.
- **The config parser understands a subset of PHP**: scalars, arrays (both
  `[...]` and `array(...)`), `env()` (evaluated as its *default* argument only,
  since `.env` is never read), string concatenation, and `Class::class`.
  Anything it cannot evaluate — `storage_path()`, closures, other calls —
  resolves to `null` rather than raising. It never executes PHP.
- **Redaction is keyed on the config key, not the value**, because the key is
  the trustworthy signal. It over-redacts by design: a false positive hides a
  value the agent could have seen; a false negative leaks a credential.
- **`composer_deps` reports the `require` block only**, not `require-dev`, since
  the target is the application's runtime dependency surface.
- **Default model table names** are derived with a small snake-case +
  pluralisation rule (`User` → `users`, `Category` → `categories`). It covers
  the common cases, not every English irregular; models with an explicit
  `$table` are always exact.
- **Jobs are read from `app/Jobs` only.** Queueable classes elsewhere are not
  discovered by the static backend.
- **The backend is selected once at startup**, not per call, so a project does
  not flip between backends mid-session.

## Layout

```
mcp_artisan/
  paths.py            project-root resolution and the path-containment check
  redaction.py        key-based secret redaction
  phpparse.py         defensive parser for the PHP subset in config/routes/models
  static_backend.py   source-only parsers (the default backend)
  artisan_backend.py  guarded, allowlisted php-artisan subprocess backend
  service.py          backend selection and the per-tool responses
  server.py           the MCP tool/resource surface over stdio
tests/
  fixtures/laravel-app/   hand-built minimal Laravel app
  test_*.py               unit, protocol, and negative safety tests
```

## Requirements

Python 3.12+. The only runtime dependency is the official `mcp` SDK. PHP is
optional and only enables the artisan backend.

## License

MIT.

TDQS

A3.5/5.0

Scored across 8 tools

Disambiguation5/5

Each tool targets a distinct aspect of a Laravel application: routes, migrations, models, jobs, config, dependencies, and a summary. describe_route is the only detailed view of list_routes, which is a common and clear pattern. No two tools overlap in purpose.

Naming Consistency4/5

Most tools follow a verb_noun snake_case pattern (list_routes, list_migrations, config_get). However, composer_deps and project_summary are noun-first, deviating from the otherwise consistent verb-first style. The naming is still predictable and readable.

Tool Count5/5

With 8 tools, the set is well-scoped for a Laravel artisan inspection server. Each tool covers a meaningful portion of the domain without redundancy or bloat. The count feels appropriate for orientation and common development tasks.

Completeness4/5

The surface covers core Laravel inspection needs: routes, migrations, models, jobs, config, and dependencies. Minor gaps exist, such as lack of direct controller listing or cache/event inspection, but these are workarounds and not critical for the stated purpose of cheap orientation.

Maintenance

ActivityMaintained
ResponsivenessNo issues