Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ HTTP Request → ApiServiceProvider (route registration)

**Runtime\Dispatcher** (`src/Runtime/`) calls an action without an HTTP request of its own: `ApiInvocation` → a synthetic `BaseApiRequest` for the action's URI → `Router::dispatch()` (route middleware cascade, not the global kernel middleware) → `ApiResult::fromResponse()` (reads `{success, payload}` from the body). It forwards the outer request's headers/cookies and restores the container `request`, facades (`request`, `api_request`), `BaseApiRequest::$_instance` (via `swapInstance()`) and the router's current route/request. GET → query string (values become strings), otherwise JSON body. Non-HTTP transports (MCP, JSON-RPC) build on it.

**MCP** (`src/Transports/Mcp/`): `McpController` (HTTP endpoint `api-mcp`, `POST /{prefix}/{mcp.path}/{version}`, registered by `ApiServiceProvider::registerMcpRoutes()` only when `laravel-api.mcp.enabled`; Origin check; GET/DELETE → 405), `McpServer` (JSON-RPC: initialize/ping/tools/list/tools/call, stateless), `McpToolset` (tool per action `controller.action`; `find()` uses the registry only, `tools()` builds descriptors or reads `ApiCache::mcpTools()`; `$defs` for templates; `'mcp' => false` opt-out). Tests enable it with `enableMcp()` in `tests/Feature/Transports/McpTest.php` (routes are registered at boot).

**ApiCache** (`src/Services/ApiCache.php`): `api:cache` freezes the prepared methods of every version and the module's route definitions; `BaseApi::getPreparedMethods()` and `BaseModule::getRouteDefinitions()` read it first. `ApiServiceProvider::makeApiRoutes()` skips route registration when `routesAreCached()` (keeps the middleware group). Tests: `ApiCache::flush()` is in `TestCase::resetStaticState()`; anything writing the cache must `ApiCache::clear()` after.

Golden-master snapshots (`tests/Feature/GoldenMaster`, stored in `tests/.pest/snapshots`) pin the spec, TS types, exports, lint report and routes of every fixture API and the example. A snapshot changes only on purpose; regenerate with `vendor/bin/pest --update-snapshots`.
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
JSON body. The global HTTP middleware is not run again. The groundwork for
transports other than HTTP.
- `BaseApiRequest::swapInstance()`.
- **MCP server.** With `laravel-api.mcp.enabled`, every served API version is
an MCP server at `POST /{prefix}/mcp/{version}` (Streamable HTTP, JSON
answers, no sessions): `initialize`, `ping`, `tools/list`, `tools/call`. Each
action is a tool named `controller.action`, its input schema the action's
input as JSON Schema with the templates under `$defs`, GET actions marked
`readOnlyHint`. A call goes through `Runtime\Dispatcher` — router,
middleware, validation, as the caller of the MCP request — and returns the
action's payload as `structuredContent` and text, `isError` when `success`
is false. `'mcp' => false` in `getMethods()` leaves an action or a controller
out; `mcp.versions`, `mcp.middleware`, `mcp.allowed_origins` (the `Origin`
check against DNS rebinding) and `mcp.name` configure it. `api:cache` caches
the tool lists (cache format 2: files written by 6.1 are ignored and rebuilt
by the next `api:cache`); a call finds its action through the route map
without describing the others.

