diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f3f229002..fa35829e9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,4 +49,4 @@ jobs: run: sleep 5 - name: Execute tests - run: ./vendor/bin/testbench package:test --no-coverage + run: composer test diff --git a/src/Actions/Action.php b/src/Actions/Action.php index b8308b065..b0a80f86e 100644 --- a/src/Actions/Action.php +++ b/src/Actions/Action.php @@ -56,7 +56,7 @@ public static function indexQuery(RestifyRequest $request, $query) /** * The callback used to authorize running the action. */ - public ?Closure $runCallback; + public ?Closure $runCallback = null; public function name() { diff --git a/src/Fields/FieldCollection.php b/src/Fields/FieldCollection.php index 9b670e1ec..db50df12e 100644 --- a/src/Fields/FieldCollection.php +++ b/src/Fields/FieldCollection.php @@ -3,7 +3,7 @@ namespace Binaryk\LaravelRestify\Fields; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpRequestable; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -70,7 +70,7 @@ public function forIndex(RestifyRequest $request, $repository): self public function forMcpIndex(RestifyRequest $request, $repository): self { // If this is an MCP request and repository has fieldsForMcpIndex method - if ($request instanceof McpRequest && method_exists($repository, 'fieldsForMcpIndex')) { + if ($request instanceof McpRequestable && method_exists($repository, 'fieldsForMcpIndex')) { // Get the MCP-specific fields from the repository $mcpFields = $repository->fieldsForMcpIndex($request); $mcpFieldAttributes = collect($mcpFields)->map(fn ($field) => $field->attribute)->toArray(); diff --git a/src/Fields/OrganicField.php b/src/Fields/OrganicField.php index ae52e6a77..74701fab4 100644 --- a/src/Fields/OrganicField.php +++ b/src/Fields/OrganicField.php @@ -3,7 +3,7 @@ namespace Binaryk\LaravelRestify\Fields; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpRequestable; use Binaryk\LaravelRestify\Traits\ProxiesCanSeeToGate; use Closure; use Illuminate\Http\Request; @@ -101,7 +101,7 @@ public function isShownOnShow(RestifyRequest $request, $repository): bool } // Check MCP-specific visibility for MCP requests - if ($request instanceof McpRequest) { + if ($request instanceof McpRequestable) { return $this->isShownOnMcp($request, $repository); } @@ -124,7 +124,7 @@ public function isShownOnIndex(RestifyRequest $request, $repository): bool } // Check MCP-specific visibility for MCP requests - if ($request instanceof McpRequest) { + if ($request instanceof McpRequestable) { return $this->isShownOnMcp($request, $repository); } diff --git a/src/Filters/MatchesCollection.php b/src/Filters/MatchesCollection.php index 417bfbd39..5a1ddfd39 100644 --- a/src/Filters/MatchesCollection.php +++ b/src/Filters/MatchesCollection.php @@ -3,7 +3,7 @@ namespace Binaryk\LaravelRestify\Filters; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpRequestable; use Binaryk\LaravelRestify\Repositories\Repository; use Closure; use Illuminate\Database\Eloquent\Builder; @@ -70,11 +70,11 @@ public function inQuery(RestifyRequest $request): self $value = $request->query($filter->column()); $negatedValue = $request->query("-{$filter->column()}"); - if ($request instanceof McpRequest) { + if ($request instanceof McpRequestable) { $negatedValue = $request->input("-{$filter->column()}"); } - if ($request instanceof McpRequest) { + if ($request instanceof McpRequestable) { $value = $request->input($filter->column()); } diff --git a/src/Http/Requests/Concerns/DetermineRequestType.php b/src/Http/Requests/Concerns/DetermineRequestType.php index 43026ab8e..c8d0c15ce 100644 --- a/src/Http/Requests/Concerns/DetermineRequestType.php +++ b/src/Http/Requests/Concerns/DetermineRequestType.php @@ -12,7 +12,15 @@ use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest; use Binaryk\LaravelRestify\Http\Requests\RepositoryUpdateBulkRequest; use Binaryk\LaravelRestify\Http\Requests\RepositoryUpdateRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpDestroyRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpGetterRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpIndexRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpShowRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpStoreBulkRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpStoreRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpUpdateBulkRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpUpdateRequest; /** * @mixin RestifyRequest @@ -21,11 +29,8 @@ trait DetermineRequestType { public function isIndexRequest(): bool { - if ($this instanceof McpRequest) { - return $this->isIndexRequest(); - } - - return $this instanceof RepositoryIndexRequest; + return $this instanceof RepositoryIndexRequest + || $this instanceof McpIndexRequest; } public function isGlobalRequest(): bool @@ -35,73 +40,49 @@ public function isGlobalRequest(): bool public function isShowRequest(): bool { - if ($this instanceof McpRequest) { - return $this->isShowRequest(); - } - - return $this instanceof RepositoryShowRequest; + return $this instanceof RepositoryShowRequest + || $this instanceof McpShowRequest; } public function isUpdateRequest(): bool { - if ($this instanceof McpRequest) { - return $this->isUpdateRequest(); - } - - return $this instanceof RepositoryUpdateRequest; + return $this instanceof RepositoryUpdateRequest + || $this instanceof McpUpdateRequest; } public function isStoreRequest(): bool { - if ($this instanceof McpRequest) { - return $this->isStoreRequest(); - } - - return $this instanceof RepositoryStoreRequest; + return $this instanceof RepositoryStoreRequest + || $this instanceof McpStoreRequest; } public function isDestroyRequest(): bool { - if ($this instanceof McpRequest) { - return $this->isDestroyRequest(); - } - - return $this instanceof RepositoryDestroyRequest; + return $this instanceof RepositoryDestroyRequest + || $this instanceof McpDestroyRequest; } public function isStoreBulkRequest(): bool { - if ($this instanceof McpRequest) { - return $this->isStoreBulkRequest(); - } - - return $this instanceof RepositoryStoreBulkRequest; + return $this instanceof RepositoryStoreBulkRequest + || $this instanceof McpStoreBulkRequest; } public function isUpdateBulkRequest(): bool { - if ($this instanceof McpRequest) { - return $this->isUpdateBulkRequest(); - } - - return $this instanceof RepositoryUpdateBulkRequest; + return $this instanceof RepositoryUpdateBulkRequest + || $this instanceof McpUpdateBulkRequest; } public function isActionRequest(): bool { - if ($this instanceof McpRequest) { - return $this->isActionRequest(); - } - - return $this instanceof ActionRequest; + return $this instanceof ActionRequest + || $this instanceof McpActionRequest; } public function isGetterRequest(): bool { - if ($this instanceof McpRequest) { - return $this->isGetterRequest(); - } - - return $this instanceof GetterRequest; + return $this instanceof GetterRequest + || $this instanceof McpGetterRequest; } } diff --git a/src/MCP/Requests/McpActionRequest.php b/src/MCP/Requests/McpActionRequest.php index fcadf25cd..5806db579 100644 --- a/src/MCP/Requests/McpActionRequest.php +++ b/src/MCP/Requests/McpActionRequest.php @@ -2,4 +2,6 @@ namespace Binaryk\LaravelRestify\MCP\Requests; -class McpActionRequest extends McpRequest {} +use Binaryk\LaravelRestify\Http\Requests\ActionRequest; + +class McpActionRequest extends ActionRequest implements McpRequestable {} diff --git a/src/MCP/Requests/McpDestroyRequest.php b/src/MCP/Requests/McpDestroyRequest.php index 23c4d297b..b093f63b3 100644 --- a/src/MCP/Requests/McpDestroyRequest.php +++ b/src/MCP/Requests/McpDestroyRequest.php @@ -2,4 +2,4 @@ namespace Binaryk\LaravelRestify\MCP\Requests; -class McpDestroyRequest extends McpRequest {} +class McpDestroyRequest extends McpRequest implements McpRequestable {} diff --git a/src/MCP/Requests/McpGetterRequest.php b/src/MCP/Requests/McpGetterRequest.php index bedfbe1df..b4b034ae4 100644 --- a/src/MCP/Requests/McpGetterRequest.php +++ b/src/MCP/Requests/McpGetterRequest.php @@ -2,4 +2,6 @@ namespace Binaryk\LaravelRestify\MCP\Requests; -class McpGetterRequest extends McpRequest {} +use Binaryk\LaravelRestify\Http\Requests\GetterRequest; + +class McpGetterRequest extends GetterRequest implements McpRequestable {} diff --git a/src/MCP/Requests/McpIndexRequest.php b/src/MCP/Requests/McpIndexRequest.php index 362852a36..24f63f0cb 100644 --- a/src/MCP/Requests/McpIndexRequest.php +++ b/src/MCP/Requests/McpIndexRequest.php @@ -2,4 +2,4 @@ namespace Binaryk\LaravelRestify\MCP\Requests; -class McpIndexRequest extends McpRequest {} +class McpIndexRequest extends McpRequest implements McpRequestable {} diff --git a/src/MCP/Requests/McpRequest.php b/src/MCP/Requests/McpRequest.php index 245ff61f8..ade63062f 100644 --- a/src/MCP/Requests/McpRequest.php +++ b/src/MCP/Requests/McpRequest.php @@ -4,7 +4,7 @@ use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; -class McpRequest extends RestifyRequest +class McpRequest extends RestifyRequest implements McpRequestable { /** * Get the MCP tool name from the request payload. diff --git a/src/MCP/Requests/McpRequestable.php b/src/MCP/Requests/McpRequestable.php new file mode 100644 index 000000000..325ff8a46 --- /dev/null +++ b/src/MCP/Requests/McpRequestable.php @@ -0,0 +1,5 @@ +replace($request->all()); + $mcpRequest->merge([ + 'mcp_repository_key' => $this->repository->uriKey(), + ]); + + // Parse repositories string to array if provided + if ($mcpRequest->has('repositories') && is_string($mcpRequest->input('repositories'))) { + $repositories = json_decode($mcpRequest->input('repositories'), true) ?? []; + $mcpRequest->merge(['repositories' => $repositories]); + } + + // For show actions with single ID, set the route parameter + if ($id = $mcpRequest->input('id')) { + $mcpRequest->setRouteResolver(function () use ($id) { + return new class($id) + { + public function __construct(private $id) {} + + public function parameter($key, $default = null) + { + return $key === 'repositoryId' ? $this->id : $default; + } + }; + }); + } $result = $this->repository->actionTool($this->action, $mcpRequest); diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index ef58362f8..e3694daac 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -15,7 +15,7 @@ use Binaryk\LaravelRestify\Http\Controllers\RestResponse; use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreBulkRequest; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; -use Binaryk\LaravelRestify\MCP\Requests\McpRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpRequestable; use Binaryk\LaravelRestify\Models\Concerns\HasActionLogs; use Binaryk\LaravelRestify\Models\CreationAware; use Binaryk\LaravelRestify\Repositories\Concerns\InteractsWithAttachers; @@ -288,7 +288,7 @@ public function collectFields(RestifyRequest $request): FieldCollection $method = 'fields'; // MCP-specific field methods (highest priority) - if ($request instanceof McpRequest) { + if ($request instanceof McpRequestable) { if ($request->isIndexRequest() && method_exists($this, 'fieldsForMcpIndex')) { $method = 'fieldsForMcpIndex'; } elseif ($request->isShowRequest() && method_exists($this, 'fieldsForMcpShow')) { @@ -575,7 +575,7 @@ public function resolveIndexPivots(RestifyRequest $request): array */ public function resolveRelationships($request): array { - if ($request instanceof McpRequest) { + if ($request instanceof McpRequestable) { $this->forMcp(get_class($request)); } diff --git a/tests/MCP/McpActionsIntegrationTest.php b/tests/MCP/McpActionsIntegrationTest.php new file mode 100644 index 000000000..6c2938728 --- /dev/null +++ b/tests/MCP/McpActionsIntegrationTest.php @@ -0,0 +1,496 @@ +mockPosts(1, 2); + + $toolsResponse = $this->postJson('/test-actions', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ]); + + $toolsData = $toolsResponse->json(); + $availableTools = collect($toolsData['result']['tools'])->pluck('name')->toArray(); + $actionToolName = collect($availableTools)->filter( + fn ($name) => str_contains($name, 'mcp-test-posts') && str_contains($name, 'publish-post-action') + )->first(); + + $this->assertNotNull($actionToolName, 'Action tool not found in available tools'); + + $response = $this->postJson('/test-actions', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $actionToolName, + 'arguments' => [ + 'repositories' => '['.$posts->first()->id.','.$posts->last()->id.']', + ], + ], + ]); + + $response->assertOk(); + $responseData = $response->json(); + + if (isset($responseData['error'])) { + $this->fail('MCP Error: '.$responseData['error']['message']); + } + + $this->assertArrayHasKey('result', $responseData); + + $resultContent = json_decode($responseData['result']['content'][0]['text'], true); + + $this->assertArrayHasKey('success', $resultContent); + $this->assertTrue($resultContent['success']); + $this->assertEquals('publish-post-action', $resultContent['action']); + + $this->assertNotEmpty(PublishPostAction::$applied, 'Action was not executed'); + $this->assertNotEmpty(PublishPostAction::$applied[0], 'No models were passed to action'); + $this->assertEquals(2, PublishPostAction::$applied[0][0]->id); + $this->assertEquals(1, PublishPostAction::$applied[0][1]->id); + } + + public function test_action_with_custom_uri_key(): void + { + PublishPostAction::$applied = []; + + $mcpPostRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'mcp-custom-posts'; + + public function actions(RestifyRequest $request): array + { + return [ + PublishPostAction::new(), + ]; + } + + public function mcpAllowsActions(): bool + { + return true; + } + }; + + Restify::repositories([ + $mcpPostRepository::class, + ]); + + Mcp::web('test-custom-actions', RestifyServer::class); + + $posts = $this->mockPosts(1, 2); + + $toolsResponse = $this->postJson('/test-custom-actions', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ]); + + $toolsData = $toolsResponse->json(); + $availableTools = collect($toolsData['result']['tools'])->pluck('name')->toArray(); + $actionToolName = collect($availableTools)->filter( + fn ($name) => str_contains($name, 'mcp-custom-posts') && str_contains($name, 'publish-post-action') + )->first(); + + $this->assertNotNull($actionToolName); + + $response = $this->postJson('/test-custom-actions', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $actionToolName, + 'arguments' => [ + 'repositories' => '['.$posts->first()->id.','.$posts->last()->id.']', + ], + ], + ]); + + $response->assertOk(); + $responseData = $response->json(); + + if (isset($responseData['error'])) { + $this->fail('MCP Error: '.$responseData['error']['message']); + } + + $this->assertArrayHasKey('result', $responseData); + $resultContent = json_decode($responseData['result']['content'][0]['text'], true); + + $this->assertTrue($resultContent['success']); + $this->assertNotEmpty(PublishPostAction::$applied); + } + + public function test_show_action_not_need_repositories(): void + { + ActivateAction::$applied = []; + + $mcpUserRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = User::class; + + public static string $uriKey = 'mcp-test-users'; + + public function actions(RestifyRequest $request): array + { + return [ + ActivateAction::new()->onlyOnShow(), + ]; + } + + public function mcpAllowsActions(): bool + { + return true; + } + }; + + Restify::repositories([ + $mcpUserRepository::class, + ]); + + Mcp::web('test-show-actions', RestifyServer::class); + + $users = $this->mockUsers(); + + $toolsResponse = $this->postJson('/test-show-actions', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ]); + + $toolsData = $toolsResponse->json(); + $availableTools = collect($toolsData['result']['tools'])->pluck('name')->toArray(); + $actionToolName = collect($availableTools)->filter( + fn ($name) => str_contains($name, 'mcp-test-users') && str_contains($name, 'activate-action') + )->first(); + + $this->assertNotNull($actionToolName); + + $response = $this->postJson('/test-show-actions', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $actionToolName, + 'arguments' => [ + 'id' => (string) $users->first()->id, + ], + ], + ]); + + $response->assertOk(); + $responseData = $response->json(); + + if (isset($responseData['error'])) { + $this->fail('MCP Error: '.$responseData['error']['message']); + } + + $this->assertArrayHasKey('result', $responseData); + $resultContent = json_decode($responseData['result']['content'][0]['text'], true); + + $this->assertArrayHasKey('success', $resultContent); + $this->assertTrue($resultContent['success']); + $this->assertEquals(1, ActivateAction::$applied[0]->id); + } + + public function test_could_perform_standalone_action(): void + { + DisableProfileAction::$applied = []; + + $mcpUserRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = User::class; + + public static string $uriKey = 'mcp-standalone-users'; + + public function actions(RestifyRequest $request): array + { + return [ + DisableProfileAction::new()->standalone(), + ]; + } + + public function mcpAllowsActions(): bool + { + return true; + } + }; + + Restify::repositories([ + $mcpUserRepository::class, + ]); + + Mcp::web('test-standalone-actions', RestifyServer::class); + + $toolsResponse = $this->postJson('/test-standalone-actions', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ]); + + $toolsData = $toolsResponse->json(); + $availableTools = collect($toolsData['result']['tools'])->pluck('name')->toArray(); + $actionToolName = collect($availableTools)->filter( + fn ($name) => str_contains($name, 'mcp-standalone-users') && str_contains($name, 'disable_profile') + )->first(); + + $this->assertNotNull($actionToolName); + + $response = $this->postJson('/test-standalone-actions', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $actionToolName, + 'arguments' => [], + ], + ]); + + $response->assertOk(); + $responseData = $response->json(); + + if (isset($responseData['error'])) { + $this->fail('MCP Error: '.$responseData['error']['message']); + } + + $this->assertArrayHasKey('result', $responseData); + $resultContent = json_decode($responseData['result']['content'][0]['text'], true); + + $this->assertTrue($resultContent['success']); + $this->assertEquals('foo', DisableProfileAction::$applied[0]); + } + + public function test_action_authorization_with_can_run(): void + { + $mcpUserRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = User::class; + + public static string $uriKey = 'mcp-auth-users'; + + public function actions(RestifyRequest $request): array + { + return [ + ActivateAction::new()->onlyOnShow()->canRun(function ($request, $model) { + return false; + }), + ]; + } + + public function mcpAllowsActions(): bool + { + return true; + } + }; + + Restify::repositories([ + $mcpUserRepository::class, + ]); + + Mcp::web('test-auth-actions', RestifyServer::class); + + $users = $this->mockUsers(); + + $toolsResponse = $this->postJson('/test-auth-actions', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ]); + + $toolsData = $toolsResponse->json(); + $availableTools = collect($toolsData['result']['tools'])->pluck('name')->toArray(); + $actionToolName = collect($availableTools)->filter( + fn ($name) => str_contains($name, 'mcp-auth-users') && str_contains($name, 'activate-action') + )->first(); + + $this->assertNotNull($actionToolName); + + $response = $this->postJson('/test-auth-actions', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $actionToolName, + 'arguments' => [ + 'id' => (string) $users->first()->id, + ], + ], + ]); + + $response->assertOk(); + $responseData = $response->json(); + + $resultContent = json_decode($responseData['result']['content'][0]['text'], true); + + $this->assertArrayHasKey('error', $resultContent); + $this->assertEquals('Not authorized to run this action', $resultContent['error']); + } + + public function test_action_with_validation_rules(): void + { + $mcpPostRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'mcp-validation-posts'; + + public function actions(RestifyRequest $request): array + { + return [ + new class extends Action + { + public static $uriKey = 'custom-publish'; + + public function rules(): array + { + return [ + 'title' => ['required', 'string'], + 'is_active' => ['required', 'boolean'], + ]; + } + + public function handle(ActionRequest $request, Collection $models): JsonResponse + { + return response()->json([ + 'validated_data' => $request->validated(), + ]); + } + }, + ]; + } + + public function mcpAllowsActions(): bool + { + return true; + } + }; + + Restify::repositories([ + $mcpPostRepository::class, + ]); + + Mcp::web('test-validation-actions', RestifyServer::class); + + $posts = $this->mockPosts(1); + + $toolsResponse = $this->postJson('/test-validation-actions', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ]); + + $toolsData = $toolsResponse->json(); + $availableTools = collect($toolsData['result']['tools'])->pluck('name')->toArray(); + $actionToolName = collect($availableTools)->filter( + fn ($name) => str_contains($name, 'mcp-validation-posts') && str_contains($name, 'custom-publish') + )->first(); + + $this->assertNotNull($actionToolName); + + $response = $this->postJson('/test-validation-actions', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $actionToolName, + 'arguments' => [ + 'repositories' => '['.$posts->first()->id.']', + 'title' => 'Test Title', + 'is_active' => true, + ], + ], + ]); + + $response->assertOk(); + $responseData = $response->json(); + + if (isset($responseData['error'])) { + $this->fail('MCP Error: '.$responseData['error']['message']); + } + + $resultContent = json_decode($responseData['result']['content'][0]['text'], true); + + $this->assertTrue($resultContent['success']); + + // Verify action executed successfully - the fact that we got success means + // the action was able to process the request with the validation rules defined + } +}