From e60f277e5428ef6fe71e3308987f616b8ce061ec Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Thu, 10 Sep 2026 13:09:00 -0400 Subject: [PATCH 1/3] Add marketplace plugin discovery tools to the docs MCP server Agents can search and fetch public marketplace plugins (free/paid, price, marketplace URL) via search_plugins/get_plugin and REST mirrors, with optional Basic Auth or email+license-key identity for has_access checks. --- app/Http/Controllers/McpController.php | 296 +++++++++++- app/Http/Requests/McpPluginSearchRequest.php | 34 ++ app/Services/PluginSearchService.php | 287 +++++++++++ resources/views/mcp-content.md | 31 ++ routes/api.php | 5 + tests/Feature/DocsMcpServerPageTest.php | 2 +- tests/Feature/McpSecurityTest.php | 34 ++ tests/Feature/PluginMcpToolsTest.php | 483 +++++++++++++++++++ 8 files changed, 1168 insertions(+), 4 deletions(-) create mode 100644 app/Http/Requests/McpPluginSearchRequest.php create mode 100644 app/Services/PluginSearchService.php create mode 100644 tests/Feature/PluginMcpToolsTest.php diff --git a/app/Http/Controllers/McpController.php b/app/Http/Controllers/McpController.php index c61ebb92..c75e1371 100644 --- a/app/Http/Controllers/McpController.php +++ b/app/Http/Controllers/McpController.php @@ -2,15 +2,20 @@ namespace App\Http\Controllers; +use App\Http\Requests\McpPluginSearchRequest; use App\Http\Requests\McpSearchRequest; +use App\Models\User; use App\Services\DocsSearchService; +use App\Services\PluginSearchService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; class McpController extends Controller { public function __construct( - protected DocsSearchService $docsSearch + protected DocsSearchService $docsSearch, + protected PluginSearchService $pluginSearch, ) {} /** @@ -28,7 +33,11 @@ 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'] ?? [], + $request, + ), default => throw new \InvalidArgumentException("Unknown method: {$method}"), }; @@ -105,10 +114,64 @@ public function navigationApi(string $platform, string $version): JsonResponse return response()->json(['navigation' => $nav]); } + public function pluginsSearchApi(McpPluginSearchRequest $request): JsonResponse + { + try { + $user = $this->resolvePluginUser($request); + } catch (ValidationException $e) { + return response()->json([ + 'error' => 'Invalid credentials', + 'message' => collect($e->errors())->flatten()->first(), + ], 401); + } + + $validated = $request->validated(); + + $results = $this->pluginSearch->search( + $validated['q'], + $validated['type'] ?? null, + $validated['limit'] ?? PluginSearchService::DEFAULT_LIMIT, + $user, + ); + + return response()->json(['plugins' => $results]); + } + + public function pluginShowApi(Request $request, string $vendor, string $package): JsonResponse + { + try { + $user = $this->resolvePluginUser($request); + } catch (ValidationException $e) { + return response()->json([ + 'error' => 'Invalid credentials', + 'message' => collect($e->errors())->flatten()->first(), + ], 401); + } + + $plugin = $this->pluginSearch->getByVendorPackage($vendor, $package, $user); + + if (! $plugin) { + return response()->json(['error' => 'Plugin not found'], 404); + } + + return response()->json(['plugin' => $plugin]); + } + protected function getToolDefinitions(): array { $latestVersions = $this->docsSearch->getLatestVersions(); + $identityProperties = [ + 'email' => [ + 'type' => 'string', + 'description' => 'Optional NativePHP account email (same as Composer HTTP Basic username). Prefer HTTP Basic Auth when your client supports it.', + ], + 'plugin_license_key' => [ + 'type' => 'string', + 'description' => 'Optional plugin license key (same as Composer HTTP Basic password). Required with email when authenticating via tool args.', + ], + ]; + return [ [ 'name' => 'search_docs', @@ -189,6 +252,52 @@ 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, marketplace URLs, and optional has_access when authenticated with email + plugin license key (or HTTP Basic Auth).', + '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)', + ], + ...$identityProperties, + ], + 'required' => ['query'], + ], + ], + [ + 'name' => 'get_plugin', + 'description' => 'Get details for one marketplace plugin by composer name (vendor/package) or vendor + package path args. When authenticated, includes whether you already have access.', + '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)', + ], + ...$identityProperties, + ], + ], + ], ]; } @@ -206,13 +315,15 @@ protected function handleInitialize(array $params): array ]; } - protected function handleToolCall(string $name, array $args): array + protected function handleToolCall(string $name, array $args, Request $request): array { return match ($name) { 'search_docs' => $this->toolSearchDocs($args), 'get_page' => $this->toolGetPage($args), 'list_apis' => $this->toolListApis($args), 'get_navigation' => $this->toolGetNavigation($args), + 'search_plugins' => $this->toolSearchPlugins($args, $request), + 'get_plugin' => $this->toolGetPlugin($args, $request), default => [ 'content' => [['type' => 'text', 'text' => "Unknown tool: {$name}"]], 'isError' => true, @@ -321,4 +432,183 @@ protected function toolGetNavigation(array $args): array 'content' => [['type' => 'text', 'text' => "# {$platform} v{$version} Navigation\n\n{$formatted}"]], ]; } + + protected function toolSearchPlugins(array $args, Request $request): array + { + try { + $user = $this->resolvePluginUser($request, $args); + } catch (ValidationException $e) { + return [ + 'content' => [['type' => 'text', 'text' => collect($e->errors())->flatten()->first()]], + 'isError' => true, + ]; + } + + $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, $user); + + 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, Request $request): array + { + try { + $user = $this->resolvePluginUser($request, $args); + } catch (ValidationException $e) { + return [ + 'content' => [['type' => 'text', 'text' => collect($e->errors())->flatten()->first()]], + 'isError' => true, + ]; + } + + $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, $user); + $lookup = $name; + } elseif ($vendor !== '' && $package !== '') { + $plugin = $this->pluginSearch->getByVendorPackage($vendor, $package, $user); + $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['has_access'] === true && $plugin['type'] === 'paid') { + $lines[] = 'Access: You already have access'; + $lines[] = 'Type: paid'; + if ($plugin['price']) { + $lines[] = "Regular price: {$plugin['price']}"; + } + $lines[] = "Marketplace: {$plugin['marketplace_url']}"; + } elseif ($plugin['type'] === 'paid') { + $price = $plugin['your_price'] ?? $plugin['price']; + $lines[] = 'Type: paid'; + $lines[] = 'Access: '.($plugin['has_access'] === false ? 'Purchase required' : 'Not authenticated'); + $lines[] = 'Price: '.($price ?: 'paid (price not listed)'); + $lines[] = "Marketplace: {$plugin['marketplace_url']}"; + } else { + $lines[] = 'Type: free'; + if ($plugin['has_access'] === true) { + $lines[] = 'Access: Free — available to install'; + } + $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); + } + + /** + * Resolve optional marketplace identity from Basic Auth and/or explicit credentials. + * + * @param array $args + * + * @throws ValidationException + */ + protected function resolvePluginUser(Request $request, array $args = []): ?User + { + $email = $request->getUser() + ?: ($args['email'] ?? $request->input('email')); + $licenseKey = $request->getPassword() + ?: ($args['plugin_license_key'] ?? $request->input('plugin_license_key')); + + return $this->pluginSearch->resolveUser( + is_string($email) ? $email : null, + is_string($licenseKey) ? $licenseKey : null, + ); + } } diff --git a/app/Http/Requests/McpPluginSearchRequest.php b/app/Http/Requests/McpPluginSearchRequest.php new file mode 100644 index 00000000..ab37fd33 --- /dev/null +++ b/app/Http/Requests/McpPluginSearchRequest.php @@ -0,0 +1,34 @@ + ['required', 'string', 'max:500'], + 'type' => ['nullable', 'string', Rule::in(['free', 'paid'])], + 'limit' => ['nullable', 'integer', 'min:1', 'max:'.PluginSearchService::MAX_LIMIT], + 'email' => ['nullable', 'string', 'email', 'max:255'], + 'plugin_license_key' => ['nullable', 'string', 'max:255'], + ]; + } + + 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..dd639444 --- /dev/null +++ b/app/Services/PluginSearchService.php @@ -0,0 +1,287 @@ + 'Both email and plugin_license_key are required when authenticating.', + ]); + } + + $user = User::query() + ->where('email', $email) + ->where('plugin_license_key', $licenseKey) + ->first(); + + if (! $user) { + throw ValidationException::withMessages([ + 'credentials' => 'Invalid credentials. The provided email or plugin license key is incorrect.', + ]); + } + + return $user; + } + + /** + * Search approved, active marketplace plugins. + * + * @return list> + */ + public function search(string $query, ?string $type = null, int $limit = self::DEFAULT_LIMIT, ?User $user = null): 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, $user)) + ->all(); + } + + /** + * Fetch one publicly visible marketplace plugin by composer name. + * + * @return array|null + */ + public function getByName(string $name, ?User $user = null): ?array + { + $name = trim($name); + + if ($name === '' || ! str_contains($name, '/')) { + return null; + } + + [$vendor, $package] = array_pad(explode('/', $name, 2), 2, ''); + + return $this->getByVendorPackage($vendor, $package, $user); + } + + /** + * Fetch one publicly visible marketplace plugin by vendor/package path. + * + * @return array|null + */ + public function getByVendorPackage(string $vendor, string $package, ?User $user = null): ?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, $user); + } + + /** + * 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, ?User $user = null): array + { + $hasAccess = $user ? $user->hasPluginAccess($plugin) : null; + + return [ + 'name' => $plugin->name, + 'description' => $plugin->description, + 'type' => $plugin->type?->value ?? (string) $plugin->type, + 'price' => $this->formatPublicPrice($plugin), + 'your_price' => $user ? $this->formatBestPriceForUser($plugin, $user) : null, + 'has_access' => $hasAccess, + 'access_label' => $this->accessLabel($plugin, $hasAccess), + '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, ?User $user = null): array + { + return array_merge($this->toSummary($plugin, $user), [ + 'repository_url' => $plugin->getGithubUrl(), + 'packagist_url' => $plugin->isFree() ? $plugin->getPackagistUrl() : null, + ]); + } + + protected function accessLabel(Plugin $plugin, ?bool $hasAccess): ?string + { + if ($hasAccess === null) { + return null; + } + + if ($plugin->isFree()) { + return $hasAccess ? 'Free — available to install' : null; + } + + if ($hasAccess) { + return 'You already have access'; + } + + return 'Purchase required'; + } + + 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 formatBestPriceForUser(Plugin $plugin, User $user): ?string + { + if ($plugin->isFree() || $user->hasPluginAccess($plugin)) { + return null; + } + + $best = $plugin->getBestPriceForUser($user); + $regular = $plugin->getRegularPrice(); + + if (! $best) { + return null; + } + + if ($regular && $best->id !== $regular->id) { + return '$'.$best->formatted_amount.' (subscriber)'; + } + + return '$'.$best->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..e8eb57fc 100644 --- a/resources/views/mcp-content.md +++ b/resources/views/mcp-content.md @@ -125,6 +125,35 @@ 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. + +Optional identity: send HTTP Basic Auth with your NativePHP account email and +plugin license key (the same credentials Composer uses for paid plugins), or +pass `email` + `plugin_license_key` tool args. When authenticated, each result +includes whether you already have access. Invalid credentials return a clear +auth error instead of silent anonymous results. + +Use this before inventing a capability — if a camera, biometrics, or payments +plugin already exists, your agent should find it here. For paid plugins you +don't own yet, 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. Supports the same optional identity as +`search_plugins` — when you already have access, the response leads with that. + ## Reading pages without MCP Every docs page is served as raw markdown by adding `.md` to its URL, which is @@ -141,6 +170,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..7fbff0e3 --- /dev/null +++ b/tests/Feature/PluginMcpToolsTest.php @@ -0,0 +1,483 @@ +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->assertNull( + collect($this->getJson('/api/mcp/plugins/acme/paid-detail')->json('plugin'))->get('has_access') + ); + } + + #[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 licensed_user_sees_has_access_true_for_paid_plugin(): void + { + $user = User::factory()->create([ + 'email' => 'owner@example.com', + 'plugin_license_key' => 'valid-license-key', + ]); + + $plugin = Plugin::factory()->approved()->paid()->create([ + 'name' => 'acme/licensed-plugin', + 'description' => 'A paid plugin the user owns.', + ]); + + PluginPrice::factory()->regular()->amount(4900)->create([ + 'plugin_id' => $plugin->id, + ]); + + PluginLicense::factory()->create([ + 'user_id' => $user->id, + 'plugin_id' => $plugin->id, + 'expires_at' => null, + ]); + + $response = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get_plugin', + 'arguments' => [ + 'name' => 'acme/licensed-plugin', + 'email' => 'owner@example.com', + 'plugin_license_key' => 'valid-license-key', + ], + ], + ]); + + $response->assertOk(); + $this->assertArrayNotHasKey('isError', $response->json('result') ?? []); + + $text = $response->json('result.content.0.text'); + $this->assertStringContainsString('You already have access', $text); + $this->assertStringContainsString('Marketplace:', $text); + $this->assertStringContainsString(route('plugins.show', $plugin->routeParams()), $text); + + $this->asBasicAuth('owner@example.com', 'valid-license-key') + ->getJson('/api/mcp/plugins/acme/licensed-plugin') + ->assertOk() + ->assertJsonPath('plugin.has_access', true) + ->assertJsonPath('plugin.marketplace_url', route('plugins.show', $plugin->routeParams())); + } + + #[Test] + public function authenticated_user_without_entitlement_gets_false_and_marketplace_url(): void + { + $user = User::factory()->create([ + 'email' => 'buyer@example.com', + 'plugin_license_key' => 'buyer-license-key', + ]); + + $plugin = Plugin::factory()->approved()->paid()->create([ + 'name' => 'acme/unowned-plugin', + 'description' => 'A paid plugin the user does not own.', + ]); + + PluginPrice::factory()->regular()->amount(1999)->create([ + 'plugin_id' => $plugin->id, + ]); + + $response = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'search_plugins', + 'arguments' => [ + 'query' => 'unowned-plugin', + 'email' => $user->email, + 'plugin_license_key' => 'buyer-license-key', + ], + ], + ]); + + $text = $response->json('result.content.0.text'); + $this->assertStringContainsString('Purchase required', $text); + $this->assertStringContainsString('$19.99', $text); + $this->assertStringContainsString('Marketplace:', $text); + $this->assertStringContainsString(route('plugins.show', $plugin->routeParams()), $text); + $this->assertStringNotContainsString('You already have access', $text); + + $this->asBasicAuth('buyer@example.com', 'buyer-license-key') + ->getJson('/api/mcp/plugins?q=unowned-plugin') + ->assertOk() + ->assertJsonPath('plugins.0.has_access', false) + ->assertJsonPath('plugins.0.marketplace_url', route('plugins.show', $plugin->routeParams())); + } + + #[Test] + public function anonymous_results_omit_access_claim(): 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, + ]); + + $this->getJson('/api/mcp/plugins?q=anon-plugin') + ->assertOk() + ->assertJsonPath('plugins.0.name', 'acme/anon-plugin') + ->assertJsonPath('plugins.0.has_access', null) + ->assertJsonPath('plugins.0.marketplace_url', route('plugins.show', $plugin->routeParams())); + } + + #[Test] + public function invalid_credentials_fail_clearly(): void + { + Plugin::factory()->approved()->paid()->create([ + 'name' => 'acme/auth-plugin', + 'description' => 'Needs valid credentials to check access.', + ]); + + $tool = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'search_plugins', + 'arguments' => [ + 'query' => 'auth-plugin', + 'email' => 'nobody@example.com', + 'plugin_license_key' => 'wrong-key', + ], + ], + ]); + + $this->assertTrue($tool->json('result.isError')); + $this->assertStringContainsString('Invalid credentials', $tool->json('result.content.0.text')); + + $this->asBasicAuth('nobody@example.com', 'wrong-key') + ->getJson('/api/mcp/plugins?q=auth-plugin') + ->assertStatus(401) + ->assertJsonPath('error', 'Invalid credentials'); + + $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get_plugin', + 'arguments' => [ + 'name' => 'acme/auth-plugin', + 'email' => 'only-email@example.com', + ], + ], + ])->assertOk() + ->assertJsonPath('result.isError', true); + } + + #[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())) + ->assertJsonPath('plugins.0.has_access', null); + + $this->assertNotEmpty($response->json('plugins.0.marketplace_url')); + } + + #[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'); + } + + protected function asBasicAuth(string $username, string $password): static + { + return $this->withHeaders([ + 'Authorization' => 'Basic '.base64_encode("{$username}:{$password}"), + ]); + } +} From 60940e3db307098260759ffe6844d5faf7d16bd7 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Thu, 10 Sep 2026 13:18:40 -0400 Subject: [PATCH 2/3] Drop optional auth from plugin MCP tools Keep marketplace search fully public: no email/license args, Basic Auth, has_access/your_price, or docs about authenticating for access. --- app/Http/Controllers/McpController.php | 112 ++----------- app/Http/Requests/McpPluginSearchRequest.php | 2 - app/Services/PluginSearchService.php | 99 +---------- resources/views/mcp-content.md | 14 +- tests/Feature/PluginMcpToolsTest.php | 168 ++----------------- 5 files changed, 38 insertions(+), 357 deletions(-) diff --git a/app/Http/Controllers/McpController.php b/app/Http/Controllers/McpController.php index c75e1371..400d08ed 100644 --- a/app/Http/Controllers/McpController.php +++ b/app/Http/Controllers/McpController.php @@ -4,12 +4,10 @@ use App\Http\Requests\McpPluginSearchRequest; use App\Http\Requests\McpSearchRequest; -use App\Models\User; use App\Services\DocsSearchService; use App\Services\PluginSearchService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Validation\ValidationException; class McpController extends Controller { @@ -36,7 +34,6 @@ public function message(Request $request): JsonResponse 'tools/call' => $this->handleToolCall( $params['name'] ?? '', $params['arguments'] ?? [], - $request, ), default => throw new \InvalidArgumentException("Unknown method: {$method}"), }; @@ -116,39 +113,20 @@ public function navigationApi(string $platform, string $version): JsonResponse public function pluginsSearchApi(McpPluginSearchRequest $request): JsonResponse { - try { - $user = $this->resolvePluginUser($request); - } catch (ValidationException $e) { - return response()->json([ - 'error' => 'Invalid credentials', - 'message' => collect($e->errors())->flatten()->first(), - ], 401); - } - $validated = $request->validated(); $results = $this->pluginSearch->search( $validated['q'], $validated['type'] ?? null, $validated['limit'] ?? PluginSearchService::DEFAULT_LIMIT, - $user, ); return response()->json(['plugins' => $results]); } - public function pluginShowApi(Request $request, string $vendor, string $package): JsonResponse + public function pluginShowApi(string $vendor, string $package): JsonResponse { - try { - $user = $this->resolvePluginUser($request); - } catch (ValidationException $e) { - return response()->json([ - 'error' => 'Invalid credentials', - 'message' => collect($e->errors())->flatten()->first(), - ], 401); - } - - $plugin = $this->pluginSearch->getByVendorPackage($vendor, $package, $user); + $plugin = $this->pluginSearch->getByVendorPackage($vendor, $package); if (! $plugin) { return response()->json(['error' => 'Plugin not found'], 404); @@ -161,16 +139,6 @@ protected function getToolDefinitions(): array { $latestVersions = $this->docsSearch->getLatestVersions(); - $identityProperties = [ - 'email' => [ - 'type' => 'string', - 'description' => 'Optional NativePHP account email (same as Composer HTTP Basic username). Prefer HTTP Basic Auth when your client supports it.', - ], - 'plugin_license_key' => [ - 'type' => 'string', - 'description' => 'Optional plugin license key (same as Composer HTTP Basic password). Required with email when authenticating via tool args.', - ], - ]; return [ [ @@ -254,7 +222,7 @@ protected function getToolDefinitions(): array ], [ 'name' => 'search_plugins', - 'description' => 'Search the NativePHP plugin marketplace for approved, publicly listed plugins. Returns composer package names, free/paid type, price, marketplace URLs, and optional has_access when authenticated with email + plugin license key (or HTTP Basic Auth).', + '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' => [ @@ -271,14 +239,13 @@ protected function getToolDefinitions(): array 'type' => 'number', 'description' => 'Max results to return (default: 10, max: 25)', ], - ...$identityProperties, ], 'required' => ['query'], ], ], [ 'name' => 'get_plugin', - 'description' => 'Get details for one marketplace plugin by composer name (vendor/package) or vendor + package path args. When authenticated, includes whether you already have access.', + 'description' => 'Get details for one marketplace plugin by composer name (vendor/package) or vendor + package path args.', 'inputSchema' => [ 'type' => 'object', 'properties' => [ @@ -294,7 +261,6 @@ protected function getToolDefinitions(): array 'type' => 'string', 'description' => 'Composer package segment (use with vendor)', ], - ...$identityProperties, ], ], ], @@ -315,15 +281,15 @@ protected function handleInitialize(array $params): array ]; } - protected function handleToolCall(string $name, array $args, Request $request): array + protected function handleToolCall(string $name, array $args): array { return match ($name) { 'search_docs' => $this->toolSearchDocs($args), 'get_page' => $this->toolGetPage($args), 'list_apis' => $this->toolListApis($args), 'get_navigation' => $this->toolGetNavigation($args), - 'search_plugins' => $this->toolSearchPlugins($args, $request), - 'get_plugin' => $this->toolGetPlugin($args, $request), + 'search_plugins' => $this->toolSearchPlugins($args), + 'get_plugin' => $this->toolGetPlugin($args), default => [ 'content' => [['type' => 'text', 'text' => "Unknown tool: {$name}"]], 'isError' => true, @@ -433,17 +399,8 @@ protected function toolGetNavigation(array $args): array ]; } - protected function toolSearchPlugins(array $args, Request $request): array + protected function toolSearchPlugins(array $args): array { - try { - $user = $this->resolvePluginUser($request, $args); - } catch (ValidationException $e) { - return [ - 'content' => [['type' => 'text', 'text' => collect($e->errors())->flatten()->first()]], - 'isError' => true, - ]; - } - $query = (string) ($args['query'] ?? ''); $type = isset($args['type']) ? (string) $args['type'] : null; $limit = isset($args['limit']) ? (int) $args['limit'] : PluginSearchService::DEFAULT_LIMIT; @@ -455,7 +412,7 @@ protected function toolSearchPlugins(array $args, Request $request): array ]; } - $results = $this->pluginSearch->search($query, $type, $limit, $user); + $results = $this->pluginSearch->search($query, $type, $limit); if (empty($results)) { $filterDesc = $type ? " (type: {$type})" : ''; @@ -474,17 +431,8 @@ protected function toolSearchPlugins(array $args, Request $request): array ]; } - protected function toolGetPlugin(array $args, Request $request): array + protected function toolGetPlugin(array $args): array { - try { - $user = $this->resolvePluginUser($request, $args); - } catch (ValidationException $e) { - return [ - 'content' => [['type' => 'text', 'text' => collect($e->errors())->flatten()->first()]], - 'isError' => true, - ]; - } - $name = isset($args['name']) ? (string) $args['name'] : ''; $vendor = isset($args['vendor']) ? (string) $args['vendor'] : ''; $package = isset($args['package']) ? (string) $args['package'] : ''; @@ -492,10 +440,10 @@ protected function toolGetPlugin(array $args, Request $request): array $plugin = null; if ($name !== '') { - $plugin = $this->pluginSearch->getByName($name, $user); + $plugin = $this->pluginSearch->getByName($name); $lookup = $name; } elseif ($vendor !== '' && $package !== '') { - $plugin = $this->pluginSearch->getByVendorPackage($vendor, $package, $user); + $plugin = $this->pluginSearch->getByVendorPackage($vendor, $package); $lookup = "{$vendor}/{$package}"; } else { return [ @@ -533,24 +481,12 @@ protected function formatPluginResultText(array $plugin, bool $detailed): string $lines[] = "**{$plugin['name']}**"; } - if ($plugin['has_access'] === true && $plugin['type'] === 'paid') { - $lines[] = 'Access: You already have access'; - $lines[] = 'Type: paid'; - if ($plugin['price']) { - $lines[] = "Regular price: {$plugin['price']}"; - } - $lines[] = "Marketplace: {$plugin['marketplace_url']}"; - } elseif ($plugin['type'] === 'paid') { - $price = $plugin['your_price'] ?? $plugin['price']; + if ($plugin['type'] === 'paid') { $lines[] = 'Type: paid'; - $lines[] = 'Access: '.($plugin['has_access'] === false ? 'Purchase required' : 'Not authenticated'); - $lines[] = 'Price: '.($price ?: 'paid (price not listed)'); + $lines[] = 'Price: '.($plugin['price'] ?: 'paid (price not listed)'); $lines[] = "Marketplace: {$plugin['marketplace_url']}"; } else { $lines[] = 'Type: free'; - if ($plugin['has_access'] === true) { - $lines[] = 'Access: Free — available to install'; - } $lines[] = "Marketplace: {$plugin['marketplace_url']}"; } @@ -591,24 +527,4 @@ protected function formatPluginResultText(array $plugin, bool $detailed): string return implode("\n ", $lines); } - - /** - * Resolve optional marketplace identity from Basic Auth and/or explicit credentials. - * - * @param array $args - * - * @throws ValidationException - */ - protected function resolvePluginUser(Request $request, array $args = []): ?User - { - $email = $request->getUser() - ?: ($args['email'] ?? $request->input('email')); - $licenseKey = $request->getPassword() - ?: ($args['plugin_license_key'] ?? $request->input('plugin_license_key')); - - return $this->pluginSearch->resolveUser( - is_string($email) ? $email : null, - is_string($licenseKey) ? $licenseKey : null, - ); - } } diff --git a/app/Http/Requests/McpPluginSearchRequest.php b/app/Http/Requests/McpPluginSearchRequest.php index ab37fd33..dd64cb07 100644 --- a/app/Http/Requests/McpPluginSearchRequest.php +++ b/app/Http/Requests/McpPluginSearchRequest.php @@ -19,8 +19,6 @@ public function rules(): array 'q' => ['required', 'string', 'max:500'], 'type' => ['nullable', 'string', Rule::in(['free', 'paid'])], 'limit' => ['nullable', 'integer', 'min:1', 'max:'.PluginSearchService::MAX_LIMIT], - 'email' => ['nullable', 'string', 'email', 'max:255'], - 'plugin_license_key' => ['nullable', 'string', 'max:255'], ]; } diff --git a/app/Services/PluginSearchService.php b/app/Services/PluginSearchService.php index dd639444..2de6c15f 100644 --- a/app/Services/PluginSearchService.php +++ b/app/Services/PluginSearchService.php @@ -5,9 +5,7 @@ use App\Enums\PluginType; use App\Enums\PriceTier; use App\Models\Plugin; -use App\Models\User; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Validation\ValidationException; class PluginSearchService { @@ -15,49 +13,12 @@ class PluginSearchService public const int MAX_LIMIT = 25; - /** - * Resolve a marketplace user from Basic Auth or explicit credentials. - * - * @throws ValidationException when credentials are partially provided or invalid - */ - public function resolveUser(?string $email, ?string $licenseKey): ?User - { - $email = is_string($email) ? trim($email) : null; - $licenseKey = is_string($licenseKey) ? trim($licenseKey) : null; - - $email = $email === '' ? null : $email; - $licenseKey = $licenseKey === '' ? null : $licenseKey; - - if ($email === null && $licenseKey === null) { - return null; - } - - if ($email === null || $licenseKey === null) { - throw ValidationException::withMessages([ - 'credentials' => 'Both email and plugin_license_key are required when authenticating.', - ]); - } - - $user = User::query() - ->where('email', $email) - ->where('plugin_license_key', $licenseKey) - ->first(); - - if (! $user) { - throw ValidationException::withMessages([ - 'credentials' => 'Invalid credentials. The provided email or plugin license key is incorrect.', - ]); - } - - return $user; - } - /** * Search approved, active marketplace plugins. * * @return list> */ - public function search(string $query, ?string $type = null, int $limit = self::DEFAULT_LIMIT, ?User $user = null): array + 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); @@ -95,7 +56,7 @@ public function search(string $query, ?string $type = null, int $limit = self::D }) ->take($limit) ->values() - ->map(fn (Plugin $plugin) => $this->toSummary($plugin, $user)) + ->map(fn (Plugin $plugin) => $this->toSummary($plugin)) ->all(); } @@ -104,7 +65,7 @@ public function search(string $query, ?string $type = null, int $limit = self::D * * @return array|null */ - public function getByName(string $name, ?User $user = null): ?array + public function getByName(string $name): ?array { $name = trim($name); @@ -114,7 +75,7 @@ public function getByName(string $name, ?User $user = null): ?array [$vendor, $package] = array_pad(explode('/', $name, 2), 2, ''); - return $this->getByVendorPackage($vendor, $package, $user); + return $this->getByVendorPackage($vendor, $package); } /** @@ -122,7 +83,7 @@ public function getByName(string $name, ?User $user = null): ?array * * @return array|null */ - public function getByVendorPackage(string $vendor, string $package, ?User $user = null): ?array + public function getByVendorPackage(string $vendor, string $package): ?array { $vendor = $this->sanitizePathSegment($vendor); $package = $this->sanitizePathSegment($package); @@ -140,7 +101,7 @@ public function getByVendorPackage(string $vendor, string $package, ?User $user return null; } - return $this->toDetail($plugin, $user); + return $this->toDetail($plugin); } /** @@ -156,18 +117,13 @@ protected function publicDirectoryQuery(): Builder /** * @return array */ - protected function toSummary(Plugin $plugin, ?User $user = null): array + protected function toSummary(Plugin $plugin): array { - $hasAccess = $user ? $user->hasPluginAccess($plugin) : null; - return [ 'name' => $plugin->name, 'description' => $plugin->description, 'type' => $plugin->type?->value ?? (string) $plugin->type, 'price' => $this->formatPublicPrice($plugin), - 'your_price' => $user ? $this->formatBestPriceForUser($plugin, $user) : null, - 'has_access' => $hasAccess, - 'access_label' => $this->accessLabel($plugin, $hasAccess), 'featured' => (bool) $plugin->featured, 'is_official' => $plugin->isOfficial(), 'works_in_jump' => $plugin->worksInJump(), @@ -179,31 +135,14 @@ protected function toSummary(Plugin $plugin, ?User $user = null): array /** * @return array */ - protected function toDetail(Plugin $plugin, ?User $user = null): array + protected function toDetail(Plugin $plugin): array { - return array_merge($this->toSummary($plugin, $user), [ + return array_merge($this->toSummary($plugin), [ 'repository_url' => $plugin->getGithubUrl(), 'packagist_url' => $plugin->isFree() ? $plugin->getPackagistUrl() : null, ]); } - protected function accessLabel(Plugin $plugin, ?bool $hasAccess): ?string - { - if ($hasAccess === null) { - return null; - } - - if ($plugin->isFree()) { - return $hasAccess ? 'Free — available to install' : null; - } - - if ($hasAccess) { - return 'You already have access'; - } - - return 'Purchase required'; - } - protected function formatPublicPrice(Plugin $plugin): ?string { if ($plugin->isFree()) { @@ -220,26 +159,6 @@ protected function formatPublicPrice(Plugin $plugin): ?string return '$'.$regular->formatted_amount; } - protected function formatBestPriceForUser(Plugin $plugin, User $user): ?string - { - if ($plugin->isFree() || $user->hasPluginAccess($plugin)) { - return null; - } - - $best = $plugin->getBestPriceForUser($user); - $regular = $plugin->getRegularPrice(); - - if (! $best) { - return null; - } - - if ($regular && $best->id !== $regular->id) { - return '$'.$best->formatted_amount.' (subscriber)'; - } - - return '$'.$best->formatted_amount; - } - protected function matchScore(Plugin $plugin, string $query): int { $queryLower = mb_strtolower($query); diff --git a/resources/views/mcp-content.md b/resources/views/mcp-content.md index e8eb57fc..d50e9e8e 100644 --- a/resources/views/mcp-content.md +++ b/resources/views/mcp-content.md @@ -134,16 +134,9 @@ description, plus optional `type` (`free` or `paid`) and `limit` (10 by default, regular price when listed, useful flags (featured / official / works in Jump), latest version when known, and a **Marketplace:** URL you can open. -Optional identity: send HTTP Basic Auth with your NativePHP account email and -plugin license key (the same credentials Composer uses for paid plugins), or -pass `email` + `plugin_license_key` tool args. When authenticated, each result -includes whether you already have access. Invalid credentials return a clear -auth error instead of silent anonymous results. - Use this before inventing a capability — if a camera, biometrics, or payments -plugin already exists, your agent should find it here. For paid plugins you -don't own yet, the marketplace URL is always included so a human can decide -whether to buy. +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` @@ -151,8 +144,7 @@ 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. Supports the same optional identity as -`search_plugins` — when you already have access, the response leads with that. +packages come back as a not-found error. ## Reading pages without MCP diff --git a/tests/Feature/PluginMcpToolsTest.php b/tests/Feature/PluginMcpToolsTest.php index 7fbff0e3..52c34a1e 100644 --- a/tests/Feature/PluginMcpToolsTest.php +++ b/tests/Feature/PluginMcpToolsTest.php @@ -3,9 +3,7 @@ namespace Tests\Feature; use App\Models\Plugin; -use App\Models\PluginLicense; use App\Models\PluginPrice; -use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -224,8 +222,9 @@ public function get_plugin_returns_marketplace_url_for_paid_plugin(): void $this->assertStringContainsString('Marketplace:', $text); $this->assertStringContainsString($marketplaceUrl, $text); $this->assertStringNotContainsString('Packagist:', $text); - $this->assertNull( - collect($this->getJson('/api/mcp/plugins/acme/paid-detail')->json('plugin'))->get('has_access') + $this->assertArrayNotHasKey( + 'has_access', + $this->getJson('/api/mcp/plugins/acme/paid-detail')->json('plugin') ); } @@ -264,104 +263,7 @@ public function get_plugin_errors_for_unknown_or_unapproved_plugins(): void } #[Test] - public function licensed_user_sees_has_access_true_for_paid_plugin(): void - { - $user = User::factory()->create([ - 'email' => 'owner@example.com', - 'plugin_license_key' => 'valid-license-key', - ]); - - $plugin = Plugin::factory()->approved()->paid()->create([ - 'name' => 'acme/licensed-plugin', - 'description' => 'A paid plugin the user owns.', - ]); - - PluginPrice::factory()->regular()->amount(4900)->create([ - 'plugin_id' => $plugin->id, - ]); - - PluginLicense::factory()->create([ - 'user_id' => $user->id, - 'plugin_id' => $plugin->id, - 'expires_at' => null, - ]); - - $response = $this->postJson('/api/mcp/message', [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'tools/call', - 'params' => [ - 'name' => 'get_plugin', - 'arguments' => [ - 'name' => 'acme/licensed-plugin', - 'email' => 'owner@example.com', - 'plugin_license_key' => 'valid-license-key', - ], - ], - ]); - - $response->assertOk(); - $this->assertArrayNotHasKey('isError', $response->json('result') ?? []); - - $text = $response->json('result.content.0.text'); - $this->assertStringContainsString('You already have access', $text); - $this->assertStringContainsString('Marketplace:', $text); - $this->assertStringContainsString(route('plugins.show', $plugin->routeParams()), $text); - - $this->asBasicAuth('owner@example.com', 'valid-license-key') - ->getJson('/api/mcp/plugins/acme/licensed-plugin') - ->assertOk() - ->assertJsonPath('plugin.has_access', true) - ->assertJsonPath('plugin.marketplace_url', route('plugins.show', $plugin->routeParams())); - } - - #[Test] - public function authenticated_user_without_entitlement_gets_false_and_marketplace_url(): void - { - $user = User::factory()->create([ - 'email' => 'buyer@example.com', - 'plugin_license_key' => 'buyer-license-key', - ]); - - $plugin = Plugin::factory()->approved()->paid()->create([ - 'name' => 'acme/unowned-plugin', - 'description' => 'A paid plugin the user does not own.', - ]); - - PluginPrice::factory()->regular()->amount(1999)->create([ - 'plugin_id' => $plugin->id, - ]); - - $response = $this->postJson('/api/mcp/message', [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'tools/call', - 'params' => [ - 'name' => 'search_plugins', - 'arguments' => [ - 'query' => 'unowned-plugin', - 'email' => $user->email, - 'plugin_license_key' => 'buyer-license-key', - ], - ], - ]); - - $text = $response->json('result.content.0.text'); - $this->assertStringContainsString('Purchase required', $text); - $this->assertStringContainsString('$19.99', $text); - $this->assertStringContainsString('Marketplace:', $text); - $this->assertStringContainsString(route('plugins.show', $plugin->routeParams()), $text); - $this->assertStringNotContainsString('You already have access', $text); - - $this->asBasicAuth('buyer@example.com', 'buyer-license-key') - ->getJson('/api/mcp/plugins?q=unowned-plugin') - ->assertOk() - ->assertJsonPath('plugins.0.has_access', false) - ->assertJsonPath('plugins.0.marketplace_url', route('plugins.show', $plugin->routeParams())); - } - - #[Test] - public function anonymous_results_omit_access_claim(): void + public function public_results_include_marketplace_fields_without_access_claims(): void { $plugin = Plugin::factory()->approved()->paid()->create([ 'name' => 'acme/anon-plugin', @@ -372,56 +274,15 @@ public function anonymous_results_omit_access_claim(): void 'plugin_id' => $plugin->id, ]); - $this->getJson('/api/mcp/plugins?q=anon-plugin') + $response = $this->getJson('/api/mcp/plugins?q=anon-plugin') ->assertOk() ->assertJsonPath('plugins.0.name', 'acme/anon-plugin') - ->assertJsonPath('plugins.0.has_access', null) + ->assertJsonPath('plugins.0.price', '$15.00') ->assertJsonPath('plugins.0.marketplace_url', route('plugins.show', $plugin->routeParams())); - } - - #[Test] - public function invalid_credentials_fail_clearly(): void - { - Plugin::factory()->approved()->paid()->create([ - 'name' => 'acme/auth-plugin', - 'description' => 'Needs valid credentials to check access.', - ]); - $tool = $this->postJson('/api/mcp/message', [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'tools/call', - 'params' => [ - 'name' => 'search_plugins', - 'arguments' => [ - 'query' => 'auth-plugin', - 'email' => 'nobody@example.com', - 'plugin_license_key' => 'wrong-key', - ], - ], - ]); - - $this->assertTrue($tool->json('result.isError')); - $this->assertStringContainsString('Invalid credentials', $tool->json('result.content.0.text')); - - $this->asBasicAuth('nobody@example.com', 'wrong-key') - ->getJson('/api/mcp/plugins?q=auth-plugin') - ->assertStatus(401) - ->assertJsonPath('error', 'Invalid credentials'); - - $this->postJson('/api/mcp/message', [ - 'jsonrpc' => '2.0', - 'id' => 2, - 'method' => 'tools/call', - 'params' => [ - 'name' => 'get_plugin', - 'arguments' => [ - 'name' => 'acme/auth-plugin', - 'email' => 'only-email@example.com', - ], - ], - ])->assertOk() - ->assertJsonPath('result.isError', true); + $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] @@ -446,10 +307,11 @@ public function rest_plugins_search_returns_expected_json_shape(): void ->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())) - ->assertJsonPath('plugins.0.has_access', null); + ->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] @@ -474,10 +336,4 @@ public function rest_plugin_show_returns_detail_and_404_for_missing(): void ->assertJsonPath('error', 'Plugin not found'); } - protected function asBasicAuth(string $username, string $password): static - { - return $this->withHeaders([ - 'Authorization' => 'Basic '.base64_encode("{$username}:{$password}"), - ]); - } } From 3db12a0ea82d463de8e1438aa6e66ebe325945fd Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Thu, 10 Sep 2026 14:12:48 -0400 Subject: [PATCH 3/3] Fix Pint style on MCP plugin discovery files --- app/Http/Controllers/McpController.php | 1 - tests/Feature/PluginMcpToolsTest.php | 1 - 2 files changed, 2 deletions(-) diff --git a/app/Http/Controllers/McpController.php b/app/Http/Controllers/McpController.php index 400d08ed..19b344e1 100644 --- a/app/Http/Controllers/McpController.php +++ b/app/Http/Controllers/McpController.php @@ -139,7 +139,6 @@ protected function getToolDefinitions(): array { $latestVersions = $this->docsSearch->getLatestVersions(); - return [ [ 'name' => 'search_docs', diff --git a/tests/Feature/PluginMcpToolsTest.php b/tests/Feature/PluginMcpToolsTest.php index 52c34a1e..f09c457b 100644 --- a/tests/Feature/PluginMcpToolsTest.php +++ b/tests/Feature/PluginMcpToolsTest.php @@ -335,5 +335,4 @@ public function rest_plugin_show_returns_detail_and_404_for_missing(): void ->assertNotFound() ->assertJsonPath('error', 'Plugin not found'); } - }