diff --git a/app/Http/Controllers/McpController.php b/app/Http/Controllers/McpController.php index c61ebb92..19b344e1 100644 --- a/app/Http/Controllers/McpController.php +++ b/app/Http/Controllers/McpController.php @@ -2,15 +2,18 @@ namespace App\Http\Controllers; +use App\Http\Requests\McpPluginSearchRequest; use App\Http\Requests\McpSearchRequest; use App\Services\DocsSearchService; +use App\Services\PluginSearchService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class McpController extends Controller { public function __construct( - protected DocsSearchService $docsSearch + protected DocsSearchService $docsSearch, + protected PluginSearchService $pluginSearch, ) {} /** @@ -28,7 +31,10 @@ public function message(Request $request): JsonResponse 'notifications/initialized' => new \stdClass, 'ping' => new \stdClass, 'tools/list' => ['tools' => $this->getToolDefinitions()], - 'tools/call' => $this->handleToolCall($params['name'] ?? '', $params['arguments'] ?? []), + 'tools/call' => $this->handleToolCall( + $params['name'] ?? '', + $params['arguments'] ?? [], + ), default => throw new \InvalidArgumentException("Unknown method: {$method}"), }; @@ -105,6 +111,30 @@ public function navigationApi(string $platform, string $version): JsonResponse return response()->json(['navigation' => $nav]); } + public function pluginsSearchApi(McpPluginSearchRequest $request): JsonResponse + { + $validated = $request->validated(); + + $results = $this->pluginSearch->search( + $validated['q'], + $validated['type'] ?? null, + $validated['limit'] ?? PluginSearchService::DEFAULT_LIMIT, + ); + + return response()->json(['plugins' => $results]); + } + + public function pluginShowApi(string $vendor, string $package): JsonResponse + { + $plugin = $this->pluginSearch->getByVendorPackage($vendor, $package); + + if (! $plugin) { + return response()->json(['error' => 'Plugin not found'], 404); + } + + return response()->json(['plugin' => $plugin]); + } + protected function getToolDefinitions(): array { $latestVersions = $this->docsSearch->getLatestVersions(); @@ -189,6 +219,50 @@ protected function getToolDefinitions(): array 'required' => ['platform', 'version'], ], ], + [ + 'name' => 'search_plugins', + 'description' => 'Search the NativePHP plugin marketplace for approved, publicly listed plugins. Returns composer package names, free/paid type, price, and marketplace URLs.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'query' => [ + 'type' => 'string', + 'description' => 'Search query matched against plugin name and description (e.g., "camera", "push notifications")', + ], + 'type' => [ + 'type' => 'string', + 'enum' => ['free', 'paid'], + 'description' => 'Filter by marketplace type (optional)', + ], + 'limit' => [ + 'type' => 'number', + 'description' => 'Max results to return (default: 10, max: 25)', + ], + ], + 'required' => ['query'], + ], + ], + [ + 'name' => 'get_plugin', + 'description' => 'Get details for one marketplace plugin by composer name (vendor/package) or vendor + package path args.', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'Composer package name, e.g. "nativephp/camera"', + ], + 'vendor' => [ + 'type' => 'string', + 'description' => 'Composer vendor segment (use with package)', + ], + 'package' => [ + 'type' => 'string', + 'description' => 'Composer package segment (use with vendor)', + ], + ], + ], + ], ]; } @@ -213,6 +287,8 @@ protected function handleToolCall(string $name, array $args): array 'get_page' => $this->toolGetPage($args), 'list_apis' => $this->toolListApis($args), 'get_navigation' => $this->toolGetNavigation($args), + 'search_plugins' => $this->toolSearchPlugins($args), + 'get_plugin' => $this->toolGetPlugin($args), default => [ 'content' => [['type' => 'text', 'text' => "Unknown tool: {$name}"]], 'isError' => true, @@ -321,4 +397,133 @@ protected function toolGetNavigation(array $args): array 'content' => [['type' => 'text', 'text' => "# {$platform} v{$version} Navigation\n\n{$formatted}"]], ]; } + + protected function toolSearchPlugins(array $args): array + { + $query = (string) ($args['query'] ?? ''); + $type = isset($args['type']) ? (string) $args['type'] : null; + $limit = isset($args['limit']) ? (int) $args['limit'] : PluginSearchService::DEFAULT_LIMIT; + + if ($type !== null && ! in_array($type, ['free', 'paid'], true)) { + return [ + 'content' => [['type' => 'text', 'text' => 'Invalid type. Use "free" or "paid".']], + 'isError' => true, + ]; + } + + $results = $this->pluginSearch->search($query, $type, $limit); + + if (empty($results)) { + $filterDesc = $type ? " (type: {$type})" : ''; + + return [ + 'content' => [['type' => 'text', 'text' => "No marketplace plugins found for \"{$query}\"{$filterDesc}"]], + ]; + } + + $formatted = collect($results)->map(function (array $plugin, int $i): string { + return ($i + 1).'. '.$this->formatPluginResultText($plugin, detailed: false); + })->join("\n\n"); + + return [ + 'content' => [['type' => 'text', 'text' => 'Found '.count($results)." marketplace plugins for \"{$query}\":\n\n{$formatted}"]], + ]; + } + + protected function toolGetPlugin(array $args): array + { + $name = isset($args['name']) ? (string) $args['name'] : ''; + $vendor = isset($args['vendor']) ? (string) $args['vendor'] : ''; + $package = isset($args['package']) ? (string) $args['package'] : ''; + + $plugin = null; + + if ($name !== '') { + $plugin = $this->pluginSearch->getByName($name); + $lookup = $name; + } elseif ($vendor !== '' && $package !== '') { + $plugin = $this->pluginSearch->getByVendorPackage($vendor, $package); + $lookup = "{$vendor}/{$package}"; + } else { + return [ + 'content' => [['type' => 'text', 'text' => 'Provide either name (vendor/package) or both vendor and package.']], + 'isError' => true, + ]; + } + + if (! $plugin) { + return [ + 'content' => [['type' => 'text', 'text' => "Plugin not found: {$lookup}"]], + 'isError' => true, + ]; + } + + $text = "# {$plugin['name']}\n\n"; + if ($plugin['description']) { + $text .= "{$plugin['description']}\n\n"; + } + $text .= $this->formatPluginResultText($plugin, detailed: true); + + return [ + 'content' => [['type' => 'text', 'text' => $text]], + ]; + } + + /** + * @param array $plugin + */ + protected function formatPluginResultText(array $plugin, bool $detailed): string + { + $lines = []; + + if (! $detailed) { + $lines[] = "**{$plugin['name']}**"; + } + + if ($plugin['type'] === 'paid') { + $lines[] = 'Type: paid'; + $lines[] = 'Price: '.($plugin['price'] ?: 'paid (price not listed)'); + $lines[] = "Marketplace: {$plugin['marketplace_url']}"; + } else { + $lines[] = 'Type: free'; + $lines[] = "Marketplace: {$plugin['marketplace_url']}"; + } + + if ($detailed) { + if ($plugin['latest_version']) { + $lines[] = "Latest version: {$plugin['latest_version']}"; + } + + $flags = collect([ + $plugin['featured'] ? 'featured' : null, + $plugin['is_official'] ? 'official' : null, + $plugin['works_in_jump'] ? 'works in Jump' : null, + ])->filter()->implode(', '); + + if ($flags !== '') { + $lines[] = "Flags: {$flags}"; + } + + if (! empty($plugin['repository_url'])) { + $lines[] = "Repository: {$plugin['repository_url']}"; + } + + if (! empty($plugin['packagist_url'])) { + $lines[] = "Packagist: {$plugin['packagist_url']}"; + } + } else { + if ($plugin['description']) { + $lines[] = $plugin['description']; + } + if ($plugin['latest_version']) { + $lines[] = "Latest: {$plugin['latest_version']}"; + } + } + + if ($detailed) { + return implode("\n", $lines); + } + + return implode("\n ", $lines); + } } diff --git a/app/Http/Requests/McpPluginSearchRequest.php b/app/Http/Requests/McpPluginSearchRequest.php new file mode 100644 index 00000000..dd64cb07 --- /dev/null +++ b/app/Http/Requests/McpPluginSearchRequest.php @@ -0,0 +1,32 @@ + ['required', 'string', 'max:500'], + 'type' => ['nullable', 'string', Rule::in(['free', 'paid'])], + 'limit' => ['nullable', 'integer', 'min:1', 'max:'.PluginSearchService::MAX_LIMIT], + ]; + } + + public function messages(): array + { + return [ + 'type.in' => 'Type must be either free or paid.', + 'limit.max' => 'Limit cannot exceed '.PluginSearchService::MAX_LIMIT.'.', + ]; + } +} diff --git a/app/Services/PluginSearchService.php b/app/Services/PluginSearchService.php new file mode 100644 index 00000000..2de6c15f --- /dev/null +++ b/app/Services/PluginSearchService.php @@ -0,0 +1,206 @@ +> + */ + public function search(string $query, ?string $type = null, int $limit = self::DEFAULT_LIMIT): array + { + $limit = min(max(1, $limit), self::MAX_LIMIT); + $type = $this->sanitizeType($type); + $query = trim($query); + + if ($query === '') { + return []; + } + + $plugins = $this->publicDirectoryQuery() + ->with(['prices' => fn ($q) => $q->active()]) + ->when($type !== null, fn (Builder $q) => $q->where('type', $type)) + ->where(function (Builder $q) use ($query): void { + $q->where('name', 'like', "%{$query}%") + ->orWhere('description', 'like', "%{$query}%"); + }) + ->orderByDesc('featured') + ->latest() + ->limit(max($limit * 5, 50)) + ->get(); + + return $plugins + ->sort(function (Plugin $a, Plugin $b) use ($query): int { + $scoreCompare = $this->matchScore($b, $query) <=> $this->matchScore($a, $query); + if ($scoreCompare !== 0) { + return $scoreCompare; + } + + $featuredCompare = ((int) $b->featured) <=> ((int) $a->featured); + if ($featuredCompare !== 0) { + return $featuredCompare; + } + + return $b->created_at <=> $a->created_at; + }) + ->take($limit) + ->values() + ->map(fn (Plugin $plugin) => $this->toSummary($plugin)) + ->all(); + } + + /** + * Fetch one publicly visible marketplace plugin by composer name. + * + * @return array|null + */ + public function getByName(string $name): ?array + { + $name = trim($name); + + if ($name === '' || ! str_contains($name, '/')) { + return null; + } + + [$vendor, $package] = array_pad(explode('/', $name, 2), 2, ''); + + return $this->getByVendorPackage($vendor, $package); + } + + /** + * Fetch one publicly visible marketplace plugin by vendor/package path. + * + * @return array|null + */ + public function getByVendorPackage(string $vendor, string $package): ?array + { + $vendor = $this->sanitizePathSegment($vendor); + $package = $this->sanitizePathSegment($package); + + if ($vendor === null || $package === null) { + return null; + } + + $plugin = $this->publicDirectoryQuery() + ->with(['prices' => fn ($q) => $q->active()]) + ->where('name', "{$vendor}/{$package}") + ->first(); + + if (! $plugin) { + return null; + } + + return $this->toDetail($plugin); + } + + /** + * Same visibility as the public marketplace directory: approved + active. + * + * @return Builder + */ + protected function publicDirectoryQuery(): Builder + { + return Plugin::query()->approved(); + } + + /** + * @return array + */ + protected function toSummary(Plugin $plugin): array + { + return [ + 'name' => $plugin->name, + 'description' => $plugin->description, + 'type' => $plugin->type?->value ?? (string) $plugin->type, + 'price' => $this->formatPublicPrice($plugin), + 'featured' => (bool) $plugin->featured, + 'is_official' => $plugin->isOfficial(), + 'works_in_jump' => $plugin->worksInJump(), + 'latest_version' => $plugin->latest_version, + 'marketplace_url' => route('plugins.show', $plugin->routeParams(), absolute: true), + ]; + } + + /** + * @return array + */ + protected function toDetail(Plugin $plugin): array + { + return array_merge($this->toSummary($plugin), [ + 'repository_url' => $plugin->getGithubUrl(), + 'packagist_url' => $plugin->isFree() ? $plugin->getPackagistUrl() : null, + ]); + } + + protected function formatPublicPrice(Plugin $plugin): ?string + { + if ($plugin->isFree()) { + return null; + } + + $regular = $plugin->prices->first(fn ($price) => $price->tier === PriceTier::Regular) + ?? $plugin->getRegularPrice(); + + if (! $regular) { + return null; + } + + return '$'.$regular->formatted_amount; + } + + protected function matchScore(Plugin $plugin, string $query): int + { + $queryLower = mb_strtolower($query); + $name = mb_strtolower((string) $plugin->name); + $description = mb_strtolower((string) $plugin->description); + + $score = 0; + + if ($name === $queryLower) { + $score += 100; + } elseif (str_contains($name, $queryLower)) { + $score += 50; + } + + if (str_contains($description, $queryLower)) { + $score += 10; + } + + return $score; + } + + protected function sanitizeType(?string $type): ?string + { + if ($type === null || $type === '') { + return null; + } + + return PluginType::tryFrom($type)?->value; + } + + protected function sanitizePathSegment(string $segment): ?string + { + $segment = trim($segment); + + if ($segment === '' || str_contains($segment, '..') || str_contains($segment, '/') || str_contains($segment, '\\')) { + return null; + } + + if (! preg_match('/^[A-Za-z0-9_.-]+$/', $segment)) { + return null; + } + + return $segment; + } +} diff --git a/resources/views/mcp-content.md b/resources/views/mcp-content.md index 54497f19..d50e9e8e 100644 --- a/resources/views/mcp-content.md +++ b/resources/views/mcp-content.md @@ -125,6 +125,27 @@ Mobile v1 and v2 docs — from v3 onwards the native APIs are documented under Plugins, and the Desktop docs have no `apis` section at all. For anything current, use `get_navigation` or `search_docs` instead. +### `search_plugins` + +Search the public plugin marketplace the same way the directory does: approved, +active plugins only. Takes a required `query` matched against package name and +description, plus optional `type` (`free` or `paid`) and `limit` (10 by default, +25 max). Results include the composer name, short description, free/paid type, +regular price when listed, useful flags (featured / official / works in Jump), +latest version when known, and a **Marketplace:** URL you can open. + +Use this before inventing a capability — if a camera, biometrics, or payments +plugin already exists, your agent should find it here. For paid plugins, the +marketplace URL is always included so a human can decide whether to buy. + +### `get_plugin` + +Fetch one marketplace plugin by composer `name` (`vendor/package`), or by +`vendor` + `package` path args. Returns richer detail than search: description, +type, price, repository URL when public, Packagist URL for free plugins, +marketplace URL, latest version, and flags. Unapproved, inactive, or unknown +packages come back as a not-found error. + ## Reading pages without MCP Every docs page is served as raw markdown by adding `.md` to its URL, which is @@ -141,6 +162,8 @@ MCP client: - `/api/mcp/page/{platform}/{version}/{section}/{slug}` — a single page - `/api/mcp/navigation/{platform}/{version}` — the docs navigation tree - `/api/mcp/apis/{platform}/{version}` — the `apis` section listing +- `/api/mcp/plugins?q=camera&type=free&limit=10` — marketplace plugin search +- `/api/mcp/plugins/{vendor}/{package}` — one marketplace plugin - `/api/mcp/health` — liveness check, and the versions currently published Both the MCP and REST endpoints are rate limited to 60 requests per minute per diff --git a/routes/api.php b/routes/api.php index e4f270a9..49db610e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -32,6 +32,11 @@ ->name('mcp.api.page'); Route::get('apis/{platform}/{version}', [McpController::class, 'apisApi'])->name('mcp.api.apis'); Route::get('navigation/{platform}/{version}', [McpController::class, 'navigationApi'])->name('mcp.api.navigation'); + + Route::get('plugins', [McpController::class, 'pluginsSearchApi'])->name('mcp.api.plugins.search'); + Route::get('plugins/{vendor}/{package}', [McpController::class, 'pluginShowApi']) + ->where(['vendor' => '[A-Za-z0-9_.-]+', 'package' => '[A-Za-z0-9_.-]+']) + ->name('mcp.api.plugins.show'); }); Route::middleware('auth.api_key')->group(function (): void { diff --git a/tests/Feature/DocsMcpServerPageTest.php b/tests/Feature/DocsMcpServerPageTest.php index 486be834..580ac45f 100644 --- a/tests/Feature/DocsMcpServerPageTest.php +++ b/tests/Feature/DocsMcpServerPageTest.php @@ -75,7 +75,7 @@ public function the_documented_message_endpoint_lists_the_documented_tools(): vo $tools = collect($response->json('result.tools'))->pluck('name')->all(); $this->assertEqualsCanonicalizing( - ['search_docs', 'get_page', 'list_apis', 'get_navigation'], + ['search_docs', 'get_page', 'list_apis', 'get_navigation', 'search_plugins', 'get_plugin'], $tools, ); } diff --git a/tests/Feature/McpSecurityTest.php b/tests/Feature/McpSecurityTest.php index 216686f9..ebeefc65 100644 --- a/tests/Feature/McpSecurityTest.php +++ b/tests/Feature/McpSecurityTest.php @@ -2,10 +2,13 @@ namespace Tests\Feature; +use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class McpSecurityTest extends TestCase { + use RefreshDatabase; + public function test_search_rejects_path_traversal_in_platform(): void { $response = $this->getJson('/api/mcp/search?q=test&platform=..'); @@ -82,4 +85,35 @@ public function test_navigation_endpoint_rejects_invalid_version(): void $response->assertStatus(200); $response->assertJson(['navigation' => []]); } + + public function test_plugins_search_rejects_excessive_limit(): void + { + $response = $this->getJson('/api/mcp/plugins?q=test&limit=1200'); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['limit']); + } + + public function test_plugins_search_rejects_invalid_type(): void + { + $response = $this->getJson('/api/mcp/plugins?q=test&type=enterprise'); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['type']); + } + + public function test_plugins_search_accepts_valid_parameters(): void + { + $response = $this->getJson('/api/mcp/plugins?q=camera&type=free&limit=10'); + + $response->assertStatus(200); + $response->assertJsonStructure(['plugins']); + } + + public function test_plugins_show_rejects_path_traversal(): void + { + $response = $this->getJson('/api/mcp/plugins/../etc/passwd'); + + $response->assertStatus(404); + } } diff --git a/tests/Feature/PluginMcpToolsTest.php b/tests/Feature/PluginMcpToolsTest.php new file mode 100644 index 00000000..f09c457b --- /dev/null +++ b/tests/Feature/PluginMcpToolsTest.php @@ -0,0 +1,338 @@ +postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + ]); + + $response->assertOk(); + + $tools = collect($response->json('result.tools'))->pluck('name')->all(); + + $this->assertContains('search_plugins', $tools); + $this->assertContains('get_plugin', $tools); + } + + #[Test] + public function search_plugins_finds_approved_plugin_by_name_fragment(): void + { + $plugin = Plugin::factory()->approved()->free()->create([ + 'name' => 'acme/unique-camera-plugin', + 'description' => 'Native camera helpers for mobile apps.', + ]); + + Plugin::factory()->draft()->create([ + 'name' => 'acme/draft-camera-plugin', + 'description' => 'Draft camera plugin should stay hidden.', + ]); + + Plugin::factory()->pending()->create([ + 'name' => 'acme/pending-camera-plugin', + 'description' => 'Pending camera plugin should stay hidden.', + ]); + + Plugin::factory()->approved()->inactive()->create([ + 'name' => 'acme/inactive-camera-plugin', + 'description' => 'Inactive camera plugin should stay hidden.', + ]); + + $response = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'search_plugins', + 'arguments' => ['query' => 'unique-camera'], + ], + ]); + + $response->assertOk(); + + $text = $response->json('result.content.0.text'); + + $this->assertStringContainsString('acme/unique-camera-plugin', $text); + $this->assertStringContainsString('Marketplace:', $text); + $this->assertStringContainsString(route('plugins.show', $plugin->routeParams()), $text); + $this->assertStringNotContainsString('draft-camera-plugin', $text); + $this->assertStringNotContainsString('pending-camera-plugin', $text); + $this->assertStringNotContainsString('inactive-camera-plugin', $text); + $this->assertStringNotContainsString('You already have access', $text); + } + + #[Test] + public function search_plugins_filters_by_type(): void + { + Plugin::factory()->approved()->free()->create([ + 'name' => 'acme/free-biometrics', + 'description' => 'Free biometrics plugin.', + ]); + + $paid = Plugin::factory()->approved()->paid()->create([ + 'name' => 'acme/paid-biometrics', + 'description' => 'Paid biometrics plugin.', + ]); + + PluginPrice::factory()->regular()->amount(4900)->create([ + 'plugin_id' => $paid->id, + ]); + + $freeResponse = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'search_plugins', + 'arguments' => ['query' => 'biometrics', 'type' => 'free'], + ], + ]); + + $freeText = $freeResponse->json('result.content.0.text'); + $this->assertStringContainsString('acme/free-biometrics', $freeText); + $this->assertStringNotContainsString('acme/paid-biometrics', $freeText); + + $paidResponse = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'search_plugins', + 'arguments' => ['query' => 'biometrics', 'type' => 'paid'], + ], + ]); + + $paidText = $paidResponse->json('result.content.0.text'); + $this->assertStringContainsString('acme/paid-biometrics', $paidText); + $this->assertStringContainsString('Marketplace:', $paidText); + $this->assertStringContainsString(route('plugins.show', $paid->routeParams()), $paidText); + $this->assertStringContainsString('$49.00', $paidText); + $this->assertStringNotContainsString('acme/free-biometrics', $paidText); + } + + #[Test] + public function search_plugins_always_includes_marketplace_url_for_paid_plugins_even_without_price(): void + { + $paid = Plugin::factory()->approved()->paid()->create([ + 'name' => 'acme/paid-no-price', + 'description' => 'Paid plugin without a listed regular price.', + ]); + + $response = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'search_plugins', + 'arguments' => ['query' => 'paid-no-price'], + ], + ]); + + $response->assertOk(); + + $text = $response->json('result.content.0.text'); + $marketplaceUrl = route('plugins.show', $paid->routeParams()); + + $this->assertStringContainsString('acme/paid-no-price', $text); + $this->assertStringContainsString('paid', $text); + $this->assertStringContainsString('Marketplace:', $text); + $this->assertStringContainsString($marketplaceUrl, $text); + } + + #[Test] + public function get_plugin_returns_detail_for_approved_plugin(): void + { + $plugin = Plugin::factory()->approved()->free()->create([ + 'name' => 'acme/detail-plugin', + 'description' => 'A detailed free plugin.', + 'repository_url' => 'https://github.com/acme/detail-plugin', + 'latest_version' => '1.2.3', + 'works_in_jump' => true, + ]); + + $response = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get_plugin', + 'arguments' => ['name' => 'acme/detail-plugin'], + ], + ]); + + $response->assertOk(); + $this->assertArrayNotHasKey('isError', $response->json('result')); + + $text = $response->json('result.content.0.text'); + $this->assertStringContainsString('acme/detail-plugin', $text); + $this->assertStringContainsString('A detailed free plugin.', $text); + $this->assertStringContainsString('1.2.3', $text); + $this->assertStringContainsString('https://github.com/acme/detail-plugin', $text); + $this->assertStringContainsString('https://packagist.org/packages/acme/detail-plugin', $text); + $this->assertStringContainsString('Marketplace:', $text); + $this->assertStringContainsString(route('plugins.show', $plugin->routeParams()), $text); + $this->assertStringContainsString('works in Jump', $text); + } + + #[Test] + public function get_plugin_returns_marketplace_url_for_paid_plugin(): void + { + $plugin = Plugin::factory()->approved()->paid()->create([ + 'name' => 'acme/paid-detail', + 'description' => 'Paid detail plugin.', + ]); + + PluginPrice::factory()->regular()->amount(2999)->create([ + 'plugin_id' => $plugin->id, + ]); + + $response = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get_plugin', + 'arguments' => [ + 'vendor' => 'acme', + 'package' => 'paid-detail', + ], + ], + ]); + + $response->assertOk(); + + $text = $response->json('result.content.0.text'); + $marketplaceUrl = route('plugins.show', $plugin->routeParams()); + + $this->assertStringContainsString('$29.99', $text); + $this->assertStringContainsString('Marketplace:', $text); + $this->assertStringContainsString($marketplaceUrl, $text); + $this->assertStringNotContainsString('Packagist:', $text); + $this->assertArrayNotHasKey( + 'has_access', + $this->getJson('/api/mcp/plugins/acme/paid-detail')->json('plugin') + ); + } + + #[Test] + public function get_plugin_errors_for_unknown_or_unapproved_plugins(): void + { + Plugin::factory()->pending()->create([ + 'name' => 'acme/pending-only', + ]); + + $unknown = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get_plugin', + 'arguments' => ['name' => 'acme/does-not-exist'], + ], + ]); + + $this->assertTrue($unknown->json('result.isError')); + $this->assertStringContainsString('Plugin not found: acme/does-not-exist', $unknown->json('result.content.0.text')); + + $pending = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get_plugin', + 'arguments' => ['name' => 'acme/pending-only'], + ], + ]); + + $this->assertTrue($pending->json('result.isError')); + $this->assertStringContainsString('Plugin not found: acme/pending-only', $pending->json('result.content.0.text')); + } + + #[Test] + public function public_results_include_marketplace_fields_without_access_claims(): void + { + $plugin = Plugin::factory()->approved()->paid()->create([ + 'name' => 'acme/anon-plugin', + 'description' => 'Anonymous paid plugin.', + ]); + + PluginPrice::factory()->regular()->amount(1500)->create([ + 'plugin_id' => $plugin->id, + ]); + + $response = $this->getJson('/api/mcp/plugins?q=anon-plugin') + ->assertOk() + ->assertJsonPath('plugins.0.name', 'acme/anon-plugin') + ->assertJsonPath('plugins.0.price', '$15.00') + ->assertJsonPath('plugins.0.marketplace_url', route('plugins.show', $plugin->routeParams())); + + $this->assertArrayNotHasKey('has_access', $response->json('plugins.0')); + $this->assertArrayNotHasKey('your_price', $response->json('plugins.0')); + $this->assertArrayNotHasKey('access_label', $response->json('plugins.0')); + } + + #[Test] + public function rest_plugins_search_returns_expected_json_shape(): void + { + $plugin = Plugin::factory()->approved()->paid()->create([ + 'name' => 'acme/rest-paid', + 'description' => 'REST paid plugin.', + 'featured' => true, + 'latest_version' => '2.0.0', + ]); + + PluginPrice::factory()->regular()->amount(9900)->create([ + 'plugin_id' => $plugin->id, + ]); + + $response = $this->getJson('/api/mcp/plugins?q=rest-paid&type=paid&limit=5'); + + $response->assertOk() + ->assertJsonPath('plugins.0.name', 'acme/rest-paid') + ->assertJsonPath('plugins.0.type', 'paid') + ->assertJsonPath('plugins.0.price', '$99.00') + ->assertJsonPath('plugins.0.featured', true) + ->assertJsonPath('plugins.0.latest_version', '2.0.0') + ->assertJsonPath('plugins.0.marketplace_url', route('plugins.show', $plugin->routeParams())); + + $this->assertNotEmpty($response->json('plugins.0.marketplace_url')); + $this->assertArrayNotHasKey('has_access', $response->json('plugins.0')); + $this->assertArrayNotHasKey('your_price', $response->json('plugins.0')); + } + + #[Test] + public function rest_plugin_show_returns_detail_and_404_for_missing(): void + { + $plugin = Plugin::factory()->approved()->free()->create([ + 'name' => 'acme/rest-free', + 'description' => 'REST free plugin.', + 'repository_url' => 'https://github.com/acme/rest-free', + ]); + + $this->getJson('/api/mcp/plugins/acme/rest-free') + ->assertOk() + ->assertJsonPath('plugin.name', 'acme/rest-free') + ->assertJsonPath('plugin.type', 'free') + ->assertJsonPath('plugin.packagist_url', 'https://packagist.org/packages/acme/rest-free') + ->assertJsonPath('plugin.repository_url', 'https://github.com/acme/rest-free') + ->assertJsonPath('plugin.marketplace_url', route('plugins.show', $plugin->routeParams())); + + $this->getJson('/api/mcp/plugins/acme/missing') + ->assertNotFound() + ->assertJsonPath('error', 'Plugin not found'); + } +}