diff --git a/src/Eager/Related.php b/src/Eager/Related.php index 62e1f436c..7f7a7eea9 100644 --- a/src/Eager/Related.php +++ b/src/Eager/Related.php @@ -64,6 +64,7 @@ public function resolveField(Repository $repository): EagerField { return $this ->field + ->forMcp($repository->isForMcp()) ->columns($this->getColumns()) ->resolve($repository); } diff --git a/src/Eager/RelatedCollection.php b/src/Eager/RelatedCollection.php index 94039941f..625a1d70f 100644 --- a/src/Eager/RelatedCollection.php +++ b/src/Eager/RelatedCollection.php @@ -11,6 +11,7 @@ use Binaryk\LaravelRestify\Fields\MorphToMany; use Binaryk\LaravelRestify\Filters\SortableFilter; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools; use Binaryk\LaravelRestify\Repositories\Repository; use Illuminate\Support\Collection; @@ -97,6 +98,21 @@ public function forIndex(RestifyRequest $request, Repository $repository): self }); } + public function forMcp(RestifyRequest $request, Repository $repository): self + { + return $this->filter(function (Related $related) { + // If there's an EagerField, check its repository class + if ($related->field && $related->field->repositoryClass) { + return in_array(HasMcpTools::class, class_uses_recursive($related->field->repositoryClass), true); + } + + // For string relationships (without EagerField), we need to find the repository + // This happens when relationships are defined as static::$related = ['user'] + // We'll allow these through and let the serialization handle the filtering + return true; + }); + } + public function inRequest(RestifyRequest $request, Repository $repository): self { return $this->filter(function (mixed $repositoryRelatedField, $repositoryRelatedKey) use ( @@ -167,7 +183,8 @@ public function forRequest(RestifyRequest $request, Repository $repository): sel ->authorized($request) ->inRequest($request, $repository) ->when($request->isShowRequest(), fn (self $collection) => $collection->forShow($request, $repository)) - ->when($request->isIndexRequest(), fn (self $collection) => $collection->forIndex($request, $repository)); + ->when($request->isIndexRequest(), fn (self $collection) => $collection->forIndex($request, $repository)) + ->when($repository->isForMcp(), fn (self $collection) => $collection->forIndex($request, $repository)); } public function unserialized(RestifyRequest $request, Repository $repository) diff --git a/src/Fields/BelongsToMany.php b/src/Fields/BelongsToMany.php index 6d3b8700c..5a9469620 100644 --- a/src/Fields/BelongsToMany.php +++ b/src/Fields/BelongsToMany.php @@ -5,11 +5,11 @@ use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Fields\Concerns\Attachable; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpRequest; use Binaryk\LaravelRestify\Repositories\PivotsCollection; use Binaryk\LaravelRestify\Repositories\Repository; use Closure; use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Http\Request; class BelongsToMany extends EagerField { @@ -51,8 +51,15 @@ public function resolve($repository, $attribute = null) $this->value = $paginator->map(function ($item) { try { - return $this->repositoryClass::resolveWith($item) - ->allowToShow(app(Request::class)) + /** + * @var Repository $repositoryFromClass + */ + $repositoryFromClass = $this->repositoryClass::resolveWith($item); + + return $repositoryFromClass + ->allowToShow( + $this->isForMcp() ? app(McpRequest::class) : app(RestifyRequest::class) + ) ->withPivots( PivotsCollection::make($this->pivotFields) ->map(fn (Field $field) => clone $field) diff --git a/src/Fields/EagerField.php b/src/Fields/EagerField.php index 2a8c3c56c..bd9d62797 100644 --- a/src/Fields/EagerField.php +++ b/src/Fields/EagerField.php @@ -4,6 +4,7 @@ use Binaryk\LaravelRestify\Filters\RelatedQuery; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\MCP\Requests\McpRequest; use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Traits\HasColumns; @@ -30,6 +31,8 @@ class EagerField extends Field private RelatedQuery $relatedQuery; + public bool $forMcp = false; + public function __construct($attribute, ?string $parentRepository = null) { parent::__construct(attribute: $attribute); @@ -85,7 +88,7 @@ public function resolve($repository, $attribute = null) $serializableRepository = $this->repositoryClass::resolveWith($relatedModel); $this->value = $serializableRepository - ->allowToShow($repository->request ?? app(Request::class)) + ->allowToShow($this->isForMcp() ? app(McpRequest::class) : app(RestifyRequest::class)) ->columns() ->eager($this); } catch (AuthorizationException) { @@ -160,4 +163,22 @@ public function qualifySortable(RestifyRequest $request): ?string return $table.'.attributes.'.$this->sortableColumn; } + + public function forMcp(bool|callable $forMcp = false): self + { + if (is_callable($forMcp)) { + $this->forMcp = $forMcp(); + + return $this; + } + + $this->forMcp = $forMcp; + + return $this; + } + + public function isForMcp(): bool + { + return $this->forMcp; + } } diff --git a/src/MCP/Concerns/McpActionTool.php b/src/MCP/Concerns/McpActionTool.php index af19092aa..b0ebe504e 100644 --- a/src/MCP/Concerns/McpActionTool.php +++ b/src/MCP/Concerns/McpActionTool.php @@ -63,7 +63,7 @@ public function actionTool(Action $action, array $arguments, McpActionRequest $a public static function actionToolSchema(Action $action, ToolInputSchema $schema, McpActionRequest $mcpRequest): void { - $modelName = class_basename(static::$model); + $modelName = class_basename(static::guessModelClassName()); // Add action-specific validation rules $actionRules = $action->rules(); diff --git a/src/MCP/Concerns/McpDestroyTool.php b/src/MCP/Concerns/McpDestroyTool.php index 1dab318dd..8efe5acaa 100644 --- a/src/MCP/Concerns/McpDestroyTool.php +++ b/src/MCP/Concerns/McpDestroyTool.php @@ -25,7 +25,7 @@ public function deleteTool(array $arguments, McpRequest $request): array public static function destroyToolSchema(ToolInputSchema $schema): void { $key = static::uriKey(); - $modelName = class_basename(static::$model); + $modelName = class_basename(static::guessModelClassName()); $schema->string('id') ->description("The ID of the $modelName to delete") diff --git a/src/MCP/Concerns/McpGetterTool.php b/src/MCP/Concerns/McpGetterTool.php index fca0ae610..bffe45a20 100644 --- a/src/MCP/Concerns/McpGetterTool.php +++ b/src/MCP/Concerns/McpGetterTool.php @@ -50,7 +50,7 @@ public function getterTool(Getter $getter, array $arguments, McpGetterRequest $g public static function getterToolSchema(Getter $getter, ToolInputSchema $schema, McpGetterRequest $mcpRequest): void { - $modelName = class_basename(static::$model); + $modelName = class_basename(static::guessModelClassName()); // Add getter-specific validation rules if the getter has a rules method if (method_exists($getter, 'rules')) { diff --git a/src/MCP/Concerns/McpShowTool.php b/src/MCP/Concerns/McpShowTool.php index 379b63aeb..2bd248446 100644 --- a/src/MCP/Concerns/McpShowTool.php +++ b/src/MCP/Concerns/McpShowTool.php @@ -36,7 +36,7 @@ public function showTool(array $arguments, McpRequest $request): array public static function showToolSchema(ToolInputSchema $schema): void { - $modelName = class_basename(static::$model); + $modelName = class_basename(static::guessModelClassName()); $schema->string('id') ->description("The ID of the $modelName to retrieve") diff --git a/src/MCP/Concerns/McpStoreTool.php b/src/MCP/Concerns/McpStoreTool.php index c6a9d54bb..28db98c16 100644 --- a/src/MCP/Concerns/McpStoreTool.php +++ b/src/MCP/Concerns/McpStoreTool.php @@ -21,7 +21,7 @@ public function storeTool(array $arguments, McpRequest $request): array public static function storeToolSchema(ToolInputSchema $schema): void { - $repository = static::resolveWith(app(static::$model)); + $repository = static::resolveWith(static::newModel()); $repository->collectFields($request = app(McpRequest::class)) ->forStore($request, $repository) diff --git a/src/MCP/Concerns/McpUpdateTool.php b/src/MCP/Concerns/McpUpdateTool.php index fe107355a..edfc554c4 100644 --- a/src/MCP/Concerns/McpUpdateTool.php +++ b/src/MCP/Concerns/McpUpdateTool.php @@ -25,7 +25,7 @@ public function updateTool(array $arguments, McpRequest $request): array public static function updateToolSchema(ToolInputSchema $schema): void { $key = static::uriKey(); - $modelName = class_basename(static::$model); + $modelName = class_basename(static::guessModelClassName()); $schema->string('id') ->description("The ID of the $modelName to update") diff --git a/src/MCP/Tools/Operations/ActionTool.php b/src/MCP/Tools/Operations/ActionTool.php index ec98b4631..47c65c3f4 100644 --- a/src/MCP/Tools/Operations/ActionTool.php +++ b/src/MCP/Tools/Operations/ActionTool.php @@ -34,7 +34,7 @@ public function description(): string { $repositoryUriKey = $this->repository->uriKey(); $actionName = $this->action->name(); - $modelName = class_basename($this->repository::$model); + $modelName = class_basename($this->repository::guessModelClassName()); if ($this->action->isStandalone()) { return "Execute {$actionName} action (standalone - no models required) in the {$repositoryUriKey} repository."; diff --git a/src/MCP/Tools/Operations/DeleteTool.php b/src/MCP/Tools/Operations/DeleteTool.php index e043e7229..52859bb7f 100644 --- a/src/MCP/Tools/Operations/DeleteTool.php +++ b/src/MCP/Tools/Operations/DeleteTool.php @@ -27,7 +27,7 @@ public function name(): string public function description(): string { $uriKey = $this->repository->uriKey(); - $modelName = class_basename($this->repository::$model); + $modelName = class_basename($this->repository::guessModelClassName()); return "Delete an existing {$modelName} record by ID from the {$uriKey} repository."; } diff --git a/src/MCP/Tools/Operations/GetterTool.php b/src/MCP/Tools/Operations/GetterTool.php index 55d9707e8..08436604a 100644 --- a/src/MCP/Tools/Operations/GetterTool.php +++ b/src/MCP/Tools/Operations/GetterTool.php @@ -34,7 +34,7 @@ public function description(): string { $repositoryUriKey = $this->repository->uriKey(); $getterName = $this->getter->name(); - $modelName = class_basename($this->repository::$model); + $modelName = class_basename($this->repository::guessModelClassName()); // Check if it's primarily a show getter or index getter $mcpRequest = app(McpGetterRequest::class); diff --git a/src/MCP/Tools/Operations/IndexTool.php b/src/MCP/Tools/Operations/IndexTool.php index 0ab50e73b..a005a2cc9 100644 --- a/src/MCP/Tools/Operations/IndexTool.php +++ b/src/MCP/Tools/Operations/IndexTool.php @@ -28,7 +28,7 @@ public function name(): string public function description(): string { $uriKey = $this->repository->uriKey(); - $modelName = class_basename($this->repository::$model); + $modelName = class_basename($this->repository::guessModelClassName()); return "Retrieve a paginated list of {$modelName} records from the {$uriKey} repository with filtering, sorting, and search capabilities."; } diff --git a/src/MCP/Tools/Operations/ProfileTool.php b/src/MCP/Tools/Operations/ProfileTool.php index d50048365..bab037d50 100644 --- a/src/MCP/Tools/Operations/ProfileTool.php +++ b/src/MCP/Tools/Operations/ProfileTool.php @@ -29,7 +29,7 @@ public function name(): string public function description(): string { - $modelName = class_basename($this->repository::$model); + $modelName = class_basename($this->repository::guessModelClassName()); return "Get the current authenticated user profile including {$modelName} and relationship information."; } diff --git a/src/MCP/Tools/Operations/ShowTool.php b/src/MCP/Tools/Operations/ShowTool.php index 2a3b5cd9d..094b43745 100644 --- a/src/MCP/Tools/Operations/ShowTool.php +++ b/src/MCP/Tools/Operations/ShowTool.php @@ -28,7 +28,7 @@ public function name(): string public function description(): string { $uriKey = $this->repository->uriKey(); - $modelName = class_basename($this->repository::$model); + $modelName = class_basename($this->repository::guessModelClassName()); return "Retrieve a single {$modelName} record by ID from the {$uriKey} repository with optional relationship loading."; } diff --git a/src/MCP/Tools/Operations/StoreTool.php b/src/MCP/Tools/Operations/StoreTool.php index c01564249..24f9f5260 100644 --- a/src/MCP/Tools/Operations/StoreTool.php +++ b/src/MCP/Tools/Operations/StoreTool.php @@ -27,7 +27,7 @@ public function name(): string public function description(): string { $uriKey = $this->repository->uriKey(); - $modelName = class_basename($this->repository::$model); + $modelName = class_basename($this->repository::guessModelClassName()); return "Create a new {$modelName} record in the {$uriKey} repository with the provided data."; } diff --git a/src/MCP/Tools/Operations/UpdateTool.php b/src/MCP/Tools/Operations/UpdateTool.php index d4fe14f5c..4b182a856 100644 --- a/src/MCP/Tools/Operations/UpdateTool.php +++ b/src/MCP/Tools/Operations/UpdateTool.php @@ -27,7 +27,7 @@ public function name(): string public function description(): string { $uriKey = $this->repository->uriKey(); - $modelName = class_basename($this->repository::$model); + $modelName = class_basename($this->repository::guessModelClassName()); return "Update an existing {$modelName} record by ID in the {$uriKey} repository with the provided data."; } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index e9d0036c0..4fdc2cd54 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -5,6 +5,7 @@ use Binaryk\LaravelRestify\Actions\Action; use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Eager\Related; +use Binaryk\LaravelRestify\Eager\RelatedCollection; use Binaryk\LaravelRestify\Exceptions\InstanceOfException; use Binaryk\LaravelRestify\Fields\BelongsToMany; use Binaryk\LaravelRestify\Fields\EagerField; @@ -107,7 +108,7 @@ class Repository implements JsonSerializable, RestifySearchable */ public Model $resource; - // public RestifyRequest $request; + public bool $forMcp = false; /** * The list of relations available for the show or index. @@ -558,6 +559,8 @@ public function resolveIndexPivots(RestifyRequest $request): array */ public function resolveRelationships($request): array { + $this->forMcp($request instanceof McpRequest); + if (! $request->related()->hasRelated()) { return []; } @@ -566,6 +569,7 @@ public function resolveRelationships($request): array ->forRequest($request, $this) ->mapIntoRelated($request, $this) ->unserialized($request, $this) + ->when($this->isForMcp(), fn (RelatedCollection $collection) => $collection->forMcp($request, $this)) ->map(fn (Related $related) => $related->resolve($request, $this)->getValue()) ->map(function (mixed $items) { if ($items instanceof Collection) { @@ -608,7 +612,6 @@ public function resolveIndexRelationships($request) public function indexAsArray(RestifyRequest $request): array { // Preserve the request instance for the entire flow - // $this->request = $request; // Check if the model was set under the repository throw_if( @@ -627,7 +630,6 @@ public function indexAsArray(RestifyRequest $request): array $items = $this->indexCollection($request, $paginator->getCollection())->map(function ($value) { $repository = static::resolveWith($value); // Ensure each resolved repository maintains the original request - // $repository->request = $request; return $repository; })->filter(function (self $repository) use ($request) { @@ -1151,7 +1153,9 @@ protected function getId(RestifyRequest $request): ?string public function jsonSerialize() { return $this->serializeForShow( - $this->request ?? app(RestifyRequest::class) + $this->isForMcp() + ? app(McpRequest::class) + : app(RestifyRequest::class) ); } @@ -1209,6 +1213,7 @@ public function eager(?EagerField $field = null): Repository return $this; } + $this->forMcp($field->isForMcp()); $this->eagerState = $field->queryKeyThatRendered(); $this->columns($field->getColumns()); @@ -1283,4 +1288,16 @@ protected function ensureResourceExists(): self return $this; } + + public function forMcp($forMcp = true): self + { + $this->forMcp = $forMcp; + + return $this; + } + + public function isForMcp(): bool + { + return $this->forMcp; + } } diff --git a/src/Services/Search/RepositorySearchService.php b/src/Services/Search/RepositorySearchService.php index 78706b5dd..514d02723 100644 --- a/src/Services/Search/RepositorySearchService.php +++ b/src/Services/Search/RepositorySearchService.php @@ -23,14 +23,15 @@ public function search(RestifyRequest $request, Repository $repository): Builder $this->repository = $repository; $scoutQuery = null; + $shouldUseScout = $this->isScoutAvailable($repository); - if ($repository::usesScout()) { + if ($shouldUseScout) { $scoutQuery = $this->initializeQueryUsingScout($request, $repository); } $query = $this->prepareMatchFields( $request, - $repository::usesScout() + $shouldUseScout ? $this->prepareRelations($request, $scoutQuery ?? $repository::query($request)) : $this->prepareSearchFields( $request, @@ -101,11 +102,6 @@ public function prepareRelations(RestifyRequest $request, Builder|Relation $quer true, ))->filter(function ($relation) use ($query) { try { - if ($relation === 'target') { - ray($query->getRelation($relation)); - ray($query->getRelation($relation) instanceof Relation); - } - return $query->getRelation($relation) instanceof Relation; } catch (Throwable) { return false; @@ -168,20 +164,25 @@ protected function applyIndexQuery(RestifyRequest $request, Repository $reposito public function initializeQueryUsingScout(RestifyRequest $request, Repository $repository): Builder { - /** - * @var Collection $keys - */ - $keys = tap( - is_null($request->input('search')) ? $repository::newModel() : $repository::newModel()->search($request->input('search')), - function ($scoutBuilder) use ($repository, $request) { - return $repository::scoutQuery($request, $scoutBuilder); - } - )->take($repository::$scoutSearchResults)->get()->map->getKey(); - - return $repository::newModel()->newQuery()->whereIn( - $repository::newModel()->getQualifiedKeyName(), - $keys->all() - ); + try { + /** + * @var Collection $keys + */ + $keys = tap( + is_null($request->input('search')) ? $repository::newModel() : $repository::newModel()->search($request->input('search')), + function ($scoutBuilder) use ($repository, $request) { + return $repository::scoutQuery($request, $scoutBuilder); + } + )->take($repository::$scoutSearchResults)->get()->map->getKey(); + + return $repository::newModel()->newQuery()->whereIn( + $repository::newModel()->getQualifiedKeyName(), + $keys->all() + ); + } catch (\Exception $e) { + // Scout operation failed, fall back to database search + return $repository::query($request); + } } protected function applyMainQuery(RestifyRequest $request, Repository $repository): callable @@ -226,6 +227,35 @@ protected function applyGroupBy(RestifyRequest $request, Repository $repository, return $query; } + /** + * Check if Scout is available and properly configured for the given repository. + */ + protected function isScoutAvailable(Repository $repository): bool + { + // First check if the model uses Scout at all + if (! $repository::usesScout()) { + return false; + } + + try { + // Check if Scout service is bound in the container + if (! app()->bound('Laravel\Scout\EngineManager')) { + return false; + } + + // Try to get the Scout engine - this will fail if driver is not properly configured + $engine = app('Laravel\Scout\EngineManager')->engine(); + + // Basic connectivity test - try to create a search builder (this is lightweight) + $repository::newModel()->search(''); + + return true; + } catch (\Exception $e) { + // Scout is not available or misconfigured + return false; + } + } + public static function make(): static { return new static; diff --git a/tests/MCP/McpFieldsIntegrationTest.php b/tests/MCP/McpFieldsIntegrationTest.php index 3d34348c0..e058455cf 100644 --- a/tests/MCP/McpFieldsIntegrationTest.php +++ b/tests/MCP/McpFieldsIntegrationTest.php @@ -2,6 +2,7 @@ namespace Binaryk\LaravelRestify\Tests\MCP; +use Binaryk\LaravelRestify\Fields\BelongsTo; use Binaryk\LaravelRestify\Fields\Field; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools; @@ -11,6 +12,7 @@ use Binaryk\LaravelRestify\Restify; use Binaryk\LaravelRestify\Tests\Database\Factories\PostFactory; use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post; +use Binaryk\LaravelRestify\Tests\Fixtures\User\User; use Binaryk\LaravelRestify\Tests\IntegrationTestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Mcp\Server\Facades\Mcp; @@ -239,9 +241,11 @@ public function mcpAllowsIndex(): bool // Find our expected tool name $availableTools = collect($toolsData['result']['tools'])->pluck('name')->toArray(); - $indexToolName = collect($availableTools)->filter(fn ($name) => str_contains($name, 'test-posts') && str_contains($name, 'index'))->first(); + $indexToolName = collect($availableTools)->filter(fn ($name) => str_contains($name, + 'test-posts') && str_contains($name, 'index'))->first(); - $this->assertNotNull($indexToolName, 'Expected test-posts index tool not found. Available tools: '.implode(', ', $availableTools)); + $this->assertNotNull($indexToolName, + 'Expected test-posts index tool not found. Available tools: '.implode(', ', $availableTools)); // Create MCP JSON-RPC 2.0 request payload for calling the index tool $mcpPayload = [ @@ -300,4 +304,315 @@ public function mcpAllowsIndex(): bool $this->assertArrayHasKey('description', $attributes); $this->assertArrayHasKey('user_id', $attributes); } + + public function test_mcp_http_integration_with_relationships_uses_mcp_specific_fields(): void + { + // Create simple MCP-enabled Post repository + $mcpPostRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'test-posts-with-user'; + + public static function include(): array + { + return [ + BelongsTo::make('user', UserWithMcpIndexFields::class), + ]; + } + + public static array $related = ['user']; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title'), + Field::make('description'), + Field::make('user_id'), + ]; + } + + public function fieldsForMcpIndex(RestifyRequest $request): array + { + return [ + Field::make('title'), + Field::make('description'), + Field::make('user_id'), + Field::make('mcp_post_metadata')->resolveCallback(fn () => 'post-mcp-specific-data'), + Field::make('post_analytics')->resolveCallback(fn () => 'post-analytics-data'), + BelongsTo::make('user'), // Will use the MCP-enabled UserRepository + ]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + }; + + // Register both repositories with Restify, replacing the existing UserRepository + Restify::repositories([ + UserWithMcpIndexFields::class, + $mcpPostRepository::class, + ]); + + // Register MCP server route + Mcp::web('test-restify-relations', RestifyServer::class); + + // Create test data with relationships + $user = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $post = Post::factory()->create([ + 'user_id' => $user->id, + 'title' => 'Test Post with User', + 'description' => 'A post that belongs to a user', + ]); + + // First, get available tools + $toolsListPayload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ]; + + $toolsResponse = $this->postJson('/test-restify-relations', $toolsListPayload); + $toolsResponse->assertOk(); + + $toolsData = $toolsResponse->json(); + + // Find the post index tool name + $availableTools = collect($toolsData['result']['tools'])->pluck('name')->toArray(); + $postIndexToolName = collect($availableTools)->filter( + fn ($name) => str_contains($name, 'test-posts-with-user') && str_contains($name, 'index') + )->first(); + + $this->assertNotNull($postIndexToolName, + 'Expected test-posts-with-user index tool not found. Available tools: '.implode(', ', $availableTools)); + + // Create MCP request with relationship inclusion + $mcpPayload = [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $postIndexToolName, + 'arguments' => [ + 'perPage' => 10, + 'related' => 'user', + ], + ], + ]; + + // Make HTTP POST request to MCP endpoint + $response = $this->postJson('/test-restify-relations', $mcpPayload); + $response->assertOk(); + + $responseData = $response->json(); + + // Check for errors + if (isset($responseData['error'])) { + $this->fail('MCP Error: '.$responseData['error']['message']); + } + + // Assert JSON-RPC response structure + $this->assertArrayHasKey('result', $responseData); + + // Parse the result content + $resultContent = json_decode($responseData['result']['content'][0]['text'], true); + + $this->assertArrayHasKey('data', $resultContent); + $this->assertNotEmpty($resultContent['data']); + + $firstItem = $resultContent['data'][0]; + + // Assert Post MCP-specific fields + $attributes = $firstItem['attributes']; + $this->assertArrayHasKey('mcp_post_metadata', $attributes); + $this->assertArrayHasKey('post_analytics', $attributes); + $this->assertEquals('post-mcp-specific-data', $attributes['mcp_post_metadata']); + $this->assertEquals('post-analytics-data', $attributes['post_analytics']); + + // Assert regular post fields are present + $this->assertArrayHasKey('title', $attributes); + $this->assertArrayHasKey('description', $attributes); + $this->assertArrayHasKey('user_id', $attributes); + + // Assert relationship data is present + $this->assertArrayHasKey('relationships', $firstItem); + $this->assertArrayHasKey('user', $firstItem['relationships']); + + $userRelationship = $firstItem['relationships']['user']['attributes']; + + // This would happen if the MCP-enabled anonymous repository was used + $this->assertEquals('user-mcp-specific-data', $userRelationship['user_mcp_data']); + $this->assertEquals('user-internal-123', $userRelationship['internal_user_tracking']); + $this->assertEquals('admin-access-only', $userRelationship['admin_notes']); + echo "\n✅ SUCCESS: MCP fields are working because MCP-enabled repository was found!\n"; + + // Also check basic fields are present + $this->assertArrayHasKey('name', $userRelationship); + $this->assertArrayHasKey('email', $userRelationship); + $this->assertEquals('John Doe', $userRelationship['name']); + $this->assertEquals('john@example.com', $userRelationship['email']); + $this->assertArrayNotHasKey('description', $userRelationship); + } + + public function test_mcp_relationship_respects_has_mcp_tools_trait(): void + { + // Create MCP-enabled Post repository that references the regular user + $mcpPostRepository = new class extends Repository + { + use HasMcpTools; + + public static $model = Post::class; + + public static string $uriKey = 'test-posts-regular-user'; + + public static function include(): array + { + return [ + BelongsTo::make('user', UserWithoutMcpToolsFields::class), + ]; + } + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('title'), + Field::make('description'), + ]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } + }; + + // Register repositories - the regular user repository should be found first for User models + Restify::repositories([ + UserWithoutMcpToolsFields::class, + $mcpPostRepository::class, + ]); + + Mcp::web('test-regular-relations', RestifyServer::class); + + // Create test data + $user = User::factory()->create([ + 'name' => 'Regular User', + 'email' => 'regular@example.com', + ]); + + $post = Post::factory()->create([ + 'user_id' => $user->id, + 'title' => 'Post with Regular User', + 'description' => 'This post belongs to a regular user repository', + ]); + + // Get available tools + $toolsResponse = $this->postJson('/test-regular-relations', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [], + ]); + $toolsResponse->assertOk(); + + $toolsData = $toolsResponse->json(); + $availableTools = collect($toolsData['result']['tools'])->pluck('name')->toArray(); + $postIndexToolName = collect($availableTools)->filter( + fn ($name) => str_contains($name, 'test-posts-regular-user') && str_contains($name, 'index') + )->first(); + + $this->assertNotNull($postIndexToolName); + + // Make MCP request + $response = $this->postJson('/test-regular-relations', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $postIndexToolName, + 'arguments' => [ + 'perPage' => 10, + 'related' => 'user', + ], + ], + ]); + + $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->assertArrayHasKey('data', $resultContent); + $this->assertNotEmpty($resultContent['data']); + + $firstItem = $resultContent['data'][0]; + + $this->assertArrayNotHasKey('relationships', $firstItem); + } +} + +class UserWithMcpIndexFields extends Repository +{ + use HasMcpTools; + + public static $model = User::class; + + public static string $uriKey = 'users'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('name'), + Field::make('email'), + ]; + } + + public function fieldsForMcpIndex(RestifyRequest $request): array + { + return [ + Field::make('name'), + Field::make('email'), + Field::make('user_mcp_data')->resolveCallback(fn () => 'user-mcp-specific-data'), + Field::make('internal_user_tracking')->resolveCallback(fn () => 'user-internal-123'), + Field::make('admin_notes')->resolveCallback(fn () => 'admin-access-only'), + ]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } +} + +class UserWithoutMcpToolsFields extends Repository +{ + public static $model = User::class; + + public static string $uriKey = 'users'; + + public function fields(RestifyRequest $request): array + { + return [ + Field::make('name'), + Field::make('email'), + ]; + } + + public function mcpAllowsIndex(): bool + { + return true; + } }