### Changed

Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Build versioned APIs with automatic OpenAPI documentation generated from PHP doc
- [OpenAPI 3.0 Documentation](#openapi-30-documentation)
- [CRUD Scaffolding](#crud-scaffolding)
- [Testing](#testing)
- [MCP server](#mcp-server)
- [Configuration](#configuration)
- [Error Handling](#error-handling)
- [IDE support](#ide-support)
Expand Down Expand Up @@ -769,6 +770,41 @@ generator uses, and also reports an action that validates its input without
declaring any (`input.undeclared`). Full list of rules:
[docs/linting.md](docs/en/linting.md).

## MCP server

Every API version can be an [MCP](https://modelcontextprotocol.io) server, so
agents call the API as tools:

```php
// config/laravel-api.php
'mcp' => [
'enabled' => true, // LARAVEL_API_MCP
'versions' => ['v2'], // empty: every version not in hidden_versions
'middleware' => ['auth:sanctum'], // on the MCP endpoint itself
'allowed_origins' => [], // browser origins besides the app's own
],
```

```bash
claude mcp add --transport http shop https://shop.test/api/mcp/v2 \
--header "Authorization: Bearer $TOKEN"
```

Each action is a tool named `controller.action` (`user.show`), described by
its docblock: the summary is the title, the `@input` lines are the input
schema (templates travel under `$defs`), GET actions are marked read-only. A
tool call goes through the same router, middleware cascade and validation as an
HTTP request, as the caller of the MCP request — its headers, the
`Authorization` above included, are passed on — and returns the action's
`payload` (`isError` when `success` is false). Leave an action or a controller
out with `'mcp' => false` in `getMethods()`.

The endpoint is `POST /{prefix}/mcp/{version}` (Streamable HTTP, JSON answers,
no sessions); a browser `Origin` other than the application's own is refused.
`api:cache` caches the tool lists too. Not offered: resources, prompts,
streaming, file uploads, and `outputSchema` — a docblock's `@output` is not
trusted to be exact enough for a client that validates against it.

## Production

```bash
Expand Down
24 changes: 24 additions & 0 deletions config/laravel-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,30 @@
// the namespaces, so it is off by default.
'expose_controller_class' => env('LARAVEL_API_EXPOSE_CONTROLLER_CLASS', false),

// The API as an MCP server, for agents: one per version, at
// /{prefix}/{path}/{version}. Every action is a tool named
// `controller.action`, called through the same router, middleware and
// validation as over HTTP, as the caller of the MCP request. Leave an
// action or a controller out with `'mcp' => false` in getMethods().
'mcp' => [
'enabled' => env('LARAVEL_API_MCP', false),

// The versions served; empty serves every one not in hidden_versions.
'versions' => [],

'path' => 'mcp',

// Run on the MCP endpoint itself — authentication, rate limiting.
'middleware' => [],

// Browser origins allowed besides the application's own (`*` for any).
// A request without an Origin — a client that is not a browser — passes.
'allowed_origins' => [],

// The server name agents see; the application's name when empty.
'name' => env('LARAVEL_API_MCP_NAME'),
],

// The documentation page on /api/doc: @dskripchenko/openapi's standalone
// reference — the versions with a switcher, search, try-it, code samples,
// TypeScript types, PDF, and links that keep working (DocLink's anchors).
Expand Down
36 changes: 36 additions & 0 deletions docs/de/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,42 @@ OpenAPI-Generator und meldet außerdem eine Aktion, die ihre Eingaben validiert,
ohne welche zu deklarieren (`input.undeclared`). Vollständige Regelliste:
[linting.md](linting.md).

## MCP-Server

Jede API-Version kann ein [MCP](https://modelcontextprotocol.io)-Server sein,
sodass Agenten die API als Tools aufrufen:

```php
// config/laravel-api.php
'mcp' => [
'enabled' => true, // LARAVEL_API_MCP
'versions' => ['v2'], // empty: every version not in hidden_versions
'middleware' => ['auth:sanctum'], // on the MCP endpoint itself
'allowed_origins' => [], // browser origins besides the app's own
],
```

```bash
claude mcp add --transport http shop https://shop.test/api/mcp/v2 \
--header "Authorization: Bearer $TOKEN"
```

Jede Action ist ein Tool namens `controller.action` (`user.show`), beschrieben
durch ihren Docblock: die Summary ist der Titel, die `@input`-Zeilen sind das
Eingabeschema (Templates reisen unter `$defs`), GET-Actions sind als
schreibgeschützt markiert. Ein Tool-Aufruf läuft durch denselben Router,
dieselbe Middleware-Kaskade und Validierung wie eine HTTP-Anfrage, als
derjenige, der den MCP-Endpunkt aufruft — seine Header, `Authorization`
eingeschlossen, werden weitergereicht — und liefert den `payload` der Action
(`isError`, wenn `success` false ist). Eine Action oder ein Controller wird mit
`'mcp' => false` in `getMethods()` ausgenommen.

Der Endpunkt ist `POST /{prefix}/mcp/{version}` (Streamable HTTP,
JSON-Antworten, keine Sessions); ein Browser-`Origin` außer dem eigenen wird
abgelehnt. `api:cache` cacht auch die Tool-Listen. Nicht angeboten: Resources,
Prompts, Streaming, Datei-Uploads und `outputSchema` — das `@output` eines
Docblocks gilt nicht als genau genug für einen Client, der dagegen validiert.

## API-Export

API-Spezifikation in verschiedenen Formaten exportieren:
Expand Down
35 changes: 35 additions & 0 deletions docs/ru/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,41 @@ php artisan api:lint --unrouted # плюс публичные методы, на
также сообщает о действии, которое валидирует вход, но ничего не объявляет
(`input.undeclared`). Полный список правил: [linting.md](linting.md).

## MCP-сервер

Каждая версия API может быть [MCP](https://modelcontextprotocol.io)-сервером —
агенты вызывают API как инструменты:

```php
// config/laravel-api.php
'mcp' => [
'enabled' => true, // LARAVEL_API_MCP
'versions' => ['v2'], // empty: every version not in hidden_versions
'middleware' => ['auth:sanctum'], // on the MCP endpoint itself
'allowed_origins' => [], // browser origins besides the app's own
],
```

```bash
claude mcp add --transport http shop https://shop.test/api/mcp/v2 \
--header "Authorization: Bearer $TOKEN"
```

Каждый action — инструмент с именем `controller.action` (`user.show`),
описанный своим докблоком: заголовок — summary, входная схема — строки
`@input` (шаблоны едут в `$defs`), GET-действия помечены как только для чтения.
Вызов инструмента идёт через тот же роутер, каскад middleware и валидацию, что и
HTTP-запрос, от имени того, кто вызвал MCP-эндпоинт — его заголовки, включая
`Authorization`, передаются, — и возвращает `payload` действия (`isError`, если
`success` ложен). Исключить action или контроллер — `'mcp' => false` в
`getMethods()`.

Эндпоинт — `POST /{prefix}/mcp/{version}` (Streamable HTTP, ответы JSON, без
сессий); браузерный `Origin`, отличный от собственного, отклоняется. `api:cache`
кеширует и списки инструментов. Не поддерживаются: resources, prompts,
стриминг, загрузка файлов и `outputSchema` — `@output` докблока не считается
настолько точным, чтобы клиент мог по нему валидировать.

## Продакшен

```bash
Expand Down
23 changes: 23 additions & 0 deletions docs/zh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,29 @@ php artisan api:lint --unrouted # 另外检查没有动作指向的公有方法
却没有声明任何输入的动作(`input.undeclared`)。完整规则列表:
[linting.md](linting.md)。

## MCP 服务器

每个 API 版本都可以作为 [MCP](https://modelcontextprotocol.io) 服务器,让智能体以工具的方式调用 API:

```php
// config/laravel-api.php
'mcp' => [
'enabled' => true, // LARAVEL_API_MCP
'versions' => ['v2'], // empty: every version not in hidden_versions
'middleware' => ['auth:sanctum'], // on the MCP endpoint itself
'allowed_origins' => [], // browser origins besides the app's own
],
```

```bash
claude mcp add --transport http shop https://shop.test/api/mcp/v2 \
--header "Authorization: Bearer $TOKEN"
```

每个操作都是一个名为 `controller.action`(如 `user.show`)的工具,由其 docblock 描述:summary 作为标题,`@input` 行作为输入模式(模板放在 `$defs` 下),GET 操作标记为只读。工具调用与 HTTP 请求一样经过同一路由、中间件级联和验证,以调用 MCP 端点者的身份执行(其请求头,包括 `Authorization`,会被传递),并返回操作的 `payload`(`success` 为 false 时 `isError` 为 true)。在 `getMethods()` 中用 `'mcp' => false` 排除某个操作或控制器。

端点为 `POST /{prefix}/mcp/{version}`(Streamable HTTP,JSON 响应,无会话);与应用自身不同的浏览器 `Origin` 会被拒绝。`api:cache` 也会缓存工具列表。不支持:resources、prompts、流式传输、文件上传以及 `outputSchema`——docblock 的 `@output` 不足以精确到让客户端据此校验。

## 生产环境

```bash
Expand Down
7 changes: 7 additions & 0 deletions src/Console/Commands/ApiCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ public function handle(): int
$specs === [] ? '' : sprintf('; %d OpenAPI spec(s) built', count($specs))
));

if ($data['mcp'] !== []) {
$this->info(sprintf(
'MCP tools cached for %s.',
implode(', ', array_map(static fn ($v, $e) => "{$v} (" . count($e['tools']) . ')', array_keys($data['mcp']), $data['mcp']))
));
}

return self::SUCCESS;
}
}
21 changes: 21 additions & 0 deletions src/Providers/ApiServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Dskripchenko\LaravelApi\Facades\ApiModule;
use Dskripchenko\LaravelApi\Components\BaseModule;
use Dskripchenko\LaravelApi\Requests\BaseApiRequest;
use Dskripchenko\LaravelApi\Transports\Mcp\McpController;
use Exception;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Routing\Router;
Expand Down Expand Up @@ -123,6 +124,24 @@ protected function getApiRequest()
return BaseApiRequest::getInstance();
}

/**
* The MCP endpoint, when `laravel-api.mcp.enabled` is on — inside the API
* prefix, ahead of the catch-all route.
*/
public static function registerMcpRoutes(): void
{
if (!config('laravel-api.mcp.enabled', false)) {
return;
}

$path = trim((string) config('laravel-api.mcp.path', 'mcp'), '/');

Route::match(['get', 'post', 'delete'], "{$path}/{version}", static function (string $version) {
return app()->call(McpController::class . '@handle', ['version' => $version]);
})->name('api-mcp')
->middleware((array) config('laravel-api.mcp.middleware', []));
}

public function makeApiRoutes()
{
$middlewareGroupName = "api-middleware-group";
Expand Down Expand Up @@ -150,6 +169,8 @@ public function makeApiRoutes()
})->name('api-doc-source')
->middleware(ApiModule::getDocMiddleware());

static::registerMcpRoutes();

$routeDefinitions = ApiModule::getRouteDefinitions();
foreach ($routeDefinitions as $definition) {
$perActionMiddleware = (array) ($definition['middleware'] ?? []);
Expand Down
42 changes: 39 additions & 3 deletions src/Services/ApiCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Dskripchenko\LaravelApi\Components\BaseApi;
use Dskripchenko\LaravelApi\Components\BaseModule;
use Dskripchenko\LaravelApi\Transports\Mcp\McpController;
use Dskripchenko\LaravelApi\Transports\Mcp\McpToolset;

/**
* The API map frozen into a file — what `php artisan api:cache` writes.
Expand All @@ -22,7 +24,7 @@
final class ApiCache
{
/** Bumped when the shape of the file changes; a file of another shape is ignored. */
private const FORMAT = 1;
private const FORMAT = 2;

/** @var array|false|null The file's contents; false when there is none; null before it was looked for. */
private static array|false|null $loaded = null;
Expand Down Expand Up @@ -66,9 +68,22 @@ public static function routeDefinitions(string $moduleClass): ?array
return $cache['routes'];
}

/**
* The MCP tools of a version, as `tools/list` answers with them, or null
* when they are not cached.
*/
public static function mcpTools(string $apiClass, string $version): ?array
{
$cache = self::load();
$entry = $cache === false ? null : ($cache['mcp'][$version] ?? null);

return ($entry['api'] ?? null) === $apiClass ? $entry['tools'] : null;
}

/**
* What the cache of a module holds: the prepared methods of every version
* class and the module's route definitions.
* class, the module's route definitions, and — with MCP on — the tools of
* every version it serves.
*
* @throws \UnexpectedValueException When the map holds something a PHP file cannot carry.
*/
Expand All @@ -81,11 +96,22 @@ public static function build(BaseModule $module): array
}
}

$mcp = [];
if (config('laravel-api.mcp.enabled', false)) {
foreach (McpController::servedVersions() as $version => $apiClass) {
$mcp[(string) $version] = [
'api' => $apiClass,
'tools' => (new McpToolset($apiClass, (string) $version))->tools(),
];
}
}

$data = [
'format' => self::FORMAT,
'module' => get_class($module),
'prepared' => $prepared,
'routes' => $module->getRouteDefinitions(),
'mcp' => $mcp,
];

self::assertExportable($data, '');
Expand Down Expand Up @@ -132,9 +158,19 @@ private static function load(): array|false
return self::$loaded = is_array($data) && ($data['format'] ?? null) === self::FORMAT ? $data : false;
}

/** var_export() writes an object as a call that brings back nothing useful. */
/**
* var_export() writes an object as a call that brings back nothing useful —
* except stdClass, which it writes as `(object) array(...)` and an empty
* JSON object needs.
*/
private static function assertExportable(mixed $value, string $path): void
{
if ($value instanceof \stdClass) {
self::assertExportable((array) $value, $path);

return;
}

if (is_object($value) || is_resource($value)) {
throw new \UnexpectedValueException(
"The API map holds a " . get_debug_type($value) . " at `{$path}`, which a cache file cannot carry."
Expand Down
Loading
Loading