Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 207 additions & 2 deletions app/Http/Controllers/McpController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}

/**
Expand All @@ -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}"),
};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)',
],
],
],
],
];
}

Expand All @@ -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,
Expand Down Expand Up @@ -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<string, mixed> $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);
}
}
32 changes: 32 additions & 0 deletions app/Http/Requests/McpPluginSearchRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace App\Http\Requests;

use App\Services\PluginSearchService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class McpPluginSearchRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
'q' => ['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.'.',
];
}
}
Loading