From aac3e94b523e2f204e814045358610457f823096 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Tue, 7 Jul 2026 13:26:29 +0300 Subject: [PATCH 1/3] feat(mcp): harden and modernize the MCP server layer - Make operation tools stateless per call: tools hold only the repository class string and resolve a fresh Repository in every handle(), fixing the root cause behind #735 (singleton-cached repository state) and removing the store-model band-aid. Collapse the 8 duplicate execute* dispatch methods in McpToolsManager. - Return real MCP errors: all failure paths now set isError=true via Response::error() with a stable {error, code, errors, detail} payload; actions/getters propagate per-field ValidationException detail like CRUD; raw exception messages only surface when app.debug is on; fix the 'getter' vs 'action' key in the action authorization error. - Add tool annotations (#[IsReadOnly]/#[IsDestructive]/#[IsIdempotent]/ #[IsOpenWorld]) to every operation, wrapper, and search tool, and expose the same hints per operation in the wrapper catalog payloads. - Harden the server: default mode flips to 'wrapper' (breaking, 10.x), the per-request ?mode= override is removed, new restify.mcp.read_only mode gates write operations in discovery and execution, and store file ingestion rejects local paths by default, blocks private-IP URLs, and caps download size. - Emit structuredContent alongside JSON text on tool results so hosts can parse responses deterministically. - New restify:skill artisan command generating SKILL.md + openapi.json from MCP-enabled repositories (auth, pagination, filter conventions, per-repository operations and curl examples). BREAKING CHANGE: restify.mcp.mode now defaults to 'wrapper'; set RESTIFY_MCP_MODE=direct to keep the previous per-operation tool exposure. --- config/restify.php | 42 +- docs-v3/content/docs/6.mcp/1.mcp.md | 169 ++++++- src/Commands/SkillCommand.php | 49 ++ src/LaravelRestifyServiceProvider.php | 2 + src/MCP/Concerns/McpActionTool.php | 22 +- src/MCP/Concerns/McpGetterTool.php | 26 +- src/MCP/Concerns/McpStoreTool.php | 107 +++- src/MCP/Concerns/WrapperToolHelpers.php | 23 +- src/MCP/Enums/OperationTypeEnum.php | 25 + src/MCP/McpTools.php | 2 +- src/MCP/McpToolsManager.php | 121 ++--- src/MCP/RestifyServer.php | 7 +- src/MCP/Skill/RestifySkillGenerator.php | 459 ++++++++++++++++++ src/MCP/Tools/GlobalSearchTool.php | 2 + src/MCP/Tools/Operations/ActionTool.php | 42 +- src/MCP/Tools/Operations/DeleteTool.php | 31 +- src/MCP/Tools/Operations/GetterTool.php | 42 +- src/MCP/Tools/Operations/IndexTool.php | 27 +- src/MCP/Tools/Operations/ProfileTool.php | 29 +- src/MCP/Tools/Operations/ShowTool.php | 29 +- src/MCP/Tools/Operations/StoreTool.php | 25 +- src/MCP/Tools/Operations/UpdateTool.php | 27 +- .../Wrapper/DiscoverRepositoriesTool.php | 27 +- .../Tools/Wrapper/ExecuteOperationTool.php | 53 +- .../Tools/Wrapper/GetOperationDetailsTool.php | 52 +- .../Wrapper/GetRepositoryOperationsTool.php | 43 +- tests/MCP/McpActionsIntegrationTest.php | 7 + tests/MCP/McpErrorResponseTest.php | 174 +++++++ tests/MCP/McpFieldsIntegrationTest.php | 7 + tests/MCP/McpServerHardeningTest.php | 251 ++++++++++ tests/MCP/McpStoreToolIntegrationTest.php | 223 +++++++++ tests/MCP/McpToolAnnotationsTest.php | 44 ++ tests/MCP/McpUpdateToolIntegrationTest.php | 7 + tests/MCP/SkillCommandTest.php | 187 +++++++ tests/MCP/WrapperToolsIntegrationTest.php | 175 +++++++ 35 files changed, 2245 insertions(+), 313 deletions(-) create mode 100644 src/Commands/SkillCommand.php create mode 100644 src/MCP/Skill/RestifySkillGenerator.php create mode 100644 tests/MCP/McpErrorResponseTest.php create mode 100644 tests/MCP/McpServerHardeningTest.php create mode 100644 tests/MCP/McpToolAnnotationsTest.php create mode 100644 tests/MCP/SkillCommandTest.php diff --git a/config/restify.php b/config/restify.php index e53384369..37f43ffef 100644 --- a/config/restify.php +++ b/config/restify.php @@ -326,7 +326,47 @@ | regardless of this setting. | */ - 'mode' => env('RESTIFY_MCP_MODE', 'direct'), + 'mode' => env('RESTIFY_MCP_MODE', 'wrapper'), + + /* + |-------------------------------------------------------------------------- + | Read-Only Mode + |-------------------------------------------------------------------------- + | + | When enabled, the MCP server refuses every write operation (store, + | update, delete, action). In 'direct' mode only read tools (index, show, + | getter, profile) are registered; in 'wrapper' mode write operations are + | hidden from discovery and rejected by execute-operation. + | + */ + 'read_only' => env('RESTIFY_MCP_READ_ONLY', false), + + /* + |-------------------------------------------------------------------------- + | File Ingestion + |-------------------------------------------------------------------------- + | + | Controls how AI-supplied file fields are resolved into uploads. + | + */ + 'files' => [ + /* + | Allow AI-supplied values to reference local filesystem paths. Disabled + | by default: enabling it lets an MCP client read arbitrary server files. + */ + 'allow_local_paths' => env('RESTIFY_MCP_FILES_ALLOW_LOCAL_PATHS', false), + + /* + | Maximum number of bytes fetched from a remote file URL. Downloads + | larger than this are rejected. Defaults to 10MB. + */ + 'max_bytes' => env('RESTIFY_MCP_FILES_MAX_BYTES', 10 * 1024 * 1024), + + /* + | Timeout in seconds applied when fetching a remote file URL. + */ + 'timeout' => env('RESTIFY_MCP_FILES_TIMEOUT', 10), + ], 'tools' => [ 'exclude' => [ diff --git a/docs-v3/content/docs/6.mcp/1.mcp.md b/docs-v3/content/docs/6.mcp/1.mcp.md index cdeeba62a..ce50a06a7 100644 --- a/docs-v3/content/docs/6.mcp/1.mcp.md +++ b/docs-v3/content/docs/6.mcp/1.mcp.md @@ -168,6 +168,49 @@ This approach allows you to: - Set validation rules (required, optional) - Override automatic type inference when it's incorrect +### Error Responses + +When an operation fails, Restify returns a real MCP error result with `isError` set to `true` on the tool result, so agents can reliably distinguish failures from successful calls. + +The error content is a JSON payload with a stable shape. The keys are guaranteed to be stable so agents can parse them: + +```json +{ + "error": "Validation failed", + "code": "VALIDATION_ERROR", + "errors": { + "title": ["The title field is required."] + } +} +``` + +- `error`: a human-readable message. +- `code`: a machine-readable code such as `MISSING_PARAMETER`, `INVALID_OPERATION`, `VALIDATION_ERROR`, `AUTHORIZATION_ERROR`, `NOT_FOUND`, or `EXECUTION_ERROR`. +- `errors`: present for validation failures, containing per-field messages. Actions and getters surface the same per-field detail as CRUD `store`/`update` operations. +- `detail`: present only for unexpected server errors and only when `app.debug` is enabled. Internal exception messages are never leaked to clients in production. + +### Structured Tool Output + +Successful tool results are emitted with both the existing JSON `text` content and a machine-parsable `structuredContent` field on the same tool result. This lets hosts read the payload deterministically without re-parsing the text block, while clients that only read `text` continue to work unchanged. + +Both the operation tools (index, show, store, update, delete, profile, action, getter) and the 4 wrapper tools (`discover-repositories`, `get-repository-operations`, `get-operation-details`, `execute-operation`) return structured content. The `structuredContent` value is always byte-for-byte identical to the JSON parsed from the `text` block: + +```json +{ + "result": { + "content": [ + { "type": "text", "text": "{ \"success\": true, \"total\": 1, ... }" } + ], + "structuredContent": { "success": true, "total": 1, "...": "..." }, + "isError": false + } +} +``` + +The three read-only wrapper discovery tools also publish an `outputSchema` (their shape is stable), so hosts can validate `structuredContent` up front. Operation tools such as index/show derive their shape from repository fields at runtime and therefore do not declare a static `outputSchema`. + +Error results are intentionally **not** given `structuredContent`: they are already flagged with `isError: true` and carry the stable JSON error payload described above in their `text` content. + ## Configuration The MCP integration respects your existing Restify configuration and adds MCP-specific options: @@ -178,7 +221,13 @@ The MCP integration respects your existing Restify configuration and adds MCP-sp 'server_name' => 'My App MCP Server', 'server_version' => '1.0.0', 'default_pagination' => 25, - 'mode' => env('RESTIFY_MCP_MODE', 'direct'), // 'direct' or 'wrapper' + 'mode' => env('RESTIFY_MCP_MODE', 'wrapper'), // 'wrapper' (default) or 'direct' + 'read_only' => env('RESTIFY_MCP_READ_ONLY', false), // refuse all write operations + 'files' => [ + 'allow_local_paths' => env('RESTIFY_MCP_FILES_ALLOW_LOCAL_PATHS', false), + 'max_bytes' => env('RESTIFY_MCP_FILES_MAX_BYTES', 10 * 1024 * 1024), + 'timeout' => env('RESTIFY_MCP_FILES_TIMEOUT', 10), + ], 'tools' => [ 'exclude' => [ // Tools to exclude from discovery @@ -190,11 +239,15 @@ The MCP integration respects your existing Restify configuration and adds MCP-sp ], ``` +::alert{type="warning"} +As of Restify 10, the MCP mode defaults to **`wrapper`** (previously `direct`). Wrapper mode exposes only 4 tools and hides individual operations behind a progressive discovery workflow. Set `RESTIFY_MCP_MODE=direct` to restore the previous behavior. +:: + ## MCP Mode: Direct vs Wrapper Laravel Restify offers two modes for exposing your repositories through MCP: **Direct Mode** and **Wrapper Mode**. Each mode has different trade-offs in terms of token usage and discoverability. -### Direct Mode (Default) +### Direct Mode In direct mode, every repository operation (index, show, store, update, delete) and custom action/getter is exposed as a separate MCP tool. This provides maximum discoverability for AI agents. @@ -212,9 +265,9 @@ In direct mode, every repository operation (index, show, store, update, delete) RESTIFY_MCP_MODE=direct ``` -### Wrapper Mode (Token-Efficient) +### Wrapper Mode (Default, Token-Efficient) -Wrapper mode uses a progressive discovery pattern that exposes only **4 wrapper tools** regardless of how many repositories you have. AI agents discover and execute operations through a multi-step workflow. +Wrapper mode is the default. It uses a progressive discovery pattern that exposes only **4 wrapper tools** regardless of how many repositories you have. AI agents discover and execute operations through a multi-step workflow. **When to use Wrapper Mode:** - You have many repositories (10+) @@ -279,24 +332,43 @@ Lists all operations, actions, and getters available for a specific repository. { "success": true, "repository": "users", - "crud_operations": ["index", "show", "store", "update", "delete", "profile"], + "operations": [ + { + "type": "index", + "name": "users-index-tool", + "title": "Users", + "description": "List User records", + "annotations": { "readOnlyHint": true } + }, + { + "type": "delete", + "name": "users-delete-tool", + "title": "Users Delete", + "description": "Delete an existing User record", + "annotations": { "destructiveHint": true, "idempotentHint": true } + } + ], "actions": [ { "name": "activate-user", "title": "Activate User", - "description": "Activate a user account" + "description": "Activate a user account", + "annotations": { "openWorldHint": true } } ], "getters": [ { "name": "active-users", "title": "Active Users", - "description": "Get all active users" + "description": "Get all active users", + "annotations": { "readOnlyHint": true } } ] } ``` +Each operation, action, and getter carries an `annotations` object with the same [MCP tool safety hints](#tool-safety-annotations) that direct mode exposes on the tool definitions, so agents get consistent safety signals through the wrapper path. + #### 3. `get-operation-details` Returns the complete JSON schema and documentation for a specific operation, including all parameters, validation rules, and examples. @@ -316,6 +388,7 @@ Returns the complete JSON schema and documentation for a specific operation, inc "type": "create", "title": "Create User", "description": "Create a new user account", + "annotations": {}, "schema": { "type": "object", "properties": { @@ -367,6 +440,20 @@ Executes a repository operation with the provided parameters. This is the final } ``` +### Tool Safety Annotations + +Every operation tool is tagged with the [MCP tool annotations](https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations) that describe its behavior, so a host can warn before running a destructive call or safely cache a read-only one. Restify applies them automatically based on the operation type: + +| Operation | Annotation hints | +| --- | --- | +| `index`, `show`, `profile`, getters, `global-search` | `readOnlyHint: true` | +| `store` | *(none — a create is neither idempotent nor read-only)* | +| `update` | `idempotentHint: true` | +| `delete` | `destructiveHint: true`, `idempotentHint: true` | +| actions | `openWorldHint: true` (an action may touch external systems) | + +In **direct mode** these appear in the `annotations` field of each tool definition returned by `tools/list`. In **wrapper mode** the per-operation tools are not registered with the host, so the same hints are surfaced inside the `annotations` object of every entry returned by `get-repository-operations` and `get-operation-details`. + ### Wrapper Mode Workflow Here's a typical workflow when an AI agent uses wrapper mode: @@ -383,14 +470,14 @@ This progressive discovery pattern reduces token usage while maintaining full fu You can switch between modes at any time by updating your `.env` file: ```bash -# Direct mode (default) -RESTIFY_MCP_MODE=direct - -# Wrapper mode (token-efficient) +# Wrapper mode (default, token-efficient) RESTIFY_MCP_MODE=wrapper + +# Direct mode +RESTIFY_MCP_MODE=direct ``` -No code changes are required. The MCP server automatically adapts to the configured mode. +No code changes are required. The MCP server automatically adapts to the configured mode. The mode is read from configuration only — a request parameter cannot override it. ### Performance Considerations @@ -408,12 +495,66 @@ No code changes are required. The MCP server automatically adapts to the configu ### Best Practices -1. **Start with Direct Mode** during development to verify all tools are working correctly -2. **Switch to Wrapper Mode** in production if you have 10+ repositories or token efficiency is important +1. **Keep the default Wrapper Mode** in production if you have 10+ repositories or token efficiency is important +2. **Switch to Direct Mode** during development when you want every tool visible immediately 3. **Use wrapper mode** when working with AI agents that have limited context windows 4. **Monitor token usage** to determine which mode is best for your application 5. **Document your choice** so team members understand which mode is active +## Read-Only Mode + +When you want to give an AI agent access to your data without letting it mutate anything, enable read-only mode: + +```bash +RESTIFY_MCP_READ_ONLY=true +``` + +With read-only mode enabled, every write operation (`store`, `update`, `delete`, and custom actions) is refused: + +- In **direct mode**, only read tools (`index`, `show`, getters, and `profile`) are registered with the host. Write tools are never exposed. +- In **wrapper mode**, write operations are hidden from `discover-repositories`, `get-repository-operations`, and `get-operation-details`, and `execute-operation` rejects them with an authorization-style `isError` response. + +Enforcement happens centrally, so an agent cannot re-enable a hidden operation by calling it directly. + +## Generating an Agent Skill + +Agents increasingly consume APIs through [Agent Skills](https://www.anthropic.com/news/skills) and code execution rather than raw MCP calls. Restify can emit a ready-to-ship skill straight from your MCP-enabled repositories, so the documentation an agent reads never drifts from what your API actually exposes: + +```bash +php artisan restify:skill +``` + +By default the files are written to `./restify-skill/`. Pass `--path` to choose another directory: + +```bash +php artisan restify:skill --path=resources/skills/api +``` + +The command produces two files: + +- **`SKILL.md`** — a Claude Agent Skill with YAML front matter and markdown that teaches the base URL convention (`/api/restify/{repository}`), Sanctum bearer authentication, pagination, sorting, related resources, search, and Restify's filter convention (`filterName=value`, **not** `filter[name]=value`). It then documents one section per MCP-enabled repository: available operations, the field schema, matchables/sortables/searchables, and copy-paste `curl` examples. +- **`openapi.json`** — an OpenAPI 3.1 description with a path per enabled operation (`index`, `show`, `store`, `update`, `delete`, actions, and getters), request bodies derived from your store/update validation rules, and a `bearerAuth` security scheme. + +Only repositories using the `HasMcpTools` trait and the operations they expose are included. [Read-only mode](#read-only-mode) is honored too: when it's enabled, write operations and actions are omitted from both files. + +## File Ingestion Safety + +Repository `File` fields accept an AI-supplied value that Restify turns into an upload. To avoid server-side request forgery (SSRF) and local file disclosure, ingestion is locked down by default: + +- **Local filesystem paths are disabled.** A value like `/etc/hosts` is ignored unless you explicitly opt in with `RESTIFY_MCP_FILES_ALLOW_LOCAL_PATHS=true`. +- **Only `http`/`https` URLs are fetched.** Other schemes (`file://`, `ftp://`, …) are rejected. +- **Private, loopback, link-local, and reserved IPs are rejected**, including hosts that resolve to them, so an agent cannot reach internal services (for example `http://169.254.169.254` or `http://127.0.0.1`). +- **Downloads are capped and time-limited** by `RESTIFY_MCP_FILES_MAX_BYTES` (default 10MB) and `RESTIFY_MCP_FILES_TIMEOUT` (default 10 seconds). Redirects are not followed. + +```bash +# Opt in to local paths (use with care) +RESTIFY_MCP_FILES_ALLOW_LOCAL_PATHS=true + +# Tune the remote fetch guardrails +RESTIFY_MCP_FILES_MAX_BYTES=10485760 +RESTIFY_MCP_FILES_TIMEOUT=10 +``` + ## Fine-Grained Tool Permissions Laravel Restify's MCP integration includes a powerful permission system that allows you to control which tools each API token can access. This is essential for multi-tenant applications or when you need to restrict AI agent capabilities. diff --git a/src/Commands/SkillCommand.php b/src/Commands/SkillCommand.php new file mode 100644 index 000000000..f71335b78 --- /dev/null +++ b/src/Commands/SkillCommand.php @@ -0,0 +1,49 @@ +repositories(); + + if ($repositories->isEmpty()) { + $this->warn('No MCP-enabled repositories found. Add the HasMcpTools trait to a repository first.'); + + return self::SUCCESS; + } + + $path = rtrim($this->option('path') ?: getcwd().'/restify-skill', '/'); + + File::ensureDirectoryExists($path); + + $this->info("Generating skill for {$repositories->count()} repository(ies)..."); + + $repositories->each(function (array $repository): void { + $this->line(" - {$repository['name']}"); + }); + + $skillPath = $path.'/SKILL.md'; + $openApiPath = $path.'/openapi.json'; + + File::put($skillPath, $generator->markdown()); + File::put($openApiPath, json_encode($generator->openApi(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL); + + $this->comment("Wrote {$skillPath}"); + $this->comment("Wrote {$openApiPath}"); + + return self::SUCCESS; + } +} diff --git a/src/LaravelRestifyServiceProvider.php b/src/LaravelRestifyServiceProvider.php index 385e274d9..2b158f1ad 100644 --- a/src/LaravelRestifyServiceProvider.php +++ b/src/LaravelRestifyServiceProvider.php @@ -18,6 +18,7 @@ use Binaryk\LaravelRestify\Commands\RestifyRouteListCommand; use Binaryk\LaravelRestify\Commands\SetupAuthCommand; use Binaryk\LaravelRestify\Commands\SetupCommand; +use Binaryk\LaravelRestify\Commands\SkillCommand; use Binaryk\LaravelRestify\Commands\StoreCommand; use Binaryk\LaravelRestify\Commands\StubCommand; use Binaryk\LaravelRestify\Commands\ToolCommand; @@ -54,6 +55,7 @@ public function configurePackage(Package $package): void ToolCommand::class, McpResourceCommand::class, GraphqlGenerateCommand::class, + SkillCommand::class, ]); } diff --git a/src/MCP/Concerns/McpActionTool.php b/src/MCP/Concerns/McpActionTool.php index b49af9ed7..955f69f07 100644 --- a/src/MCP/Concerns/McpActionTool.php +++ b/src/MCP/Concerns/McpActionTool.php @@ -18,12 +18,11 @@ public function actionTool(Action $action, McpActionRequest $actionRequest): arr if (! $action->authorizedToRun($actionRequest, $actionRequest->findModelOrFail($id, static::uriKey()))) { return [ 'error' => 'Not authorized to run this action', - 'getter' => $action->uriKey(), + 'action' => $action->uriKey(), ]; } } - // Check authorization if (! $action->authorizedToSee($actionRequest)) { return [ 'error' => 'Not authorized to see this action', @@ -31,20 +30,13 @@ public function actionTool(Action $action, McpActionRequest $actionRequest): arr ]; } - try { - $result = $action->handleRequest($actionRequest); + $result = $action->handleRequest($actionRequest); - return [ - 'success' => true, - 'action' => $action->uriKey(), - 'result' => $result->getData(), - ]; - } catch (\Exception $e) { - return [ - 'error' => $e->getMessage(), - 'action' => $action->uriKey(), - ]; - } + return [ + 'success' => true, + 'action' => $action->uriKey(), + 'result' => $result->getData(), + ]; } public static function actionToolSchema(Action $action, JsonSchema $schema, McpActionRequest $mcpRequest): array diff --git a/src/MCP/Concerns/McpGetterTool.php b/src/MCP/Concerns/McpGetterTool.php index e05289502..4213e716b 100644 --- a/src/MCP/Concerns/McpGetterTool.php +++ b/src/MCP/Concerns/McpGetterTool.php @@ -24,25 +24,17 @@ public function getterTool(Getter $getter, McpGetterRequest $getterRequest): arr } } - try { - $result = $getter->handleRequest($getterRequest); + $result = $getter->handleRequest($getterRequest); - // Handle different response types - $responseData = $result instanceof JsonResponse - ? $result->getData() - : $result->getContent(); + $responseData = $result instanceof JsonResponse + ? $result->getData() + : $result->getContent(); - return [ - 'success' => true, - 'getter' => $getter->uriKey(), - 'result' => $responseData, - ]; - } catch (\Exception $e) { - return [ - 'error' => $e->getMessage(), - 'getter' => $getter->uriKey(), - ]; - } + return [ + 'success' => true, + 'getter' => $getter->uriKey(), + 'result' => $responseData, + ]; } public static function getterToolSchema(Getter $getter, JsonSchema $schema, McpGetterRequest $mcpRequest): array diff --git a/src/MCP/Concerns/McpStoreTool.php b/src/MCP/Concerns/McpStoreTool.php index 7ba417914..78dc8f97f 100644 --- a/src/MCP/Concerns/McpStoreTool.php +++ b/src/MCP/Concerns/McpStoreTool.php @@ -16,8 +16,6 @@ trait McpStoreTool { public function storeTool(McpStoreRequest $request): array { - $this->withResource(static::newModel()); - $this->collectFields($request) ->forStore($request, $this) ->areFiles() @@ -26,28 +24,15 @@ public function storeTool(McpStoreRequest $request): array return; } - $filePath = $request->input($file->attribute); - $actualPath = null; - $fileName = null; - - if (file_exists($filePath) && is_readable($filePath)) { - $actualPath = $filePath; - $fileName = basename($filePath); - } elseif (filter_var($filePath, FILTER_VALIDATE_URL)) { - $actualPath = tempnam(sys_get_temp_dir(), 'upload_'); - file_put_contents($actualPath, file_get_contents($filePath)); - $fileName = basename(parse_url($filePath, PHP_URL_PATH)); + $value = $request->input($file->attribute); + + if (! is_string($value) || $value === '') { + return; } - if ($actualPath) { - $uploadedFile = new UploadedFile( - $actualPath, - $fileName, - mime_content_type($actualPath), - null, - true // Mark it as test mode to allow local files - ); + $uploadedFile = $this->resolveUploadedFileFromInput($value); + if ($uploadedFile) { $request->merge([$file->attribute => $uploadedFile]); } }); @@ -58,6 +43,86 @@ public function storeTool(McpStoreRequest $request): array ->getData(true); } + public function resolveUploadedFileFromInput(string $value): ?UploadedFile + { + if (config('restify.mcp.files.allow_local_paths') && is_file($value) && is_readable($value)) { + return new UploadedFile($value, basename($value), mime_content_type($value) ?: null, null, true); + } + + if (! static::isSafePublicUrl($value)) { + return null; + } + + $contents = static::fetchRemoteFile($value); + + if ($contents === null) { + return null; + } + + $tempPath = tempnam(sys_get_temp_dir(), 'restify_mcp_upload_'); + file_put_contents($tempPath, $contents); + + $fileName = basename((string) parse_url($value, PHP_URL_PATH)) ?: 'upload'; + + return new UploadedFile($tempPath, $fileName, mime_content_type($tempPath) ?: null, null, true); + } + + public static function isSafePublicUrl(string $url): bool + { + $parts = parse_url($url); + + if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) { + return false; + } + + if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { + return false; + } + + $host = trim($parts['host'], '[]'); + + $ips = filter_var($host, FILTER_VALIDATE_IP) + ? [$host] + : (gethostbynamel($host) ?: []); + + if ($ips === []) { + return false; + } + + foreach ($ips as $ip) { + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return false; + } + } + + return true; + } + + protected static function fetchRemoteFile(string $url): ?string + { + $maxBytes = (int) config('restify.mcp.files.max_bytes', 10 * 1024 * 1024); + $timeout = (int) config('restify.mcp.files.timeout', 10); + + $context = stream_context_create([ + 'http' => [ + 'timeout' => $timeout, + 'follow_location' => 0, + ], + ]); + + $contents = @file_get_contents($url, false, $context, 0, $maxBytes + 1); + + if ($contents === false) { + return null; + } + + if (strlen($contents) > $maxBytes) { + return null; + } + + return $contents; + } + public static function storeToolSchema(JsonSchema $schema): array { $repository = static::resolveWith(static::newModel()); diff --git a/src/MCP/Concerns/WrapperToolHelpers.php b/src/MCP/Concerns/WrapperToolHelpers.php index 57de4f6d0..a7a1fb329 100644 --- a/src/MCP/Concerns/WrapperToolHelpers.php +++ b/src/MCP/Concerns/WrapperToolHelpers.php @@ -3,6 +3,7 @@ namespace Binaryk\LaravelRestify\MCP\Concerns; use Illuminate\JsonSchema\JsonSchemaTypeFactory; +use Laravel\Mcp\Response; trait WrapperToolHelpers { @@ -215,19 +216,29 @@ protected function generateStringExample(string $fieldName): string } /** - * Build error response. + * Build a real MCP error response (isError=true) with a stable JSON payload. + * + * The shape keys (error/code/errors/detail) are parsed by agents, so keep them stable. */ - protected function buildErrorResponse(string $message, ?string $code = null): array + protected function errorResponse(string $message, ?string $code = null, ?array $errors = null, ?string $detail = null): Response { - $response = [ + $payload = [ 'error' => $message, ]; - if ($code) { - $response['code'] = $code; + if ($code !== null) { + $payload['code'] = $code; } - return $response; + if ($errors !== null) { + $payload['errors'] = $errors; + } + + if ($detail !== null) { + $payload['detail'] = $detail; + } + + return Response::error(json_encode($payload, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); } /** diff --git a/src/MCP/Enums/OperationTypeEnum.php b/src/MCP/Enums/OperationTypeEnum.php index 6bc252149..6c52a7df1 100644 --- a/src/MCP/Enums/OperationTypeEnum.php +++ b/src/MCP/Enums/OperationTypeEnum.php @@ -29,6 +29,31 @@ enum OperationTypeEnum case custom; case wrapper; + /** + * MCP safety-hint annotations for this operation, mirroring the class attributes + * on the per-operation tools so wrapper-mode agents get the same signal. + * + * @return array + */ + public function annotations(): array + { + return match ($this) { + self::index, self::show, self::profile, self::getter => ['readOnlyHint' => true], + self::update => ['idempotentHint' => true], + self::delete => ['destructiveHint' => true, 'idempotentHint' => true], + self::action => ['openWorldHint' => true], + default => [], + }; + } + + public function isWrite(): bool + { + return match ($this) { + self::store, self::update, self::delete, self::action => true, + default => false, + }; + } + public static function fromTool(Tool $tool): self { return match (true) { diff --git a/src/MCP/McpTools.php b/src/MCP/McpTools.php index 646e29b87..a17505722 100644 --- a/src/MCP/McpTools.php +++ b/src/MCP/McpTools.php @@ -23,7 +23,7 @@ * @method static \Illuminate\Support\Collection getAvailableRepositories(?string $search = null) * @method static array getRepositoryOperations(string $repositoryKey) * @method static array getOperationDetails(string $repositoryKey, string $operationType, ?string $operationName = null) - * @method static \Laravel\Mcp\Response executeOperation(string $repositoryKey, string $operationType, ?string $operationName, array $parameters) + * @method static \Laravel\Mcp\Response|\Laravel\Mcp\ResponseFactory executeOperation(string $repositoryKey, string $operationType, ?string $operationName, array $parameters) * * @see McpToolsManager */ diff --git a/src/MCP/McpToolsManager.php b/src/MCP/McpToolsManager.php index cdf50a0a1..425cf3a4d 100644 --- a/src/MCP/McpToolsManager.php +++ b/src/MCP/McpToolsManager.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Cache; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; /** @@ -187,14 +188,28 @@ public function rediscover(): void $this->discover(); } + /** + * Whether the MCP server is running in read-only mode. + */ + public function isReadOnly(): bool + { + return (bool) config('restify.mcp.read_only', false); + } + /** * Get all available repositories with their MCP-enabled operations. */ public function getAvailableRepositories(?string $search = null): ToolsCollection { // Get all repository tools from authorized tools - $repositories = $this->authorized() - ->filter(fn (array $tool): bool => isset($tool['repository'])) + $tools = $this->authorized() + ->filter(fn (array $tool): bool => isset($tool['repository'])); + + if ($this->isReadOnly()) { + $tools = $tools->reject(fn (array $tool): bool => $tool['type']->isWrite()); + } + + $repositories = $tools ->groupBy('repository') ->map(function (Collection $tools, string $repositoryKey): array { $firstTool = $tools->first(); @@ -212,10 +227,14 @@ public function getAvailableRepositories(?string $search = null): ToolsCollectio $gettersCount = $tools->where('type', OperationTypeEnum::getter)->count(); // Get repository metadata from the first tool instance + $firstInstance = $firstTool['instance']; + /** * @var Repository $repository */ - $repository = app($firstTool['instance']->repository ?? Repository::class); + $repository = method_exists($firstInstance, 'repository') + ? $firstInstance->repository() + : app(Repository::class); return [ 'name' => $repositoryKey, @@ -256,6 +275,10 @@ public function getRepositoryOperations(string $repositoryKey): array // Filter by permissions $tools = $tools->filter(fn (array $tool): bool => $this->canUse($tool['instance'])); + if ($this->isReadOnly()) { + $tools = $tools->reject(fn (array $tool): bool => $tool['type']->isWrite()); + } + if ($tools->isEmpty()) { throw new \InvalidArgumentException("Repository '{$repositoryKey}' found, but you don't have permission to access any of its operations"); } @@ -276,6 +299,7 @@ public function getRepositoryOperations(string $repositoryKey): array 'name' => $tool['name'], 'title' => $tool['title'], 'description' => $tool['description'], + 'annotations' => $tool['type']->annotations(), ]) ->values() ->toArray(); @@ -288,6 +312,7 @@ public function getRepositoryOperations(string $repositoryKey): array 'tool_name' => $tool['name'], 'title' => $tool['title'], 'description' => $tool['description'], + 'annotations' => OperationTypeEnum::action->annotations(), ]) ->values() ->toArray(); @@ -300,6 +325,7 @@ public function getRepositoryOperations(string $repositoryKey): array 'tool_name' => $tool['name'], 'title' => $tool['title'], 'description' => $tool['description'], + 'annotations' => OperationTypeEnum::getter->annotations(), ]) ->values() ->toArray(); @@ -353,6 +379,10 @@ public function getOperationDetails(string $repositoryKey, string $operationType throw new AuthorizationException('Not authorized to access this operation'); } + if ($this->isReadOnly() && $tool['type']->isWrite()) { + throw new AuthorizationException('This operation is disabled while the MCP server is in read-only mode'); + } + // Get schema from the tool instance $schema = new JsonSchemaTypeFactory; $toolSchema = $tool['instance']->schema($schema); @@ -362,6 +392,7 @@ public function getOperationDetails(string $repositoryKey, string $operationType 'type' => $tool['type']->name, 'title' => $tool['title'], 'description' => $tool['description'], + 'annotations' => $tool['type']->annotations(), 'schema' => $toolSchema, ]; } @@ -369,7 +400,7 @@ public function getOperationDetails(string $repositoryKey, string $operationType /** * Execute an operation with the provided parameters. */ - public function executeOperation(string $repositoryKey, string $operationType, ?string $operationName, array $parameters): Response + public function executeOperation(string $repositoryKey, string $operationType, ?string $operationName, array $parameters): Response|ResponseFactory { // Convert string operation type to enum $operationTypeEnum = OperationTypeEnum::{$operationType}; @@ -405,73 +436,23 @@ public function executeOperation(string $repositoryKey, string $operationType, ? throw new AuthorizationException('Not authorized to execute this operation'); } - // Execute based on operation type - return match ($operationTypeEnum) { - OperationTypeEnum::index => $this->executeIndexOperation($tool, $parameters), - OperationTypeEnum::show => $this->executeShowOperation($tool, $parameters), - OperationTypeEnum::store => $this->executeStoreOperation($tool, $parameters), - OperationTypeEnum::update => $this->executeUpdateOperation($tool, $parameters), - OperationTypeEnum::delete => $this->executeDeleteOperation($tool, $parameters), - OperationTypeEnum::profile => $this->executeProfileOperation($tool, $parameters), - OperationTypeEnum::action => $this->executeActionOperation($tool, $parameters), - OperationTypeEnum::getter => $this->executeGetterOperation($tool, $parameters), - default => throw new \InvalidArgumentException("Invalid operation type: {$operationType}"), - }; - } - - protected function executeIndexOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeShowOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeStoreOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeUpdateOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeDeleteOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeProfileOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } - - protected function executeActionOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); - - return $tool['instance']->handle($request); - } + if ($this->isReadOnly() && $operationTypeEnum->isWrite()) { + throw new AuthorizationException('This operation is disabled while the MCP server is in read-only mode'); + } - protected function executeGetterOperation(array $tool, array $parameters): Response - { - $request = new Request($parameters); + if (! in_array($operationTypeEnum, [ + OperationTypeEnum::index, + OperationTypeEnum::show, + OperationTypeEnum::store, + OperationTypeEnum::update, + OperationTypeEnum::delete, + OperationTypeEnum::profile, + OperationTypeEnum::action, + OperationTypeEnum::getter, + ], true)) { + throw new \InvalidArgumentException("Invalid operation type: {$operationType}"); + } - return $tool['instance']->handle($request); + return $tool['instance']->handle(new Request($parameters)); } } diff --git a/src/MCP/RestifyServer.php b/src/MCP/RestifyServer.php index 3b379cb22..acbbda280 100644 --- a/src/MCP/RestifyServer.php +++ b/src/MCP/RestifyServer.php @@ -171,14 +171,14 @@ protected function discoverTools(): ToolsCollection protected function registerRepositoryTools(): void { - $mode = request()->get('mode', config('restify.mcp.mode')); - - if ($mode === 'wrapper') { + if (config('restify.mcp.mode') === 'wrapper') { $this->registerWrapperTools(); return; } + $readOnly = (bool) config('restify.mcp.read_only', false); + McpTools::all() ->whereIn('category', [ ToolsCategoryEnum::CRUD_OPERATIONS->value, @@ -187,6 +187,7 @@ protected function registerRepositoryTools(): void ToolsCategoryEnum::PROFILE->value, ]) ->filter(fn (array $tool): bool => $this->canUseTool($tool['instance'])) + ->reject(fn (array $tool): bool => $readOnly && $tool['type']->isWrite()) ->each(fn (array $tool) => $this->tools[] = $tool['instance']); } diff --git a/src/MCP/Skill/RestifySkillGenerator.php b/src/MCP/Skill/RestifySkillGenerator.php new file mode 100644 index 000000000..99fb0406e --- /dev/null +++ b/src/MCP/Skill/RestifySkillGenerator.php @@ -0,0 +1,459 @@ +base = '/'.trim((string) config('restify.base', '/api/restify'), '/'); + } + + /** + * @return Collection + */ + public function repositories(): Collection + { + return McpTools::getAvailableRepositories(); + } + + public function markdown(): string + { + $sections = $this->repositories() + ->map(fn (array $repository): string => $this->renderRepositorySection($repository['name'])) + ->filter() + ->implode("\n\n---\n\n"); + + return $this->frontMatter() + ."\n\n".$this->conventions() + ."\n\n## Repositories\n\n".$sections."\n"; + } + + public function openApi(): array + { + $paths = []; + + foreach ($this->repositories() as $repository) { + $paths = array_merge($paths, $this->repositoryPaths($repository['name'])); + } + + return [ + 'openapi' => '3.1.0', + 'info' => [ + 'title' => config('app.name', 'Laravel').' Restify API', + 'version' => '1.0.0', + 'description' => 'Auto-generated from the MCP-enabled Restify repositories.', + ], + 'servers' => [ + ['url' => $this->base], + ], + 'security' => [ + ['bearerAuth' => []], + ], + 'components' => [ + 'securitySchemes' => [ + 'bearerAuth' => [ + 'type' => 'http', + 'scheme' => 'bearer', + 'description' => 'Laravel Sanctum personal access token.', + ], + ], + ], + 'paths' => $paths, + ]; + } + + private function frontMatter(): string + { + $name = Str::slug(config('app.name', 'Laravel').'-restify-api'); + + return implode("\n", [ + '---', + "name: {$name}", + 'description: Query and mutate this application\'s data through the Laravel Restify HTTP API. Use for listing, filtering, sorting, showing, creating, updating and deleting the exposed resources.', + '---', + ]); + } + + private function conventions(): string + { + return <<base}/{repository}` where `{repository}` is one of the resources documented below. + + ## Authentication + + Requests are authenticated with a Laravel Sanctum bearer token. Send it in the `Authorization` header: + + ```bash + curl -H "Authorization: Bearer \$RESTIFY_TOKEN" \\ + -H "Accept: application/json" \\ + {$this->base}/{repository} + ``` + + Store the token in an environment variable (e.g. `RESTIFY_TOKEN`) rather than hardcoding it. + + ## Pagination + + Index endpoints are paginated. Control them with query params: + + - `page` — the page number (default `1`). + - `perPage` — items per page. + + ```bash + {$this->base}/{repository}?page=2&perPage=25 + ``` + + ## Sorting + + Use the `sort` param with a comma-separated list of columns. Prefix a column with `-` for descending order: + + ```bash + {$this->base}/{repository}?sort=-created_at,title + ``` + + ## Related resources + + Eager load relationships with the `related` param (comma-separated): + + ```bash + {$this->base}/{repository}?related=user,comments + ``` + + ## Search + + Full-text search across the searchable columns with the `search` param: + + ```bash + {$this->base}/{repository}?search=laravel + ``` + + ## Filtering (IMPORTANT) + + Restify applies filters as **direct query params**, using the filter name as the key: + + ```bash + {$this->base}/{repository}?status=active&priority=high + ``` + + Do **NOT** use the bracketed `filter[name]=value` convention — Restify does not read filters that way. Always pass `filterName=value` directly. + MD; + } + + private function renderRepositorySection(string $key): string + { + $repository = McpTools::getRepositoryOperations($key); + + $heading = "### `{$key}`"; + + $description = trim((string) ($repository['description'] ?? '')); + $intro = $description === '' ? '' : $description."\n"; + + $operations = $this->renderOperations($repository); + $schema = $this->renderFieldSchema($key, $repository); + $query = $this->renderQueryCapabilities($key); + $examples = $this->renderExamples($key, $repository); + + return collect([$heading, $intro, $operations, $schema, $query, $examples]) + ->map(fn (string $part): string => trim($part)) + ->filter(fn (string $part): bool => $part !== '') + ->implode("\n\n"); + } + + private function renderOperations(array $repository): string + { + $operations = collect($repository['operations']) + ->pluck('type') + ->map(fn (string $type): string => "`{$type}`"); + + $actions = collect($repository['actions']) + ->map(fn (array $action): string => "`action:{$action['name']}`"); + + $getters = collect($repository['getters']) + ->map(fn (array $getter): string => "`getter:{$getter['name']}`"); + + $available = $operations->concat($actions)->concat($getters)->implode(', '); + + return "**Operations:** {$available}"; + } + + private function renderFieldSchema(string $key, array $repository): string + { + $schema = $this->writeSchema($key, $repository); + + if ($schema === null) { + return ''; + } + + $properties = $schema['properties'] ?? []; + + if ($properties === []) { + return ''; + } + + $required = $schema['required'] ?? []; + + $lines = collect($properties) + ->map(function (array $property, string $name) use ($required): string { + $type = $property['type'] ?? 'string'; + $type = is_array($type) ? implode('|', $type) : $type; + $flag = in_array($name, $required, true) ? 'required' : 'optional'; + + return "- `{$name}` — {$type}, {$flag}"; + }) + ->implode("\n"); + + return "**Fields:**\n\n".$lines; + } + + private function renderQueryCapabilities(string $key): string + { + $class = Restify::repositoryClassForKey($key); + + if ($class === null) { + return ''; + } + + $matches = array_keys($class::matches()); + $sortables = $this->queryParamIdentifiers($class::sorts()); + $searchables = $this->queryParamIdentifiers($class::searchables()); + $related = $this->queryParamIdentifiers($class::related()); + + $lines = collect([ + 'Matchables (filters)' => $matches, + 'Sortables' => $sortables, + 'Searchables' => $searchables, + 'Related' => $related, + ]) + ->filter(fn (array $values): bool => $values !== []) + ->map(function (array $values, string $label): string { + $formatted = collect($values) + ->map(fn ($value, $key): string => '`'.($this->queryParamIdentifier($value, $key)).'`') + ->implode(', '); + + return "- **{$label}:** {$formatted}"; + }) + ->implode("\n"); + + return $lines === '' ? '' : $lines; + } + + private function queryParamIdentifiers(array $values): array + { + return collect($values) + ->map(fn ($value, $key): string => $this->queryParamIdentifier($value, $key)) + ->values() + ->all(); + } + + private function queryParamIdentifier(mixed $value, int|string $key): string + { + return is_string($value) ? $value : (string) $key; + } + + private function renderExamples(string $key, array $repository): string + { + $examples = collect(); + + $matches = array_keys(($class = Restify::repositoryClassForKey($key)) ? $class::matches() : []); + $indexQuery = $matches === [] ? '?perPage=10' : '?'.$matches[0].'=value'; + + $examples->push(implode("\n", [ + '# List (with a filter)', + 'curl -H "Authorization: Bearer $RESTIFY_TOKEN" \\', + ' -H "Accept: application/json" \\', + " \"{$this->base}/{$key}{$indexQuery}\"", + ])); + + if ($this->hasOperation($repository, 'show')) { + $examples->push(implode("\n", [ + '# Show a single record', + 'curl -H "Authorization: Bearer $RESTIFY_TOKEN" \\', + ' -H "Accept: application/json" \\', + " \"{$this->base}/{$key}/1\"", + ])); + } + + if ($this->hasOperation($repository, 'store')) { + $body = json_encode($this->sampleBody($key, $repository), JSON_UNESCAPED_SLASHES); + + $examples->push(implode("\n", [ + '# Create a record', + "curl -X POST \"{$this->base}/{$key}\" \\", + ' -H "Authorization: Bearer $RESTIFY_TOKEN" \\', + ' -H "Content-Type: application/json" \\', + ' -H "Accept: application/json" \\', + " -d '{$body}'", + ])); + } + + return "**Examples:**\n\n```bash\n".$examples->implode("\n\n")."\n```"; + } + + /** + * @return array + */ + private function repositoryPaths(string $key): array + { + $repository = McpTools::getRepositoryOperations($key); + + $collection = "{$this->base}/{$key}"; + $single = "{$this->base}/{$key}/{repositoryId}"; + + $paths = []; + + if ($this->hasOperation($repository, 'index')) { + $paths[$collection]['get'] = $this->operationSpec($key, 'List '.$key, [ + $this->queryParam('page', 'integer'), + $this->queryParam('perPage', 'integer'), + $this->queryParam('sort', 'string'), + $this->queryParam('search', 'string'), + $this->queryParam('related', 'string'), + ]); + } + + if ($this->hasOperation($repository, 'store')) { + $paths[$collection]['post'] = $this->operationSpec($key, 'Create '.$key, [], $this->writeSchema($key, $repository)); + } + + if ($this->hasOperation($repository, 'show')) { + $paths[$single]['get'] = $this->operationSpec($key, 'Show '.$key, [$this->pathIdParam()]); + } + + if ($this->hasOperation($repository, 'update')) { + $spec = $this->operationSpec($key, 'Update '.$key, [$this->pathIdParam()], $this->writeSchema($key, $repository)); + $paths[$single]['put'] = $spec; + $paths[$single]['patch'] = $spec; + } + + if ($this->hasOperation($repository, 'delete')) { + $paths[$single]['delete'] = $this->operationSpec($key, 'Delete '.$key, [$this->pathIdParam()]); + } + + if ($repository['actions'] !== []) { + $actionKeys = collect($repository['actions'])->pluck('name')->implode(', '); + $paths["{$this->base}/{$key}/actions"]['post'] = $this->operationSpec($key, "Run an action ({$actionKeys})", [ + $this->queryParam('action', 'string', true), + ]); + } + + foreach ($repository['getters'] as $getter) { + $paths["{$this->base}/{$key}/getters/{$getter['name']}"]['get'] = $this->operationSpec( + $key, + (string) ($getter['description'] ?: "Getter {$getter['name']}"), + ); + } + + return $paths; + } + + private function operationSpec(string $tag, string $summary, array $parameters = [], ?array $requestSchema = null): array + { + $spec = [ + 'tags' => [$tag], + 'summary' => $summary, + 'responses' => [ + '200' => ['description' => 'Successful response'], + ], + ]; + + if ($parameters !== []) { + $spec['parameters'] = $parameters; + } + + if ($requestSchema !== null) { + $spec['requestBody'] = [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => $requestSchema, + ], + ], + ]; + } + + return $spec; + } + + private function queryParam(string $name, string $type, bool $required = false): array + { + return [ + 'name' => $name, + 'in' => 'query', + 'required' => $required, + 'schema' => ['type' => $type], + ]; + } + + private function pathIdParam(): array + { + return [ + 'name' => 'repositoryId', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ]; + } + + private function writeSchema(string $key, array $repository): ?array + { + $type = match (true) { + $this->hasOperation($repository, 'store') => 'store', + $this->hasOperation($repository, 'update') => 'update', + default => null, + }; + + if ($type === null) { + return null; + } + + $details = McpTools::getOperationDetails($key, $type); + + return (new JsonSchemaTypeFactory)->object($details['schema'])->toArray(); + } + + private function sampleBody(string $key, array $repository): array + { + $schema = $this->writeSchema($key, $repository); + + $properties = $schema['properties'] ?? []; + + return collect($properties) + ->map(function (array $property): mixed { + $type = $property['type'] ?? 'string'; + $type = is_array($type) ? ($type[0] ?? 'string') : $type; + + return match ($type) { + 'integer', 'number' => 1, + 'boolean' => true, + 'array' => [], + 'object' => new \stdClass, + default => 'value', + }; + }) + ->toArray(); + } + + private function hasOperation(array $repository, string $type): bool + { + return collect($repository['operations'])->contains(fn (array $operation): bool => $operation['type'] === $type); + } +} diff --git a/src/MCP/Tools/GlobalSearchTool.php b/src/MCP/Tools/GlobalSearchTool.php index 7a8485064..6aa04837a 100644 --- a/src/MCP/Tools/GlobalSearchTool.php +++ b/src/MCP/Tools/GlobalSearchTool.php @@ -10,7 +10,9 @@ use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GlobalSearchTool extends Tool { public function name(): string diff --git a/src/MCP/Tools/Operations/ActionTool.php b/src/MCP/Tools/Operations/ActionTool.php index 3044c13dd..a26c98153 100644 --- a/src/MCP/Tools/Operations/ActionTool.php +++ b/src/MCP/Tools/Operations/ActionTool.php @@ -10,21 +10,21 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsOpenWorld; +#[IsOpenWorld] class ActionTool extends Tool { + public function __construct(protected string $repositoryClass, protected Action $action) {} + /** - * @var Repository|HasMcpTools + * @return Repository|HasMcpTools */ - protected Repository $repository; - - protected Action $action; - - public function __construct(string $repositoryClass, Action $action) + public function repository(): Repository { - $this->repository = app($repositoryClass); - $this->action = $action; + return app($this->repositoryClass); } public function title(): string @@ -34,7 +34,7 @@ public function title(): string public function name(): string { - $repositoryUriKey = $this->repository->uriKey(); + $repositoryUriKey = $this->repositoryClass::uriKey(); $actionUriKey = $this->action->uriKey(); return "{$repositoryUriKey}-{$actionUriKey}-action-tool"; @@ -46,10 +46,11 @@ public function description(): string return $description; } - $repositoryUriKey = $this->repository->uriKey(); + $repository = $this->repository(); + $repositoryUriKey = $this->repositoryClass::uriKey(); $actionName = $this->action->name(); - $modelName = class_basename($this->repository::guessModelClassName()); + $modelName = class_basename($this->repositoryClass::guessModelClassName()); if ($this->action->isStandalone()) { return "Execute {$actionName} action (standalone - no models required) in the {$repositoryUriKey} repository."; @@ -58,8 +59,8 @@ public function description(): string // Check if it's primarily a show action or index action $mcpRequest = app(McpActionRequest::class); - $shownOnShow = $this->action->isShownOnShow($mcpRequest, $this->repository); - $shownOnIndex = $this->action->isShownOnIndex($mcpRequest, $this->repository); + $shownOnShow = $this->action->isShownOnShow($mcpRequest, $repository); + $shownOnIndex = $this->action->isShownOnIndex($mcpRequest, $repository); if ($shownOnShow && ! $shownOnIndex) { return "Execute {$actionName} action on a specific {$modelName} record in the {$repositoryUriKey} repository."; @@ -72,10 +73,11 @@ public function schema(JsonSchema $schema): array { $validationSchema = []; - $modelName = class_basename($this->repository::guessModelClassName()); + $repository = $this->repository(); + $modelName = class_basename($this->repositoryClass::guessModelClassName()); if (! $this->action->isStandalone()) { - if ($this->action->isShownOnIndex(app(RestifyRequest::class), $this->repository)) { + if ($this->action->isShownOnIndex(app(RestifyRequest::class), $repository)) { $validationSchema['resources'] = $schema->array() ->items( $schema->string() @@ -84,7 +86,7 @@ public function schema(JsonSchema $schema): array ->title('resources') ->description("The ids of the resources {$modelName} to perform the action on. Use string 'all' to select all resources.") ->required(); - } elseif ($this->action->isShownOnShow(app(RestifyRequest::class), $this->repository)) { + } elseif ($this->action->isShownOnShow(app(RestifyRequest::class), $repository)) { $validationSchema['id'] = $schema->string() ->title('id') ->description('The ID of the resource to perform the action on.') @@ -97,12 +99,12 @@ public function schema(JsonSchema $schema): array return array_merge($rulesSchema, $validationSchema); } - public function handle(Request $request): Response + public function handle(Request $request): Response|ResponseFactory { $mcpRequest = app(McpActionRequest::class); $mcpRequest->replace($request->all()); $mcpRequest->merge([ - 'mcp_repository_key' => $this->repository->uriKey(), + 'mcp_repository_key' => $this->repositoryClass::uriKey(), ]); // Parse repositories string to array if provided @@ -126,8 +128,8 @@ public function parameter($key, $default = null) }); } - $result = $this->repository->actionTool($this->action, $mcpRequest); + $result = $this->repository()->actionTool($this->action, $mcpRequest); - return Response::json($result); + return Response::structured($result); } } diff --git a/src/MCP/Tools/Operations/DeleteTool.php b/src/MCP/Tools/Operations/DeleteTool.php index 010356fdd..d9b8ef169 100644 --- a/src/MCP/Tools/Operations/DeleteTool.php +++ b/src/MCP/Tools/Operations/DeleteTool.php @@ -8,43 +8,48 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; +use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent; +#[IsDestructive] +#[IsIdempotent] class DeleteTool extends Tool { + public function __construct(protected string $repositoryClass) {} + /** - * @var Repository|HasMcpTools + * @return Repository|HasMcpTools */ - protected Repository $repository; - - public function __construct(string $repositoryClass) + public function repository(): Repository { - $this->repository = app($repositoryClass); + return app($this->repositoryClass); } public function title(): string { - return $this->repository::label().' Delete'; + return $this->repositoryClass::label().' Delete'; } public function name(): string { - $uriKey = $this->repository->uriKey(); + $uriKey = $this->repositoryClass::uriKey(); return "{$uriKey}-delete-tool"; } public function description(): string { - $uriKey = $this->repository->uriKey(); - $modelName = class_basename($this->repository::guessModelClassName()); + $uriKey = $this->repositoryClass::uriKey(); + $modelName = class_basename($this->repositoryClass::guessModelClassName()); return "Delete an existing {$modelName} record by ID from the {$uriKey} repository."; } public function schema(JsonSchema $schema): array { - $repositoryClass = get_class($this->repository); + $repositoryClass = $this->repositoryClass; $modelName = class_basename($repositoryClass::guessModelClassName()); return [ @@ -52,13 +57,13 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function handle(Request $request): Response|ResponseFactory { $mcpRequest = app(McpDestroyRequest::class); $mcpRequest->merge($request->all()); - $result = $this->repository->deleteTool($mcpRequest); + $result = $this->repository()->deleteTool($mcpRequest); - return Response::json($result); + return Response::structured($result); } } diff --git a/src/MCP/Tools/Operations/GetterTool.php b/src/MCP/Tools/Operations/GetterTool.php index d45ba6a50..833d9c20e 100644 --- a/src/MCP/Tools/Operations/GetterTool.php +++ b/src/MCP/Tools/Operations/GetterTool.php @@ -7,24 +7,20 @@ use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Contracts\JsonSchema\JsonSchema; -use Illuminate\Foundation\Application; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetterTool extends Tool { - /** - * @var Repository|Application|mixed|object|string - */ - protected Repository $repository; + public function __construct(protected string $repositoryClass, protected Getter $getter) {} - protected Getter $getter; - - public function __construct(string $repositoryClass, Getter $getter) + public function repository(): Repository { - $this->repository = app($repositoryClass); - $this->getter = $getter; + return app($this->repositoryClass); } public function title(): string @@ -34,7 +30,7 @@ public function title(): string public function name(): string { - $repositoryUriKey = $this->repository->uriKey(); + $repositoryUriKey = $this->repositoryClass::uriKey(); $getterUriKey = $this->getter->uriKey(); return "{$repositoryUriKey}-{$getterUriKey}-getter-tool"; @@ -46,15 +42,16 @@ public function description(): string return $description; } - $repositoryUriKey = $this->repository->uriKey(); + $repository = $this->repository(); + $repositoryUriKey = $this->repositoryClass::uriKey(); $getterName = $this->getter->name(); - $modelName = class_basename($this->repository::guessModelClassName()); + $modelName = class_basename($this->repositoryClass::guessModelClassName()); // Check if it's primarily a show getter or index getter $mcpRequest = app(McpGetterRequest::class); - $shownOnShow = $this->getter->isShownOnShow($mcpRequest, $this->repository); - $shownOnIndex = $this->getter->isShownOnIndex($mcpRequest, $this->repository); + $shownOnShow = $this->getter->isShownOnShow($mcpRequest, $repository); + $shownOnIndex = $this->getter->isShownOnIndex($mcpRequest, $repository); if ($shownOnShow && ! $shownOnIndex) { return "Execute {$getterName} getter to retrieve data for a specific {$modelName} record in the {$repositoryUriKey} repository."; @@ -67,10 +64,11 @@ public function schema(JsonSchema $schema): array { $validationSchema = []; - $modelName = class_basename($this->repository::guessModelClassName()); + $repository = $this->repository(); + $modelName = class_basename($this->repositoryClass::guessModelClassName()); - if (! $this->getter->isShownOnIndex(app(RestifyRequest::class), $this->repository)) { - if ($this->getter->isShownOnShow(app(RestifyRequest::class), $this->repository)) { + if (! $this->getter->isShownOnIndex(app(RestifyRequest::class), $repository)) { + if ($this->getter->isShownOnShow(app(RestifyRequest::class), $repository)) { $validationSchema['id'] = $schema->string() ->title('id') ->required() @@ -83,12 +81,12 @@ public function schema(JsonSchema $schema): array return array_merge($rulesSchema, $validationSchema); } - public function handle(Request $request): Response + public function handle(Request $request): Response|ResponseFactory { $mcpRequest = app(McpGetterRequest::class); $mcpRequest->replace($request->all()); $mcpRequest->merge([ - 'mcp_repository_key' => $this->repository->uriKey(), + 'mcp_repository_key' => $this->repositoryClass::uriKey(), ]); // Parse repositories string to array if provided @@ -112,8 +110,8 @@ public function parameter($key, $default = null) }); } - $result = $this->repository->getterTool($this->getter, $mcpRequest); + $result = $this->repository()->getterTool($this->getter, $mcpRequest); - return Response::json($result); + return Response::structured($result); } } diff --git a/src/MCP/Tools/Operations/IndexTool.php b/src/MCP/Tools/Operations/IndexTool.php index 3391df9c4..6cc7b1d9e 100644 --- a/src/MCP/Tools/Operations/IndexTool.php +++ b/src/MCP/Tools/Operations/IndexTool.php @@ -8,40 +8,43 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class IndexTool extends Tool { + public function __construct(protected string $repositoryClass) {} + /** - * @var Repository|HasMcpTools + * @return Repository|HasMcpTools */ - protected Repository $repository; - - public function __construct(string $repositoryClass) + public function repository(): Repository { - $this->repository = app($repositoryClass); + return app($this->repositoryClass); } public function title(): string { - return $this->repository::label().' Index'; + return $this->repositoryClass::label().' Index'; } public function name(): string { - $uriKey = $this->repository->uriKey(); + $uriKey = $this->repositoryClass::uriKey(); return "{$uriKey}-index-tool"; } public function description(): string { - return $this->repository::description(app(McpIndexRequest::class)); + return $this->repositoryClass::description(app(McpIndexRequest::class)); } public function schema(JsonSchema $schema): array { - $repositoryClass = get_class($this->repository); + $repositoryClass = $this->repositoryClass; // Use repository's schema method if it has MCP tools if (method_exists($repositoryClass, 'indexToolSchema')) { @@ -56,13 +59,13 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function handle(Request $request): Response|ResponseFactory { $mcpRequest = app(McpIndexRequest::class); $mcpRequest->replace($request->all()); - $result = $this->repository->indexTool($mcpRequest); + $result = $this->repository()->indexTool($mcpRequest); - return Response::json($result); + return Response::structured($result); } } diff --git a/src/MCP/Tools/Operations/ProfileTool.php b/src/MCP/Tools/Operations/ProfileTool.php index 3ccf4b574..1fe16f667 100644 --- a/src/MCP/Tools/Operations/ProfileTool.php +++ b/src/MCP/Tools/Operations/ProfileTool.php @@ -7,15 +7,18 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ProfileTool extends Tool { - protected Repository $repository; + public function __construct(protected string $repositoryClass) {} - public function __construct(string $repositoryClass) + public function repository(): Repository { - $this->repository = app($repositoryClass); + return app($this->repositoryClass); } public function title(): string @@ -25,21 +28,21 @@ public function title(): string public function name(): string { - $uriKey = $this->repository->uriKey(); + $uriKey = $this->repositoryClass::uriKey(); return "{$uriKey}-profile-tool"; } public function description(): string { - $modelName = class_basename($this->repository::guessModelClassName()); + $modelName = class_basename($this->repositoryClass::guessModelClassName()); return "Get the current authenticated user profile including {$modelName} and relationship information."; } public function schema(JsonSchema $schema): array { - $relatedOptions = $this->repository::collectRelated() + $relatedOptions = $this->repositoryClass::collectRelated() ->intoAssoc() ->keys() ->toArray(); @@ -50,14 +53,15 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function handle(Request $request): Response|ResponseFactory { $user = auth()->user(); if (! $user) { - return Response::json([ + return Response::error(json_encode([ 'error' => 'No authenticated user found', - ]); + 'code' => 'UNAUTHENTICATED', + ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); } $mcpRequest = app(McpIndexRequest::class); @@ -65,10 +69,11 @@ public function handle(Request $request): Response $requestData['id'] = $user->getKey(); $mcpRequest->replace($requestData); - $this->repository->request = $mcpRequest; + $repository = $this->repository(); + $repository->request = $mcpRequest; - $result = $this->repository->indexTool($mcpRequest); + $result = $repository->indexTool($mcpRequest); - return Response::json($result); + return Response::structured($result); } } diff --git a/src/MCP/Tools/Operations/ShowTool.php b/src/MCP/Tools/Operations/ShowTool.php index b6f232b27..09b58a3b8 100644 --- a/src/MCP/Tools/Operations/ShowTool.php +++ b/src/MCP/Tools/Operations/ShowTool.php @@ -8,43 +8,46 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ShowTool extends Tool { + public function __construct(protected string $repositoryClass) {} + /** - * @var Repository|HasMcpTools + * @return Repository|HasMcpTools */ - protected Repository $repository; - - public function __construct(string $repositoryClass) + public function repository(): Repository { - $this->repository = app($repositoryClass); + return app($this->repositoryClass); } public function title(): string { - return $this->repository::label().' Show'; + return $this->repositoryClass::label().' Show'; } public function name(): string { - $uriKey = $this->repository->uriKey(); + $uriKey = $this->repositoryClass::uriKey(); return "{$uriKey}-show-tool"; } public function description(): string { - $uriKey = $this->repository->uriKey(); - $modelName = class_basename($this->repository::guessModelClassName()); + $uriKey = $this->repositoryClass::uriKey(); + $modelName = class_basename($this->repositoryClass::guessModelClassName()); return "Retrieve a single {$modelName} record by ID from the {$uriKey} repository with optional relationship loading."; } public function schema(JsonSchema $schema): array { - $repositoryClass = get_class($this->repository); + $repositoryClass = $this->repositoryClass; // Use repository's schema method if it has MCP tools if (method_exists($repositoryClass, 'showToolSchema')) { @@ -60,13 +63,13 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function handle(Request $request): Response|ResponseFactory { $mcpRequest = app(McpShowRequest::class); $mcpRequest->replace($request->all()); - $result = $this->repository->showTool($mcpRequest); + $result = $this->repository()->showTool($mcpRequest); - return Response::json($result); + return Response::structured($result); } } diff --git a/src/MCP/Tools/Operations/StoreTool.php b/src/MCP/Tools/Operations/StoreTool.php index c4511d007..34181099f 100644 --- a/src/MCP/Tools/Operations/StoreTool.php +++ b/src/MCP/Tools/Operations/StoreTool.php @@ -8,40 +8,41 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; class StoreTool extends Tool { + public function __construct(protected string $repositoryClass) {} + /** - * @var Repository|HasMcpTools + * @return Repository|HasMcpTools */ - protected Repository $repository; - - public function __construct(string $repositoryClass) + public function repository(): Repository { - $this->repository = app($repositoryClass); + return app($this->repositoryClass); } public function title(): string { - return $this->repository::label().' Create'; + return $this->repositoryClass::label().' Create'; } public function name(): string { - $uriKey = $this->repository->uriKey(); + $uriKey = $this->repositoryClass::uriKey(); return "{$uriKey}-store-tool"; } public function description(): string { - return $this->repository::description(app(McpStoreRequest::class)); + return $this->repositoryClass::description(app(McpStoreRequest::class)); } public function schema(JsonSchema $schema): array { - $repositoryClass = get_class($this->repository); + $repositoryClass = $this->repositoryClass; // Use repository's schema method if it has MCP tools if (method_exists($repositoryClass, 'storeToolSchema')) { @@ -56,13 +57,13 @@ public function schema(JsonSchema $schema): array return $fields; } - public function handle(Request $request): Response + public function handle(Request $request): Response|ResponseFactory { $mcpRequest = app(McpStoreRequest::class); $mcpRequest->replace($request->all()); - $result = $this->repository->storeTool($mcpRequest); + $result = $this->repository()->storeTool($mcpRequest); - return Response::json($result); + return Response::structured($result); } } diff --git a/src/MCP/Tools/Operations/UpdateTool.php b/src/MCP/Tools/Operations/UpdateTool.php index a19db740a..f700f57c7 100644 --- a/src/MCP/Tools/Operations/UpdateTool.php +++ b/src/MCP/Tools/Operations/UpdateTool.php @@ -8,40 +8,43 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent; +#[IsIdempotent] class UpdateTool extends Tool { + public function __construct(protected string $repositoryClass) {} + /** - * @var Repository|HasMcpTools + * @return Repository|HasMcpTools */ - protected Repository $repository; - - public function __construct(string $repositoryClass) + public function repository(): Repository { - $this->repository = app($repositoryClass); + return app($this->repositoryClass); } public function title(): string { - return $this->repository::label().' Update'; + return $this->repositoryClass::label().' Update'; } public function name(): string { - $uriKey = $this->repository->uriKey(); + $uriKey = $this->repositoryClass::uriKey(); return "{$uriKey}-update-tool"; } public function description(): string { - return $this->repository::description(app(McpUpdateRequest::class)); + return $this->repositoryClass::description(app(McpUpdateRequest::class)); } public function schema(JsonSchema $schema): array { - $repositoryClass = get_class($this->repository); + $repositoryClass = $this->repositoryClass; // Use repository's schema method if it has MCP tools if (method_exists($repositoryClass, 'updateToolSchema')) { @@ -59,13 +62,13 @@ public function schema(JsonSchema $schema): array return $fields; } - public function handle(Request $request): Response + public function handle(Request $request): Response|ResponseFactory { $mcpRequest = app(McpUpdateRequest::class); $mcpRequest->replace($request->all()); - $result = $this->repository->updateTool($mcpRequest); + $result = $this->repository()->updateTool($mcpRequest); - return Response::json($result); + return Response::structured($result); } } diff --git a/src/MCP/Tools/Wrapper/DiscoverRepositoriesTool.php b/src/MCP/Tools/Wrapper/DiscoverRepositoriesTool.php index 14a0bc0ed..32d5ec9ed 100644 --- a/src/MCP/Tools/Wrapper/DiscoverRepositoriesTool.php +++ b/src/MCP/Tools/Wrapper/DiscoverRepositoriesTool.php @@ -7,8 +7,11 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class DiscoverRepositoriesTool extends Tool { use WrapperToolHelpers; @@ -31,14 +34,28 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean() + ->description('Whether the discovery succeeded.'), + 'total' => $schema->integer() + ->description('Number of repositories returned.'), + 'repositories' => $schema->array() + ->description('The MCP-enabled repositories with their operation metadata.'), + 'next_steps' => $schema->array() + ->description('Suggested follow-up tool calls.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory { try { $search = $request->get('search'); $repositories = McpTools::getAvailableRepositories($search); - return Response::json([ + return Response::structured([ 'success' => true, 'total' => $repositories->count(), 'repositories' => $repositories->toArray(), @@ -48,7 +65,11 @@ public function handle(Request $request): Response ], ]); } catch (\Exception $e) { - return Response::json($this->buildErrorResponse($e->getMessage(), 'DISCOVERY_ERROR')); + return $this->errorResponse( + 'An error occurred while discovering repositories', + 'DISCOVERY_ERROR', + detail: config('app.debug') ? $e->getMessage() : null, + ); } } } diff --git a/src/MCP/Tools/Wrapper/ExecuteOperationTool.php b/src/MCP/Tools/Wrapper/ExecuteOperationTool.php index 4899d84db..d84c150ae 100644 --- a/src/MCP/Tools/Wrapper/ExecuteOperationTool.php +++ b/src/MCP/Tools/Wrapper/ExecuteOperationTool.php @@ -10,8 +10,11 @@ use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsOpenWorld; +#[IsOpenWorld] class ExecuteOperationTool extends Tool { use WrapperToolHelpers; @@ -45,7 +48,7 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function handle(Request $request): Response|ResponseFactory { try { $repositoryKey = $request->get('repository'); @@ -54,61 +57,51 @@ public function handle(Request $request): Response $parameters = $request->get('parameters', []); if (! $repositoryKey) { - return Response::json($this->buildErrorResponse( + return $this->errorResponse( 'Repository parameter is required', 'MISSING_PARAMETER' - )); + ); } if (! $operationType) { - return Response::json($this->buildErrorResponse( + return $this->errorResponse( 'Operation type parameter is required', 'MISSING_PARAMETER' - )); + ); } if (in_array($operationType, ['action', 'getter']) && ! $operationName) { - return Response::json($this->buildErrorResponse( + return $this->errorResponse( "Operation name is required for {$operationType} operation type", 'MISSING_PARAMETER' - )); + ); } if (! is_array($parameters)) { - return Response::json($this->buildErrorResponse( + return $this->errorResponse( 'Parameters must be an object/array', 'INVALID_PARAMETERS' - )); + ); } - // Execute the operation through the registry - $result = McpTools::executeOperation($repositoryKey, $operationType, $operationName, $parameters); - - return $result; + return McpTools::executeOperation($repositoryKey, $operationType, $operationName, $parameters); } catch (\InvalidArgumentException $e) { - return Response::json($this->buildErrorResponse($e->getMessage(), 'INVALID_OPERATION')); + return $this->errorResponse($e->getMessage(), 'INVALID_OPERATION'); } catch (ValidationException $e) { - return Response::json([ - 'error' => 'Validation failed', - 'code' => 'VALIDATION_ERROR', - 'errors' => $e->errors(), - ]); + return $this->errorResponse('Validation failed', 'VALIDATION_ERROR', $e->errors()); } catch (AuthorizationException $e) { - return Response::json($this->buildErrorResponse( + return $this->errorResponse( 'Not authorized to perform this operation', 'AUTHORIZATION_ERROR' - )); + ); } catch (ModelNotFoundException $e) { - return Response::json($this->buildErrorResponse( - 'Record not found', - 'NOT_FOUND' - )); + return $this->errorResponse('Record not found', 'NOT_FOUND'); } catch (\Exception $e) { - return Response::json([ - 'error' => $e->getMessage(), - 'code' => 'EXECUTION_ERROR', - 'type' => get_class($e), - ]); + return $this->errorResponse( + 'An error occurred while executing the operation', + 'EXECUTION_ERROR', + detail: config('app.debug') ? $e->getMessage() : null, + ); } } } diff --git a/src/MCP/Tools/Wrapper/GetOperationDetailsTool.php b/src/MCP/Tools/Wrapper/GetOperationDetailsTool.php index 3b7491538..cad885f5b 100644 --- a/src/MCP/Tools/Wrapper/GetOperationDetailsTool.php +++ b/src/MCP/Tools/Wrapper/GetOperationDetailsTool.php @@ -7,8 +7,11 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetOperationDetailsTool extends Tool { use WrapperToolHelpers; @@ -39,7 +42,31 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean() + ->description('Whether the lookup succeeded.'), + 'operation' => $schema->string() + ->description('The resolved tool name for the operation.'), + 'type' => $schema->string() + ->description('The operation type (index, show, store, update, delete, profile, action, getter).'), + 'title' => $schema->string() + ->description('Human-readable operation title.'), + 'description' => $schema->string() + ->description('Operation description.'), + 'annotations' => $schema->object() + ->description('Safety hints for the operation.'), + 'schema' => $schema->object() + ->description('JSON schema describing the operation parameters.'), + 'examples' => $schema->array() + ->description('Example parameter payloads for the operation.'), + 'next_steps' => $schema->array() + ->description('Suggested follow-up tool calls.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory { try { $repositoryKey = $request->get('repository'); @@ -47,24 +74,24 @@ public function handle(Request $request): Response $operationName = $request->get('operation_name'); if (! $repositoryKey) { - return Response::json($this->buildErrorResponse( + return $this->errorResponse( 'Repository parameter is required', 'MISSING_PARAMETER' - )); + ); } if (! $operationType) { - return Response::json($this->buildErrorResponse( + return $this->errorResponse( 'Operation type parameter is required', 'MISSING_PARAMETER' - )); + ); } if (in_array($operationType, ['action', 'getter']) && ! $operationName) { - return Response::json($this->buildErrorResponse( + return $this->errorResponse( "Operation name is required for {$operationType} operation type", 'MISSING_PARAMETER' - )); + ); } $details = McpTools::getOperationDetails($repositoryKey, $operationType, $operationName); @@ -75,12 +102,13 @@ public function handle(Request $request): Response // Generate examples $examples = $this->generateExamplesFromSchema($formattedSchema, $operationType); - return Response::json([ + return Response::structured([ 'success' => true, 'operation' => $details['operation'], 'type' => $details['type'], 'title' => $details['title'], 'description' => $details['description'], + 'annotations' => $details['annotations'], 'schema' => $formattedSchema, 'examples' => $examples, 'next_steps' => [ @@ -90,9 +118,13 @@ public function handle(Request $request): Response ], ]); } catch (\InvalidArgumentException $e) { - return Response::json($this->buildErrorResponse($e->getMessage(), 'INVALID_OPERATION')); + return $this->errorResponse($e->getMessage(), 'INVALID_OPERATION'); } catch (\Exception $e) { - return Response::json($this->buildErrorResponse($e->getMessage(), 'OPERATION_DETAILS_ERROR')); + return $this->errorResponse( + 'An error occurred while retrieving operation details', + 'OPERATION_DETAILS_ERROR', + detail: config('app.debug') ? $e->getMessage() : null, + ); } } } diff --git a/src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php b/src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php index 9d7d82d94..33df35bba 100644 --- a/src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php +++ b/src/MCP/Tools/Wrapper/GetRepositoryOperationsTool.php @@ -7,8 +7,11 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetRepositoryOperationsTool extends Tool { use WrapperToolHelpers; @@ -32,16 +35,40 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean() + ->description('Whether the lookup succeeded.'), + 'repository' => $schema->string() + ->description('The repository URI key.'), + 'label' => $schema->string() + ->description('Human-readable repository label.'), + 'description' => $schema->string() + ->description('Repository description.'), + 'operations' => $schema->array() + ->description('Available CRUD operations for the repository.'), + 'actions' => $schema->array() + ->description('Available custom actions for the repository.'), + 'getters' => $schema->array() + ->description('Available custom getters for the repository.'), + 'summary' => $schema->object() + ->description('Counts of operations, actions, and getters.'), + 'next_steps' => $schema->array() + ->description('Suggested follow-up tool calls.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory { try { $repositoryKey = $request->get('repository'); if (! $repositoryKey) { - return Response::json($this->buildErrorResponse( + return $this->errorResponse( 'Repository parameter is required', 'MISSING_PARAMETER' - )); + ); } $operations = McpTools::getRepositoryOperations($repositoryKey); @@ -62,7 +89,7 @@ public function handle(Request $request): Response $nextSteps[] = 'For getter details, use "get-operation-details" with operation_type="getter" and operation_name'; } - return Response::json([ + return Response::structured([ 'success' => true, 'repository' => $operations['repository'], 'label' => $operations['label'], @@ -78,9 +105,13 @@ public function handle(Request $request): Response 'next_steps' => $nextSteps, ]); } catch (\InvalidArgumentException $e) { - return Response::json($this->buildErrorResponse($e->getMessage(), 'INVALID_REPOSITORY')); + return $this->errorResponse($e->getMessage(), 'INVALID_REPOSITORY'); } catch (\Exception $e) { - return Response::json($this->buildErrorResponse($e->getMessage(), 'OPERATION_LISTING_ERROR')); + return $this->errorResponse( + 'An error occurred while listing repository operations', + 'OPERATION_LISTING_ERROR', + detail: config('app.debug') ? $e->getMessage() : null, + ); } } } diff --git a/tests/MCP/McpActionsIntegrationTest.php b/tests/MCP/McpActionsIntegrationTest.php index e8a7ed037..1ab66c6c3 100644 --- a/tests/MCP/McpActionsIntegrationTest.php +++ b/tests/MCP/McpActionsIntegrationTest.php @@ -25,6 +25,13 @@ class McpActionsIntegrationTest extends IntegrationTestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + + config(['restify.mcp.mode' => 'direct']); + } + protected function getPackageProviders($app): array { return array_merge(parent::getPackageProviders($app), [ diff --git a/tests/MCP/McpErrorResponseTest.php b/tests/MCP/McpErrorResponseTest.php new file mode 100644 index 000000000..b9e2fe87e --- /dev/null +++ b/tests/MCP/McpErrorResponseTest.php @@ -0,0 +1,174 @@ + true]); + config(['restify.mcp.mode' => 'wrapper']); + + Cache::partialMock() + ->shouldReceive('remember') + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }) + ->shouldReceive('flush') + ->andReturn(true); + } + + protected function tearDown(): void + { + Restify::$repositories = []; + + parent::tearDown(); + } + + protected function getPackageProviders($app): array + { + return array_merge(parent::getPackageProviders($app), [ + McpServiceProvider::class, + ]); + } + + private function callExecuteOperation(string $route, array $arguments): array + { + $response = $this->postJson($route, [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'execute-operation', + 'arguments' => $arguments, + ], + ]); + + $response->assertOk(); + + return $response->json(); + } + + public function test_missing_repository_returns_real_mcp_error(): void + { + Restify::repositories([UserErrorRepository::class]); + Mcp::web('test-missing-repository', RestifyServer::class); + + $result = $this->callExecuteOperation('/test-missing-repository', [ + 'operation_type' => 'index', + ]); + + $this->assertTrue($result['result']['isError']); + + $content = json_decode($result['result']['content'][0]['text'], true); + $this->assertEquals('Repository parameter is required', $content['error']); + $this->assertEquals('MISSING_PARAMETER', $content['code']); + } + + public function test_action_validation_errors_surface_per_field_detail(): void + { + Restify::repositories([UserErrorRepository::class]); + Mcp::web('test-action-validation', RestifyServer::class); + + $result = $this->callExecuteOperation('/test-action-validation', [ + 'repository' => 'mcp-error-users', + 'operation_type' => 'action', + 'operation_name' => 'validated-action', + 'parameters' => [], + ]); + + $this->assertTrue($result['result']['isError']); + + $content = json_decode($result['result']['content'][0]['text'], true); + $this->assertEquals('VALIDATION_ERROR', $content['code']); + $this->assertArrayHasKey('errors', $content); + $this->assertArrayHasKey('title', $content['errors']); + } + + public function test_action_authorization_failure_returns_is_error(): void + { + Restify::repositories([UserErrorRepository::class]); + Mcp::web('test-action-authorization', RestifyServer::class); + + $result = $this->callExecuteOperation('/test-action-authorization', [ + 'repository' => 'mcp-error-users', + 'operation_type' => 'action', + 'operation_name' => 'forbidden-action', + 'parameters' => [], + ]); + + $this->assertTrue($result['result']['isError']); + + $content = json_decode($result['result']['content'][0]['text'], true); + $this->assertEquals('AUTHORIZATION_ERROR', $content['code']); + } +} + +class UserErrorRepository extends Repository +{ + use HasMcpTools; + + public static $model = User::class; + + public static string $uriKey = 'mcp-error-users'; + + public function mcpAllowsIndex(): bool + { + return true; + } + + public function mcpAllowsActions(): bool + { + return true; + } + + public function actions(RestifyRequest $request): array + { + return [ + (new class extends Action + { + public static $uriKey = 'validated-action'; + + public function handle(ActionRequest $request): JsonResponse + { + Validator::make($request->all(), [ + 'title' => ['required', 'string'], + ])->validate(); + + return response()->json(['ok' => true]); + } + })->standalone(), + + (new class extends Action + { + public static $uriKey = 'forbidden-action'; + + public function handle(ActionRequest $request): JsonResponse + { + throw new AuthorizationException('Not allowed.'); + } + })->standalone(), + ]; + } +} diff --git a/tests/MCP/McpFieldsIntegrationTest.php b/tests/MCP/McpFieldsIntegrationTest.php index 93a519563..933ce5661 100644 --- a/tests/MCP/McpFieldsIntegrationTest.php +++ b/tests/MCP/McpFieldsIntegrationTest.php @@ -26,6 +26,13 @@ class McpFieldsIntegrationTest extends IntegrationTestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + + config(['restify.mcp.mode' => 'direct']); + } + protected function getPackageProviders($app): array { return array_merge(parent::getPackageProviders($app), [ diff --git a/tests/MCP/McpServerHardeningTest.php b/tests/MCP/McpServerHardeningTest.php new file mode 100644 index 000000000..6ef92550a --- /dev/null +++ b/tests/MCP/McpServerHardeningTest.php @@ -0,0 +1,251 @@ + true]); + + Cache::partialMock() + ->shouldReceive('remember') + ->andReturnUsing(fn ($key, $ttl, $callback) => $callback()) + ->shouldReceive('flush') + ->andReturn(true); + } + + protected function tearDown(): void + { + Restify::$repositories = []; + + parent::tearDown(); + } + + protected function getPackageProviders($app): array + { + return array_merge(parent::getPackageProviders($app), [ + McpServiceProvider::class, + ]); + } + + public function test_request_parameter_cannot_override_wrapper_mode(): void + { + config(['restify.mcp.mode' => 'wrapper']); + + $repository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'override-posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + + public function mcpAllowsStore(): bool + { + return true; + } + }; + + Restify::repositories([$repository::class]); + Mcp::web('test-override-restify', RestifyServer::class); + + $response = $this->postJson('/test-override-restify?mode=direct', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => ['mode' => 'direct'], + ]); + $response->assertOk(); + + $toolNames = collect($response->json('result.tools'))->pluck('name')->toArray(); + + $this->assertContains('execute-operation', $toolNames); + $this->assertNotContains('override-posts-store-tool', $toolNames); + } + + public function test_read_only_mode_hides_write_operations_from_get_repository_operations(): void + { + config(['restify.mcp.mode' => 'wrapper']); + config(['restify.mcp.read_only' => true]); + + $repository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'readonly-posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + + public function mcpAllowsStore(): bool + { + return true; + } + + public function mcpAllowsUpdate(): bool + { + return true; + } + + public function mcpAllowsDelete(): bool + { + return true; + } + }; + + Restify::repositories([$repository::class]); + Mcp::web('test-readonly-ops-restify', RestifyServer::class); + + $response = $this->postJson('/test-readonly-ops-restify', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get-repository-operations', + 'arguments' => ['repository' => 'readonly-posts'], + ], + ]); + $response->assertOk(); + + $result = json_decode($response->json('result.content.0.text'), true); + $types = collect($result['operations'])->pluck('type')->toArray(); + + $this->assertContains('index', $types); + $this->assertNotContains('store', $types); + $this->assertNotContains('update', $types); + $this->assertNotContains('delete', $types); + } + + public function test_read_only_mode_rejects_execute_operation_for_write(): void + { + config(['restify.mcp.mode' => 'wrapper']); + config(['restify.mcp.read_only' => true]); + + $repository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'readonly-execute-posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + + public function mcpAllowsStore(): bool + { + return true; + } + }; + + Restify::repositories([$repository::class]); + Mcp::web('test-readonly-execute-restify', RestifyServer::class); + + $response = $this->postJson('/test-readonly-execute-restify', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'execute-operation', + 'arguments' => [ + 'repository' => 'readonly-execute-posts', + 'operation_type' => 'store', + 'parameters' => ['title' => 'Should Not Be Created'], + ], + ], + ]); + $response->assertOk(); + + $this->assertTrue($response->json('result.isError')); + $this->assertDatabaseCount('posts', 0); + } + + public function test_local_filesystem_path_is_rejected_by_default(): void + { + $repository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'file-guard-posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + }; + + $this->assertNull($repository->resolveUploadedFileFromInput('/etc/hosts')); + $this->assertFalse($repository::isSafePublicUrl('/etc/hosts')); + + config(['restify.mcp.files.allow_local_paths' => true]); + + $this->assertInstanceOf( + \Illuminate\Http\UploadedFile::class, + $repository->resolveUploadedFileFromInput('/etc/hosts') + ); + } + + public function test_private_and_reserved_ip_urls_are_rejected(): void + { + $repository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'ssrf-guard-posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + }; + + $this->assertFalse($repository::isSafePublicUrl('http://127.0.0.1/secret')); + $this->assertFalse($repository::isSafePublicUrl('http://169.254.169.254/latest/meta-data')); + $this->assertFalse($repository::isSafePublicUrl('http://192.168.0.1/')); + $this->assertFalse($repository::isSafePublicUrl('http://10.0.0.5/')); + $this->assertFalse($repository::isSafePublicUrl('http://[::1]/')); + $this->assertFalse($repository::isSafePublicUrl('ftp://example.com/file.txt')); + $this->assertFalse($repository::isSafePublicUrl('file:///etc/passwd')); + + $this->assertTrue($repository::isSafePublicUrl('http://93.184.216.34/index.html')); + $this->assertTrue($repository::isSafePublicUrl('https://93.184.216.34/index.html')); + } +} diff --git a/tests/MCP/McpStoreToolIntegrationTest.php b/tests/MCP/McpStoreToolIntegrationTest.php index 4005ae61f..4ef8386ce 100644 --- a/tests/MCP/McpStoreToolIntegrationTest.php +++ b/tests/MCP/McpStoreToolIntegrationTest.php @@ -6,6 +6,7 @@ use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools; use Binaryk\LaravelRestify\MCP\RestifyServer; +use Binaryk\LaravelRestify\MCP\Tools\Operations\StoreTool; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Tests\Database\Factories\PostFactory; @@ -24,6 +25,7 @@ protected function setUp(): void parent::setUp(); config(['app.debug' => true]); + config(['restify.mcp.mode' => 'direct']); } protected function getPackageProviders($app): array @@ -378,4 +380,225 @@ public function mcpAllowsStore(): bool $this->assertDatabaseHas('posts', ['title' => $title]); } } + + public function test_mcp_update_after_store_targets_the_created_record(): void + { + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'articles'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title'), + Field::make('user_id'), + ]; + } + + public function mcpAllowsStore(): bool + { + return true; + } + + public function mcpAllowsUpdate(): bool + { + return true; + } + }; + + Restify::repositories([ + $mcpRepository::class, + ]); + + Mcp::web('test-store-then-update-restify', RestifyServer::class); + + $toolsResponse = $this->postJson('/test-store-then-update-restify', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ]); + $toolsResponse->assertOk(); + + $availableTools = collect($toolsResponse->json('result.tools'))->pluck('name')->toArray(); + $storeToolName = collect($availableTools)->first(fn ($name) => str_ends_with($name, 'articles-store-tool')); + $updateToolName = collect($availableTools)->first(fn ($name) => str_ends_with($name, 'articles-update-tool')); + + $this->assertNotNull($storeToolName); + $this->assertNotNull($updateToolName); + + $storeResponse = $this->postJson('/test-store-then-update-restify', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $storeToolName, + 'arguments' => [ + 'title' => 'Original Title', + 'user_id' => 1, + ], + ], + ]); + $storeResponse->assertOk(); + + $storedId = json_decode($storeResponse->json('result.content.0.text'), true)['data']['id']; + + $updateResponse = $this->postJson('/test-store-then-update-restify', [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => [ + 'name' => $updateToolName, + 'arguments' => [ + 'id' => $storedId, + 'title' => 'Updated Title', + ], + ], + ]); + $updateResponse->assertOk(); + + $updated = json_decode($updateResponse->json('result.content.0.text'), true); + + $this->assertEquals($storedId, $updated['data']['id']); + $this->assertEquals('Updated Title', $updated['data']['attributes']['title']); + + $this->assertDatabaseCount('posts', 1); + $this->assertDatabaseHas('posts', ['id' => $storedId, 'title' => 'Updated Title']); + } + + public function test_mcp_store_tools_from_different_repositories_do_not_pollute_each_other(): void + { + $firstRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'alpha'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title'), + Field::make('user_id'), + ]; + } + + public function mcpAllowsStore(): bool + { + return true; + } + }; + + $secondRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'beta'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title'), + Field::make('user_id'), + ]; + } + + public function mcpAllowsStore(): bool + { + return true; + } + }; + + Restify::repositories([ + $firstRepository::class, + $secondRepository::class, + ]); + + Mcp::web('test-multi-store-restify', RestifyServer::class); + + $availableTools = collect($this->postJson('/test-multi-store-restify', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ])->json('result.tools'))->pluck('name')->toArray(); + + $firstStoreTool = collect($availableTools)->first(fn ($name) => str_ends_with($name, 'alpha-store-tool')); + $secondStoreTool = collect($availableTools)->first(fn ($name) => str_ends_with($name, 'beta-store-tool')); + + $this->assertNotNull($firstStoreTool); + $this->assertNotNull($secondStoreTool); + + $callStore = function (string $toolName, string $title, int $id): array { + $response = $this->postJson('/test-multi-store-restify', [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'method' => 'tools/call', + 'params' => [ + 'name' => $toolName, + 'arguments' => [ + 'title' => $title, + 'user_id' => 1, + ], + ], + ]); + $response->assertOk(); + + return json_decode($response->json('result.content.0.text'), true); + }; + + $first = $callStore($firstStoreTool, 'From First Repository', 2); + $second = $callStore($secondStoreTool, 'From Second Repository', 3); + + $this->assertEquals('From First Repository', $first['data']['attributes']['title']); + $this->assertEquals('From Second Repository', $second['data']['attributes']['title']); + $this->assertNotEquals($first['data']['id'], $second['data']['id']); + + $this->assertDatabaseCount('posts', 2); + $this->assertDatabaseHas('posts', ['title' => 'From First Repository']); + $this->assertDatabaseHas('posts', ['title' => 'From Second Repository']); + } + + public function test_operation_tool_resolves_a_fresh_repository_instance_per_call(): void + { + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'fresh-instance-posts'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title'), + ]; + } + + public function mcpAllowsStore(): bool + { + return true; + } + }; + + Restify::repositories([ + $mcpRepository::class, + ]); + + $tool = new StoreTool($mcpRepository::class); + + $this->assertNotSame( + $tool->repository(), + $tool->repository(), + 'Each call must resolve a fresh repository instance so state cannot leak between MCP tool invocations.' + ); + } } diff --git a/tests/MCP/McpToolAnnotationsTest.php b/tests/MCP/McpToolAnnotationsTest.php new file mode 100644 index 000000000..74434b91b --- /dev/null +++ b/tests/MCP/McpToolAnnotationsTest.php @@ -0,0 +1,44 @@ +toArray(); + + $this->assertArrayHasKey('annotations', $definition); + $this->assertTrue($definition['annotations']['destructiveHint']); + $this->assertTrue($definition['annotations']['idempotentHint']); + } + + public function test_index_tool_definition_exposes_read_only_hint(): void + { + $definition = (new IndexTool(PostRepository::class))->toArray(); + + $this->assertTrue($definition['annotations']['readOnlyHint']); + } + + public function test_update_tool_definition_exposes_idempotent_hint(): void + { + $definition = (new UpdateTool(PostRepository::class))->toArray(); + + $this->assertTrue($definition['annotations']['idempotentHint']); + $this->assertArrayNotHasKey('destructiveHint', $definition['annotations']); + } + + public function test_store_tool_definition_has_no_safety_hints(): void + { + $annotations = (new StoreTool(PostRepository::class))->annotations(); + + $this->assertSame([], $annotations); + } +} diff --git a/tests/MCP/McpUpdateToolIntegrationTest.php b/tests/MCP/McpUpdateToolIntegrationTest.php index 4bb54af75..adab55f96 100644 --- a/tests/MCP/McpUpdateToolIntegrationTest.php +++ b/tests/MCP/McpUpdateToolIntegrationTest.php @@ -19,6 +19,13 @@ class McpUpdateToolIntegrationTest extends IntegrationTestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + + config(['restify.mcp.mode' => 'direct']); + } + protected function getPackageProviders($app): array { return array_merge(parent::getPackageProviders($app), [ diff --git a/tests/MCP/SkillCommandTest.php b/tests/MCP/SkillCommandTest.php new file mode 100644 index 000000000..370e3a8d4 --- /dev/null +++ b/tests/MCP/SkillCommandTest.php @@ -0,0 +1,187 @@ +shouldReceive('remember') + ->andReturnUsing(fn ($key, $ttl, $callback) => $callback()) + ->shouldReceive('flush') + ->andReturn(true); + + $this->outputPath = sys_get_temp_dir().'/restify-skill-'.uniqid(); + } + + protected function tearDown(): void + { + Restify::$repositories = []; + + File::deleteDirectory($this->outputPath); + + parent::tearDown(); + } + + protected function getPackageProviders($app): array + { + return array_merge(parent::getPackageProviders($app), [ + McpServiceProvider::class, + ]); + } + + public function test_generates_skill_and_openapi_files(): void + { + Restify::repositories([$this->repositoryClass()]); + + $this->artisan('restify:skill', ['--path' => $this->outputPath]) + ->assertSuccessful(); + + $this->assertFileExists($this->outputPath.'/SKILL.md'); + $this->assertFileExists($this->outputPath.'/openapi.json'); + + $skill = File::get($this->outputPath.'/SKILL.md'); + + $this->assertStringContainsString('filterName=value', $skill); + $this->assertStringContainsString('Do **NOT** use the bracketed `filter[name]=value`', $skill); + $this->assertStringContainsString('### `skill-posts`', $skill); + $this->assertStringContainsString('**Operations:**', $skill); + $this->assertStringContainsString('curl -X POST', $skill); + + $openApi = json_decode(File::get($this->outputPath.'/openapi.json'), true); + + $this->assertIsArray($openApi); + $this->assertSame('3.1.0', $openApi['openapi']); + $this->assertArrayHasKey('bearerAuth', $openApi['components']['securitySchemes']); + $this->assertArrayHasKey('/api/restify/skill-posts', $openApi['paths']); + $this->assertArrayHasKey('get', $openApi['paths']['/api/restify/skill-posts']); + $this->assertArrayHasKey('post', $openApi['paths']['/api/restify/skill-posts']); + $this->assertArrayHasKey('/api/restify/skill-posts/{repositoryId}', $openApi['paths']); + } + + public function test_generates_skill_with_object_valued_related(): void + { + Restify::repositories([$this->repositoryWithObjectRelated()]); + + $this->artisan('restify:skill', ['--path' => $this->outputPath]) + ->assertSuccessful(); + + $skill = File::get($this->outputPath.'/SKILL.md'); + + $this->assertStringContainsString('**Related:** `users`', $skill); + } + + private function repositoryWithObjectRelated(): string + { + $repository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'skill-related-posts'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title')->required(), + ]; + } + + public static function related(): array + { + return [ + 'users' => BelongsToMany::make('users', UserRepository::class), + ]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + }; + + return $repository::class; + } + + private function repositoryClass(): string + { + $repository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'skill-posts'; + + public static array $match = [ + 'title' => 'string', + ]; + + public static array $sort = [ + 'id', + ]; + + public static array $search = [ + 'title', + ]; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title')->required(), + Field::make('description'), + ]; + } + + public function actions(RestifyRequest $request): array + { + return [PublishPostAction::new()]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + + public function mcpAllowsShow(): bool + { + return true; + } + + public function mcpAllowsStore(): bool + { + return true; + } + + public function mcpAllowsActions(): bool + { + return true; + } + }; + + return $repository::class; + } +} diff --git a/tests/MCP/WrapperToolsIntegrationTest.php b/tests/MCP/WrapperToolsIntegrationTest.php index 3252447bd..17cc215f5 100644 --- a/tests/MCP/WrapperToolsIntegrationTest.php +++ b/tests/MCP/WrapperToolsIntegrationTest.php @@ -354,6 +354,68 @@ public function mcpAllowsUpdate(): bool $this->assertEquals(3, $resultContent['summary']['crud_operations_count']); } + public function test_get_repository_operations_includes_safety_annotations_per_operation(): void + { + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'test-annotations-posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + + public function mcpAllowsUpdate(): bool + { + return true; + } + + public function mcpAllowsDelete(): bool + { + return true; + } + }; + + Restify::repositories([$mcpRepository::class]); + Mcp::web('test-annotations-restify', RestifyServer::class); + + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get-repository-operations', + 'arguments' => [ + 'repository' => 'test-annotations-posts', + ], + ], + ]; + + $response = $this->postJson('/test-annotations-restify', $mcpPayload); + $response->assertOk(); + + $resultContent = json_decode($response->json()['result']['content'][0]['text'], true); + $operations = collect($resultContent['operations']); + + $index = $operations->firstWhere('type', 'index'); + $this->assertSame(['readOnlyHint' => true], $index['annotations']); + + $update = $operations->firstWhere('type', 'update'); + $this->assertSame(['idempotentHint' => true], $update['annotations']); + + $delete = $operations->firstWhere('type', 'delete'); + $this->assertSame(['destructiveHint' => true, 'idempotentHint' => true], $delete['annotations']); + } + public function test_get_repository_operations_rejects_non_mcp_repositories(): void { $nonMcpRepository = new class extends Repository @@ -644,6 +706,119 @@ public function mcpAllowsStore(): bool $this->assertStringContainsString("Operation 'store' not found", $resultContent['error']); } + public function test_discover_repositories_returns_structured_content_matching_text(): void + { + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'structured-discover-posts'; + + public function fields(RestifyRequest $request): array + { + return [Field::make('title')]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + }; + + Restify::repositories([$mcpRepository::class]); + Mcp::web('test-structured-discover-restify', RestifyServer::class); + + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'discover-repositories', + 'arguments' => [], + ], + ]; + + $response = $this->postJson('/test-structured-discover-restify', $mcpPayload); + $response->assertOk(); + + $result = $response->json()['result']; + + $this->assertArrayHasKey('structuredContent', $result); + + $textPayload = json_decode($result['content'][0]['text'], true); + + $this->assertSame($textPayload, $result['structuredContent']); + $this->assertTrue($result['structuredContent']['success']); + $this->assertSame( + 'structured-discover-posts', + $result['structuredContent']['repositories'][0]['name'] + ); + } + + public function test_execute_operation_index_returns_structured_content_matching_text(): void + { + $mcpRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'structured-index-posts'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title'), + Field::make('description'), + ]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + }; + + Restify::repositories([$mcpRepository::class]); + Mcp::web('test-structured-index-restify', RestifyServer::class); + + PostFactory::many(2); + + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'execute-operation', + 'arguments' => [ + 'repository' => 'structured-index-posts', + 'operation_type' => 'index', + 'parameters' => [ + 'page' => 1, + 'perPage' => 10, + ], + ], + ], + ]; + + $response = $this->postJson('/test-structured-index-restify', $mcpPayload); + $response->assertOk(); + + $result = $response->json()['result']; + + $this->assertArrayHasKey('structuredContent', $result); + + $textPayload = json_decode($result['content'][0]['text'], true); + + $this->assertSame($textPayload, $result['structuredContent']); + $this->assertArrayHasKey('data', $result['structuredContent']); + $this->assertArrayHasKey('meta', $result['structuredContent']); + $this->assertCount(2, $result['structuredContent']['data']); + $this->assertSame(2, $result['structuredContent']['meta']['total']); + } + public function test_wrapper_mode_complete_workflow(): void { // This test demonstrates the complete workflow: discover -> operations -> details -> execute From 1869756a405b2e17a3d4991e88e02cd38908e65c Mon Sep 17 00:00:00 2001 From: binaryk <6833714+binaryk@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:26:59 +0000 Subject: [PATCH 2/3] Fix styling --- tests/MCP/McpServerHardeningTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/MCP/McpServerHardeningTest.php b/tests/MCP/McpServerHardeningTest.php index 6ef92550a..52ce21eea 100644 --- a/tests/MCP/McpServerHardeningTest.php +++ b/tests/MCP/McpServerHardeningTest.php @@ -11,6 +11,7 @@ use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; use Binaryk\LaravelRestify\Tests\IntegrationTestCase; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Cache; use Laravel\Mcp\Facades\Mcp; use Laravel\Mcp\Server\McpServiceProvider; @@ -216,7 +217,7 @@ public function fields(RestifyRequest $request): array config(['restify.mcp.files.allow_local_paths' => true]); $this->assertInstanceOf( - \Illuminate\Http\UploadedFile::class, + UploadedFile::class, $repository->resolveUploadedFileFromInput('/etc/hosts') ); } From b3e139e638ef88959a4ff2d41b0296914a809ae3 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Tue, 7 Jul 2026 13:43:22 +0300 Subject: [PATCH 3/3] fix(tests): use a temp file instead of /etc/hosts in the local-path guard test /etc/hosts does not exist on Windows runners, so the allow_local_paths assertion received null instead of an UploadedFile. A test-created temp file also proves the default rejection against a genuinely readable file. --- tests/MCP/McpServerHardeningTest.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/MCP/McpServerHardeningTest.php b/tests/MCP/McpServerHardeningTest.php index 52ce21eea..7e8b3e724 100644 --- a/tests/MCP/McpServerHardeningTest.php +++ b/tests/MCP/McpServerHardeningTest.php @@ -211,15 +211,20 @@ public function fields(RestifyRequest $request): array } }; - $this->assertNull($repository->resolveUploadedFileFromInput('/etc/hosts')); - $this->assertFalse($repository::isSafePublicUrl('/etc/hosts')); + $localFile = tempnam(sys_get_temp_dir(), 'restify-mcp'); + file_put_contents($localFile, 'local file content'); + + $this->assertNull($repository->resolveUploadedFileFromInput($localFile)); + $this->assertFalse($repository::isSafePublicUrl($localFile)); config(['restify.mcp.files.allow_local_paths' => true]); $this->assertInstanceOf( UploadedFile::class, - $repository->resolveUploadedFileFromInput('/etc/hosts') + $repository->resolveUploadedFileFromInput($localFile) ); + + unlink($localFile); } public function test_private_and_reserved_ip_urls_are_rejected(): void