diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 93bb74a..d253f87 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ac1d6..5a47203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e8202b4..b3767f6 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/config/laravel-api.php b/config/laravel-api.php index b31b31d..1ee38bf 100644 --- a/config/laravel-api.php +++ b/config/laravel-api.php @@ -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). diff --git a/docs/de/README.md b/docs/de/README.md index 5038461..142db87 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -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: diff --git a/docs/ru/README.md b/docs/ru/README.md index df4fccf..d0034ed 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -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 diff --git a/docs/zh/README.md b/docs/zh/README.md index ddf0360..ebeba25 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -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 diff --git a/src/Console/Commands/ApiCache.php b/src/Console/Commands/ApiCache.php index dbf2612..ff9e673 100644 --- a/src/Console/Commands/ApiCache.php +++ b/src/Console/Commands/ApiCache.php @@ -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; } } diff --git a/src/Providers/ApiServiceProvider.php b/src/Providers/ApiServiceProvider.php index 4eaa968..5e3c118 100644 --- a/src/Providers/ApiServiceProvider.php +++ b/src/Providers/ApiServiceProvider.php @@ -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; @@ -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"; @@ -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'] ?? []); diff --git a/src/Services/ApiCache.php b/src/Services/ApiCache.php index 014b102..e4bf765 100644 --- a/src/Services/ApiCache.php +++ b/src/Services/ApiCache.php @@ -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. @@ -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; @@ -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. */ @@ -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, ''); @@ -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." diff --git a/src/Transports/Mcp/McpController.php b/src/Transports/Mcp/McpController.php new file mode 100644 index 0000000..914e165 --- /dev/null +++ b/src/Transports/Mcp/McpController.php @@ -0,0 +1,114 @@ + Built once per process and version: describing every action is not free. */ + private static array $toolsets = []; + + public function handle(Request $request, string $version): Response + { + $apiClass = self::servedVersions()[$version] ?? null; + if ($apiClass === null) { + return new JsonResponse(McpServer::error(null, McpServer::INVALID_REQUEST, "No MCP server for version {$version}."), 404); + } + + // A web page must not be able to drive the server through the + // reader's browser (DNS rebinding): the transport asks for the Origin + // to be checked. + if (!self::originAllowed($request)) { + return new JsonResponse(McpServer::error(null, McpServer::INVALID_REQUEST, 'Origin not allowed.'), 403); + } + + if (!$request->isMethod('POST')) { + return new JsonResponse( + McpServer::error(null, McpServer::INVALID_REQUEST, 'Only POST: this server keeps no sessions and streams nothing.'), + 405, + ['Allow' => 'POST'] + ); + } + + try { + $message = json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return new JsonResponse(McpServer::error(null, McpServer::PARSE_ERROR, 'Parse error: ' . $e->getMessage()), 400); + } + + $server = new McpServer( + $apiClass, + $version, + self::$toolsets[$version] ??= new McpToolset($apiClass, $version), + app(Dispatcher::class), + ); + + $answer = $server->handle($message); + + if ($answer === null) { + return new Response('', 202); + } + + return new JsonResponse($answer, 200, [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** Forgets the toolsets built so far. */ + public static function flush(): void + { + self::$toolsets = []; + } + + /** + * The versions served: `laravel-api.mcp.versions`, or every version the + * documentation lists when that is empty. + * + * @return array + */ + public static function servedVersions(): array + { + $all = ApiModule::getApiVersionList(); + $chosen = (array) config('laravel-api.mcp.versions', []); + + $versions = $chosen === [] + ? array_diff_key($all, array_flip((array) config('laravel-api.hidden_versions', []))) + : array_intersect_key($all, array_flip($chosen)); + + return array_filter( + $versions, + static fn ($apiClass): bool => is_string($apiClass) && is_subclass_of($apiClass, BaseApi::class) + ); + } + + /** + * No Origin (a client that is not a browser), the application's own, or + * one `laravel-api.mcp.allowed_origins` lists (`*` for any). + */ + private static function originAllowed(Request $request): bool + { + $origin = $request->headers->get('Origin'); + if ($origin === null || $origin === '') { + return true; + } + + $allowed = (array) config('laravel-api.mcp.allowed_origins', []); + + return in_array('*', $allowed, true) + || in_array($origin, $allowed, true) + || $origin === $request->getSchemeAndHttpHost(); + } +} diff --git a/src/Transports/Mcp/McpServer.php b/src/Transports/Mcp/McpServer.php new file mode 100644 index 0000000..6c570a2 --- /dev/null +++ b/src/Transports/Mcp/McpServer.php @@ -0,0 +1,149 @@ + $headers Headers to pass on to the actions a tool calls. + */ + public function handle(mixed $message, array $headers = []): ?array + { + if (!is_array($message) || array_is_list($message) || ($message['jsonrpc'] ?? null) !== '2.0') { + return self::error(null, self::INVALID_REQUEST, 'Not a JSON-RPC 2.0 message; batches are not supported.'); + } + + $isRequest = array_key_exists('id', $message); + $id = $message['id'] ?? null; + + // A notification, or the client answering a request of ours: neither + // gets a reply. + if (!$isRequest || !isset($message['method'])) { + return null; + } + + if (!is_string($message['method'])) { + return self::error($id, self::INVALID_REQUEST, 'The method has to be a string.'); + } + + $params = $message['params'] ?? []; + if (!is_array($params)) { + return self::error($id, self::INVALID_PARAMS, 'The params have to be an object.'); + } + + return match ($message['method']) { + 'initialize' => self::result($id, $this->initialize($params)), + 'ping' => self::result($id, new \stdClass()), + 'tools/list' => self::result($id, ['tools' => $this->tools->tools()]), + 'tools/call' => $this->call($id, $params, $headers), + default => self::error($id, self::METHOD_NOT_FOUND, "Method not found: {$message['method']}"), + }; + } + + private function initialize(array $params): array + { + $requested = $params['protocolVersion'] ?? null; + $version = in_array($requested, self::PROTOCOL_VERSIONS, true) ? $requested : self::PROTOCOL_VERSIONS[0]; + + $doc = OperationDocParser::parse((new \ReflectionClass($this->apiClass))->getDocComment()); + + $result = [ + 'protocolVersion' => $version, + 'capabilities' => ['tools' => ['listChanged' => false]], + 'serverInfo' => [ + 'name' => (string) (config('laravel-api.mcp.name') ?: config('app.name', 'laravel-api')), + 'version' => $this->version, + ], + ]; + + $instructions = trim($doc->summary . "\n\n" . $doc->description); + if ($instructions !== '') { + $result['instructions'] = $instructions; + } + + return $result; + } + + private function call(mixed $id, array $params, array $headers): array + { + $name = $params['name'] ?? null; + $operation = is_string($name) ? $this->tools->find($name) : null; + if ($operation === null) { + return self::error($id, self::INVALID_PARAMS, 'Unknown tool: ' . (is_string($name) ? $name : '(none)')); + } + + $arguments = $params['arguments'] ?? []; + if (!is_array($arguments)) { + return self::error($id, self::INVALID_PARAMS, 'The arguments have to be an object.'); + } + + $result = $this->dispatcher->dispatch(new ApiInvocation( + $this->version, + $operation->controllerKey, + $operation->actionKey, + $arguments, + $headers, + )); + + // A failed action is a tool result, not a protocol error: the model + // reads it and may correct the call. + $answer = [ + 'content' => [[ + 'type' => 'text', + 'text' => (string) json_encode($result->payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]], + 'isError' => !$result->success, + ]; + + if (is_array($result->payload) && !array_is_list($result->payload)) { + $answer['structuredContent'] = $result->payload; + } + + return self::result($id, $answer); + } + + private static function result(mixed $id, mixed $result): array + { + return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]; + } + + public static function error(mixed $id, int $code, string $message): array + { + return ['jsonrpc' => '2.0', 'id' => $id, 'error' => ['code' => $code, 'message' => $message]]; + } +} diff --git a/src/Transports/Mcp/McpToolset.php b/src/Transports/Mcp/McpToolset.php new file mode 100644 index 0000000..a43cf94 --- /dev/null +++ b/src/Transports/Mcp/McpToolset.php @@ -0,0 +1,204 @@ + false` in its definition, or in its + * controller's. + */ +final class McpToolset +{ + /** @var array|null Tool name → the operation it calls. */ + private ?array $operations = null; + + /** @var array[]|null */ + private ?array $tools = null; + + /** + * @param class-string $apiClass + */ + public function __construct( + public readonly string $apiClass, + public readonly string $version, + ) { + } + + /** + * The tools, as `tools/list` answers with them. + * + * @return array[] + */ + public function tools(): array + { + // `php artisan api:cache` described them already. + return $this->tools ??= ApiCache::mcpTools($this->apiClass, $this->version) ?? $this->describe(); + } + + /** + * The operation a tool calls, or null when there is no such tool. + * + * From the route map alone: a call needs one action, not a description of + * all of them. + */ + public function find(string $name): ?Operation + { + return $this->operations()[$name] ?? null; + } + + /** `controller.action`, with what a tool name cannot carry replaced by `_`. */ + public static function toolName(Operation $operation): string + { + return preg_replace('/[^A-Za-z0-9_\-]/', '_', $operation->controllerKey) + . '.' + . preg_replace('/[^A-Za-z0-9_\-]/', '_', $operation->actionKey); + } + + /** @return array */ + private function operations(): array + { + if ($this->operations !== null) { + return $this->operations; + } + + $operations = []; + foreach ($this->apiClass::operations($this->version)->controllers() as $controller) { + if (($controller->options['mcp'] ?? true) === false) { + continue; + } + + foreach ($controller->operations as $operation) { + if (($operation->options['mcp'] ?? true) !== false && $operation->isCallable()) { + $operations[self::toolName($operation)] = $operation; + } + } + } + + return $this->operations = $operations; + } + + /** @return array[] */ + private function describe(): array + { + $builder = DescriptorBuilder::for($this->apiClass); + $templates = SchemaRegistry::for($this->apiClass)->all(); + + $tools = []; + foreach ($this->operations() as $name => $operation) { + [$descriptor] = $builder->build($operation); + + $description = trim($descriptor->summary() . "\n\n" . $descriptor->description()); + if ($descriptor->deprecated) { + $description = trim('Deprecated. ' . $description); + } + + $tool = [ + 'name' => $name, + // The first line: a docblock's second line runs into its summary. + 'title' => trim(strtok($descriptor->summary(), "\n") ?: '') ?: $name, + 'description' => $description, + 'inputSchema' => self::withDefinitions(self::asObject($descriptor->input->schema()), $templates), + ]; + + if (strtolower($descriptor->httpMethod) === 'get') { + $tool['annotations'] = ['readOnlyHint' => true]; + } + + $tools[] = $tool; + } + + return $tools; + } + + /** + * An input schema has to be an object. A whole input named by a template + * is one, wrapped; an empty input is an object without properties. + */ + private static function asObject(array $schema): array + { + if (isset($schema['$ref'])) { + return ['type' => 'object', 'allOf' => [$schema]]; + } + + if (($schema['properties'] ?? null) === []) { + $schema['properties'] = new \stdClass(); + } + + return $schema; + } + + /** + * The templates the schema refers to, moved under `$defs` and the + * references pointed there — following the templates' own references too. + * + * @param array $templates + */ + private static function withDefinitions(array $schema, array $templates): array + { + $definitions = []; + $pending = self::references($schema); + + while ($pending !== []) { + $name = array_shift($pending); + if (isset($definitions[$name]) || !isset($templates[$name])) { + continue; + } + + $definitions[$name] = self::pointToDefinitions($templates[$name]); + array_push($pending, ...self::references($templates[$name])); + } + + $schema = self::pointToDefinitions($schema); + if ($definitions !== []) { + ksort($definitions); + $schema['$defs'] = $definitions; + } + + return $schema; + } + + /** @return string[] */ + private static function references(array $schema): array + { + $names = []; + array_walk_recursive($schema, static function ($value, $key) use (&$names): void { + if ($key === '$ref' && is_string($value) && str_starts_with($value, SchemaRef::PREFIX)) { + $names[] = SchemaRef::name($value); + } + }); + + return array_values(array_unique($names)); + } + + private static function pointToDefinitions(mixed $schema): mixed + { + if (!is_array($schema)) { + return $schema; + } + + foreach ($schema as $key => $value) { + if ($key === '$ref' && is_string($value) && str_starts_with($value, SchemaRef::PREFIX)) { + $schema[$key] = '#/$defs/' . SchemaRef::name($value); + } else { + $schema[$key] = self::pointToDefinitions($value); + } + } + + return $schema; + } +} diff --git a/tests/Feature/Transports/McpTest.php b/tests/Feature/Transports/McpTest.php new file mode 100644 index 0000000..a6fe660 --- /dev/null +++ b/tests/Feature/Transports/McpTest.php @@ -0,0 +1,270 @@ + array_merge(config('laravel-api.mcp'), ['enabled' => true], $config)]); + Route::prefix(ApiModule::getApiPrefix())->group(static fn () => ApiServiceProvider::registerMcpRoutes()); + Route::getRoutes()->refreshNameLookups(); +} + +function mcp(object $test, string $method, array $params = [], array $headers = [], string $version = 'v1', int|string|null $id = 1): TestResponse +{ + $message = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]; + if ($id !== null) { + $message['id'] = $id; + } + + return $test->postJson("/api/mcp/{$version}", $message, $headers); +} + +function toolNames(TestResponse $response): array +{ + return array_column($response->json('result.tools'), 'name'); +} + +it('is off unless enabled', function () { + $this->postJson('/api/mcp/v1', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping'])->assertNotFound(); +}); + +it('initializes, agreeing on a protocol version it knows', function () { + enableMcp(['name' => 'Shop']); + + $response = mcp($this, 'initialize', ['protocolVersion' => '2025-06-18', 'capabilities' => [], 'clientInfo' => ['name' => 't', 'version' => '1']]); + + $response->assertOk()->assertJson([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => ['tools' => ['listChanged' => false]], + 'serverInfo' => ['name' => 'Shop', 'version' => 'v1'], + 'instructions' => "Test API v1\n\nTest API description", + ], + ]); + + expect(mcp($this, 'initialize', ['protocolVersion' => '1999-01-01'])->json('result.protocolVersion')) + ->toBe(McpServer::PROTOCOL_VERSIONS[0]); +}); + +it('accepts notifications without answering them', function () { + enableMcp(); + + $response = mcp($this, 'notifications/initialized', id: null); + + $response->assertStatus(202); + expect($response->getContent())->toBe(''); +}); + +it('answers ping with an empty object', function () { + enableMcp(); + + expect(mcp($this, 'ping')->getContent())->toBe('{"jsonrpc":"2.0","id":1,"result":{}}'); +}); + +it('lists every action as a tool', function () { + enableMcp(); + + $response = mcp($this, 'tools/list'); + + expect(toolNames($response))->toEqualCanonicalizing(['item.list', 'item.show', 'item.create', 'item.update', 'item.remove', 'open.ping']); + + $ping = collect($response->json('result.tools'))->firstWhere('name', 'open.ping'); + expect($ping['annotations'])->toBe(['readOnlyHint' => true]); + // The first line of the summary: a docblock's second line runs into it. + expect($ping['title'])->toBe('Ping'); + expect($ping['inputSchema']['type'])->toBe('object'); + + // The input of a middleware travels with the action's own. + $create = collect($response->json('result.tools'))->firstWhere('name', 'item.create'); + expect(array_keys($create['inputSchema']['properties']))->toContain('name', 'token'); + expect($create)->not->toHaveKey('annotations'); +}); + +it('calls a tool and hands back what the action answered', function () { + enableMcp(); + + mcp($this, 'tools/call', ['name' => 'open.ping', 'arguments' => []]) + ->assertOk() + ->assertExactJson([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'content' => [['type' => 'text', 'text' => '{"pong":true}']], + 'isError' => false, + 'structuredContent' => ['pong' => true], + ], + ]); +}); + +it('calls as the caller of the MCP request, through the same middleware', function () { + enableMcp(); + + $refused = mcp($this, 'tools/call', ['name' => 'item.list']); + expect($refused->json('result.isError'))->toBeTrue(); + expect($refused->json('result.structuredContent.errorKey'))->toBe('auth_error'); + + $allowed = mcp($this, 'tools/call', ['name' => 'item.create', 'arguments' => ['name' => 'New']], ['X-Auth-Token' => 'token']); + expect($allowed->json('result.isError'))->toBeFalse(); + expect($allowed->json('result.structuredContent'))->toBe(['id' => 1, 'name' => 'New']); +}); + +it('answers protocol mistakes with JSON-RPC errors', function () { + enableMcp(); + + expect(mcp($this, 'tools/call', ['name' => 'item.nope'])->json('error.code'))->toBe(McpServer::INVALID_PARAMS); + expect(mcp($this, 'resources/list')->json('error.code'))->toBe(McpServer::METHOD_NOT_FOUND); + + $batch = $this->postJson('/api/mcp/v1', [['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping']]); + expect($batch->json('error.code'))->toBe(McpServer::INVALID_REQUEST); + + $parse = $this->call('POST', '/api/mcp/v1', [], [], [], ['CONTENT_TYPE' => 'application/json'], '{nope'); + $parse->assertStatus(400); + expect($parse->json('error.code'))->toBe(McpServer::PARSE_ERROR); +}); + +it('keeps no sessions and streams nothing', function () { + enableMcp(); + + $this->get('/api/mcp/v1')->assertStatus(405)->assertHeader('Allow', 'POST'); + $this->delete('/api/mcp/v1')->assertStatus(405); +}); + +it('serves the versions it is told to', function () { + enableMcp(['versions' => ['v2']]); + + mcp($this, 'ping')->assertNotFound(); + mcp($this, 'ping', version: 'v2')->assertOk(); + mcp($this, 'ping', version: 'v9')->assertNotFound(); +}); + +it('refuses a browser origin it does not know', function () { + enableMcp(); + + mcp($this, 'ping', [], ['Origin' => 'https://evil.example'])->assertForbidden(); + mcp($this, 'ping', [], ['Origin' => 'http://localhost'])->assertOk(); + + enableMcp(['allowed_origins' => ['https://agent.example']]); + mcp($this, 'ping', [], ['Origin' => 'https://agent.example'])->assertOk(); +}); + +// --- The toolset on its own --------------------------------------------------- + +class McpToolsetApi extends BaseApi +{ + public static $useResponseTemplates = true; + + public static function getMethods(): array + { + return ['controllers' => [ + 'order' => ['controller' => McpToolsetController::class, 'actions' => [ + 'place' => ['method' => ['post']], + 'internal' => ['mcp' => false], + ]], + 'admin' => ['controller' => McpToolsetController::class, 'mcp' => false, 'actions' => ['place']], + ]]; + } + + public static function getSchemaTemplates(): array + { + return [ + 'Order' => ['item!' => '@Item', 'note' => 'string'], + 'Item' => ['id' => 'integer!'], + 'Unused' => ['x' => 'string'], + ]; + } +} + +class McpToolsetController +{ + /** + * Place an order + * + * The order goes to the warehouse. + * @input @Order + * @deprecated + */ + public function place() + { + } + + /** + * Internal + */ + public function internal() + { + } +} + +it('leaves out what getMethods() marks with mcp => false', function () { + $tools = (new McpToolset(McpToolsetApi::class, 'v1'))->tools(); + + expect(array_column($tools, 'name'))->toBe(['order.place']); +}); + +it('carries the templates an input refers to under $defs', function () { + [$tool] = (new McpToolset(McpToolsetApi::class, 'v1'))->tools(); + + expect($tool['title'])->toBe('Place an order'); + expect($tool['description'])->toBe("Deprecated. Place an order\n\nThe order goes to the warehouse."); + expect($tool['inputSchema'])->toBe([ + 'type' => 'object', + 'allOf' => [['$ref' => '#/$defs/Order']], + '$defs' => [ + 'Item' => ['type' => 'object', 'properties' => ['id' => ['type' => 'integer']], 'required' => ['id']], + 'Order' => [ + 'type' => 'object', + 'properties' => ['item' => ['$ref' => '#/$defs/Item'], 'note' => ['type' => 'string']], + 'required' => ['item'], + ], + ], + ]); +}); + +// --- api:cache ------------------------------------------------------------------ + +it('caches the tools with the map, and lists them from there', function () { + enableMcp(); + try { + $this->artisan('api:cache', ['--no-docs' => true]) + ->expectsOutputToContain('MCP tools cached for v1 (6), v2') + ->assertSuccessful(); + + $cached = \Dskripchenko\LaravelApi\Services\ApiCache::mcpTools(\Tests\Fixtures\Versions\v1\TestApi::class, 'v1'); + expect(array_column($cached, 'name'))->toContain('open.ping'); + + // An empty input stays a JSON object through var_export(). + expect(mcp($this, 'tools/list')->getContent()) + ->toContain('"name":"open.ping","title":"Ping","description":"Ping\\nHealth check endpoint.'); + expect(mcp($this, 'tools/list')->getContent())->toContain('"inputSchema":{"type":"object","properties":{}}'); + + // Another class with the same version string is not served from it. + expect(\Dskripchenko\LaravelApi\Services\ApiCache::mcpTools(McpToolsetApi::class, 'v1'))->toBeNull(); + } finally { + \Dskripchenko\LaravelApi\Services\ApiCache::clear(); + } +}); + +it('serves tools/list from the cache rather than describing the actions again', function () { + enableMcp(); + $data = \Dskripchenko\LaravelApi\Services\ApiCache::build(app('api_module')); + $data['mcp']['v1']['tools'] = [['name' => 'frozen.tool', 'title' => 'Frozen', 'description' => '', 'inputSchema' => ['type' => 'object', 'properties' => new stdClass()]]]; + \Dskripchenko\LaravelApi\Services\ApiCache::write($data); + try { + expect(toolNames(mcp($this, 'tools/list')))->toBe(['frozen.tool']); + // A call still finds its action through the route map. + expect(mcp($this, 'tools/call', ['name' => 'open.ping'])->json('result.isError'))->toBeFalse(); + } finally { + \Dskripchenko\LaravelApi\Services\ApiCache::clear(); + } +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index e6cb1f4..32f201e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -49,6 +49,7 @@ protected function resetStaticState(): void \Dskripchenko\LaravelApi\Operations\Doc\OperationDocParser::flush(); \Dskripchenko\LaravelApi\Operations\Schema\SchemaRegistry::flush(); \Dskripchenko\LaravelApi\Services\ApiCache::flush(); + \Dskripchenko\LaravelApi\Transports\Mcp\McpController::flush(); // Reset BaseApiRequest::$_instance $ref = new \ReflectionProperty(BaseApiRequest::class, '_instance');