From 3242fda70c614fc31ad2188d78ed5b883e27b01a Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Tue, 13 Jan 2026 13:03:07 -0500 Subject: [PATCH] Add MCP docs server for documentation search - Add DocsSearchService for searching and indexing documentation - Add McpController with SSE and JSON-RPC endpoints - Add REST API endpoints for search, page, apis, and navigation - Add MCP routes at /mcp/* - Add CSRF exception for MCP endpoints --- app/Http/Controllers/McpController.php | 363 ++++++++++++++++++++++++ app/Http/Middleware/VerifyCsrfToken.php | 1 + app/Services/DocsSearchService.php | 286 +++++++++++++++++++ routes/web.php | 13 + 4 files changed, 663 insertions(+) create mode 100644 app/Http/Controllers/McpController.php create mode 100644 app/Services/DocsSearchService.php diff --git a/app/Http/Controllers/McpController.php b/app/Http/Controllers/McpController.php new file mode 100644 index 00000000..28e9793e --- /dev/null +++ b/app/Http/Controllers/McpController.php @@ -0,0 +1,363 @@ +toString(); + + return response()->stream(function () use ($sessionId) { + // Send session info + $this->sendSseEvent([ + 'type' => 'session', + 'sessionId' => $sessionId, + ]); + + // Send server info + $this->sendSseEvent([ + 'type' => 'serverInfo', + 'name' => 'nativephp-docs', + 'version' => '1.0.0', + 'capabilities' => ['tools' => new \stdClass], + ]); + + // Send available tools + $this->sendSseEvent([ + 'type' => 'tools', + 'tools' => $this->getToolDefinitions(), + ]); + + // Keep connection alive + while (true) { + if (connection_aborted()) { + break; + } + echo ": keepalive\n\n"; + ob_flush(); + flush(); + sleep(30); + } + }, 200, [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + ]); + } + + /** + * JSON-RPC message endpoint for tool calls + */ + public function message(Request $request): JsonResponse + { + $method = $request->input('method'); + $params = $request->input('params', []); + $id = $request->input('id'); + + try { + $result = match ($method) { + 'tools/list' => ['tools' => $this->getToolDefinitions()], + 'tools/call' => $this->handleToolCall($params['name'] ?? '', $params['arguments'] ?? []), + default => throw new \InvalidArgumentException("Unknown method: {$method}"), + }; + + return response()->json([ + 'jsonrpc' => '2.0', + 'id' => $id, + 'result' => $result, + ]); + } catch (\Throwable $e) { + return response()->json([ + 'jsonrpc' => '2.0', + 'id' => $id, + 'error' => [ + 'code' => -32000, + 'message' => $e->getMessage(), + ], + ]); + } + } + + /** + * Health check endpoint + */ + public function health(): JsonResponse + { + $versions = $this->docsSearch->getVersions(); + $pageCount = count($this->docsSearch->search('', null, null, 1000)); + + return response()->json([ + 'status' => 'ok', + 'versions' => $versions, + 'pages' => $pageCount, + ]); + } + + // REST API endpoints for simpler integrations + + public function searchApi(Request $request): JsonResponse + { + $query = $request->input('q', ''); + $platform = $request->input('platform'); + $version = $request->input('version'); + $limit = (int) $request->input('limit', 10); + + if (empty($query)) { + return response()->json(['error' => 'Missing query parameter: q'], 400); + } + + $results = $this->docsSearch->search($query, $platform, $version, $limit); + + return response()->json(['results' => $results]); + } + + public function pageApi(string $platform, string $version, string $section, string $slug): JsonResponse + { + $page = $this->docsSearch->getPage($platform, $version, $section, $slug); + + if (! $page) { + return response()->json(['error' => 'Page not found'], 404); + } + + return response()->json(['page' => $page]); + } + + public function apisApi(string $platform, string $version): JsonResponse + { + $apis = $this->docsSearch->listApis($platform, $version); + + return response()->json(['apis' => $apis]); + } + + public function navigationApi(string $platform, string $version): JsonResponse + { + $nav = $this->docsSearch->getNavigation($platform, $version); + + return response()->json(['navigation' => $nav]); + } + + protected function getToolDefinitions(): array + { + $latestVersions = $this->docsSearch->getLatestVersions(); + + return [ + [ + 'name' => 'search_docs', + 'description' => "Search NativePHP documentation. Latest versions: desktop v{$latestVersions['desktop']}, mobile v{$latestVersions['mobile']}.", + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'query' => [ + 'type' => 'string', + 'description' => 'Search query (e.g., "camera permissions", "window management")', + ], + 'platform' => [ + 'type' => 'string', + 'enum' => ['desktop', 'mobile'], + 'description' => 'Filter by platform (optional)', + ], + 'version' => [ + 'type' => 'string', + 'description' => 'Filter by version number (optional)', + ], + 'limit' => [ + 'type' => 'number', + 'description' => 'Max results to return (default: 10)', + ], + ], + 'required' => ['query'], + ], + ], + [ + 'name' => 'get_page', + 'description' => 'Get full content of a documentation page by path (e.g., "mobile/3/apis/camera")', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'path' => [ + 'type' => 'string', + 'description' => 'Page path: platform/version/section/slug', + ], + ], + 'required' => ['path'], + ], + ], + [ + 'name' => 'list_apis', + 'description' => 'List all native APIs for a platform/version', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'platform' => [ + 'type' => 'string', + 'enum' => ['desktop', 'mobile'], + 'description' => 'Platform to list APIs for', + ], + 'version' => [ + 'type' => 'string', + 'description' => 'Version number', + ], + ], + 'required' => ['platform', 'version'], + ], + ], + [ + 'name' => 'get_navigation', + 'description' => 'Get the docs navigation structure for a platform/version', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'platform' => [ + 'type' => 'string', + 'enum' => ['desktop', 'mobile'], + 'description' => 'Platform', + ], + 'version' => [ + 'type' => 'string', + 'description' => 'Version number', + ], + ], + 'required' => ['platform', 'version'], + ], + ], + ]; + } + + 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), + default => [ + 'content' => [['type' => 'text', 'text' => "Unknown tool: {$name}"]], + 'isError' => true, + ], + }; + } + + protected function toolSearchDocs(array $args): array + { + $query = $args['query'] ?? ''; + $platform = $args['platform'] ?? null; + $version = $args['version'] ?? null; + $limit = $args['limit'] ?? 10; + + $results = $this->docsSearch->search($query, $platform, $version, $limit); + + if (empty($results)) { + $filterDesc = ''; + if ($platform) { + $filterDesc .= " in {$platform}"; + } + if ($version) { + $filterDesc .= " v{$version}"; + } + + return [ + 'content' => [['type' => 'text', 'text' => "No results found for \"{$query}\"{$filterDesc}"]], + ]; + } + + $formatted = collect($results)->map(function ($r, $i) { + $num = $i + 1; + + return "{$num}. **{$r['title']}** ({$r['platform']}/v{$r['version']}/{$r['section']})\n Path: {$r['id']}\n {$r['snippet']}"; + })->join("\n\n"); + + return [ + 'content' => [['type' => 'text', 'text' => 'Found '.count($results)." results for \"{$query}\":\n\n{$formatted}"]], + ]; + } + + protected function toolGetPage(array $args): array + { + $path = $args['path'] ?? ''; + $page = $this->docsSearch->getPageByPath($path); + + if (! $page) { + return [ + 'content' => [['type' => 'text', 'text' => "Page not found: {$path}"]], + ]; + } + + $text = "# {$page['title']}\n\n"; + $text .= "**Platform:** {$page['platform']} | **Version:** {$page['version']} | **Section:** {$page['section']}\n\n"; + $text .= $page['content']; + + return [ + 'content' => [['type' => 'text', 'text' => $text]], + ]; + } + + protected function toolListApis(array $args): array + { + $platform = $args['platform'] ?? ''; + $version = $args['version'] ?? ''; + + $apis = $this->docsSearch->listApis($platform, $version); + + if (empty($apis)) { + return [ + 'content' => [['type' => 'text', 'text' => "No APIs found for {$platform} v{$version}"]], + ]; + } + + $formatted = collect($apis)->map(function ($api) { + $desc = $api['description'] ?: 'No description'; + + return "- **{$api['title']}** ({$api['slug']})\n {$desc}"; + })->join("\n"); + + return [ + 'content' => [['type' => 'text', 'text' => "# {$platform} v{$version} APIs\n\n{$formatted}"]], + ]; + } + + protected function toolGetNavigation(array $args): array + { + $platform = $args['platform'] ?? ''; + $version = $args['version'] ?? ''; + + $nav = $this->docsSearch->getNavigation($platform, $version); + + if (empty($nav)) { + return [ + 'content' => [['type' => 'text', 'text' => "No navigation found for {$platform} v{$version}"]], + ]; + } + + $formatted = collect($nav)->map(function ($pages, $section) { + $pageList = collect($pages)->map(fn ($p) => " - {$p['title']} ({$p['slug']})")->join("\n"); + + return "## {$section}\n{$pageList}"; + })->join("\n\n"); + + return [ + 'content' => [['type' => 'text', 'text' => "# {$platform} v{$version} Navigation\n\n{$formatted}"]], + ]; + } + + protected function sendSseEvent(array $data): void + { + echo 'data: '.json_encode($data)."\n\n"; + ob_flush(); + flush(); + } +} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 0c163a38..12d647ac 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware protected $except = [ 'stripe/webhook', 'opencollective/contribution', + 'mcp/*', ]; } diff --git a/app/Services/DocsSearchService.php b/app/Services/DocsSearchService.php new file mode 100644 index 00000000..c6243623 --- /dev/null +++ b/app/Services/DocsSearchService.php @@ -0,0 +1,286 @@ +docsPath = resource_path('views/docs'); + } + + public function search(string $query, ?string $platform = null, ?string $version = null, int $limit = 10): array + { + $pages = $this->getAllPages($platform, $version); + $queryTerms = $this->tokenize($query); + + return collect($pages) + ->map(function ($page) use ($queryTerms) { + $score = $this->calculateScore($page, $queryTerms); + $page['score'] = $score; + $page['snippet'] = $this->extractSnippet($page['content'], $queryTerms); + + return $page; + }) + ->filter(fn ($page) => $page['score'] > 0) + ->sortByDesc('score') + ->take($limit) + ->values() + ->toArray(); + } + + public function getPage(string $platform, string $version, string $section, string $slug): ?array + { + $filePath = "{$this->docsPath}/{$platform}/{$version}/{$section}/{$slug}.md"; + + if (! file_exists($filePath)) { + return null; + } + + return $this->parsePage($filePath, $platform, $version, $section); + } + + public function getPageByPath(string $path): ?array + { + $parts = explode('/', $path); + + if (count($parts) < 4) { + return null; + } + + return $this->getPage($parts[0], $parts[1], $parts[2], $parts[3]); + } + + public function listApis(string $platform, string $version): array + { + return collect($this->getAllPages($platform, $version)) + ->filter(fn ($page) => $page['section'] === 'apis') + ->sortBy('order') + ->values() + ->toArray(); + } + + public function getNavigation(string $platform, string $version): array + { + $pages = $this->getAllPages($platform, $version); + + $sections = []; + foreach ($pages as $page) { + $section = $page['section']; + if (! isset($sections[$section])) { + $sections[$section] = []; + } + $sections[$section][] = $page; + } + + foreach ($sections as $section => $sectionPages) { + usort($sections[$section], fn ($a, $b) => $a['order'] <=> $b['order']); + } + + return $sections; + } + + public function getPlatforms(): array + { + return ['desktop', 'mobile']; + } + + public function getVersions(?string $platform = null): array + { + $versions = []; + + foreach ($this->getPlatforms() as $plat) { + if ($platform && $plat !== $platform) { + continue; + } + + $platformPath = "{$this->docsPath}/{$plat}"; + if (is_dir($platformPath)) { + $versions[$plat] = collect(scandir($platformPath)) + ->filter(fn ($dir) => is_dir("{$platformPath}/{$dir}") && ! in_array($dir, ['.', '..'])) + ->values() + ->toArray(); + } + } + + return $platform ? ($versions[$platform] ?? []) : $versions; + } + + public function getLatestVersions(): array + { + $versions = $this->getVersions(); + + return [ + 'desktop' => collect($versions['desktop'] ?? [])->sort()->last() ?? '2', + 'mobile' => collect($versions['mobile'] ?? [])->sort()->last() ?? '3', + ]; + } + + protected function getAllPages(?string $platform = null, ?string $version = null): array + { + $cacheKey = "mcp_docs_pages_{$platform}_{$version}"; + + if (config('app.env') !== 'local') { + $cached = Cache::get($cacheKey); + if ($cached) { + return $cached; + } + } + + $pages = []; + $platforms = $platform ? [$platform] : $this->getPlatforms(); + + foreach ($platforms as $plat) { + $versions = $version ? [$version] : $this->getVersions($plat); + + foreach ($versions as $ver) { + $versionPath = "{$this->docsPath}/{$plat}/{$ver}"; + + if (! is_dir($versionPath)) { + continue; + } + + $finder = (new Finder) + ->files() + ->name('*.md') + ->notName('_index.md') + ->depth('> 0') + ->in($versionPath); + + foreach ($finder as $file) { + $section = basename(dirname($file->getPathname())); + $page = $this->parsePage($file->getPathname(), $plat, $ver, $section); + if ($page) { + $pages[] = $page; + } + } + } + } + + if (config('app.env') !== 'local') { + Cache::put($cacheKey, $pages, now()->addDay()); + } + + return $pages; + } + + protected function parsePage(string $filePath, string $platform, string $version, string $section): ?array + { + if (! file_exists($filePath)) { + return null; + } + + $content = file_get_contents($filePath); + $document = YamlFrontMatter::parse($content); + $slug = pathinfo($filePath, PATHINFO_FILENAME); + + $cleanContent = $this->stripBladeComponents($document->body()); + + return [ + 'id' => "{$platform}/{$version}/{$section}/{$slug}", + 'platform' => $platform, + 'version' => $version, + 'section' => $section, + 'slug' => $slug, + 'title' => $document->matter('title') ?? $slug, + 'description' => $document->matter('description') ?? '', + 'content' => $cleanContent, + 'headings' => $this->extractHeadings($cleanContent), + 'order' => $document->matter('order') ?? 9999, + ]; + } + + protected function stripBladeComponents(string $content): string + { + // Remove ... tags + $content = preg_replace('/]+>[\s\S]*?<\/x-[^>]+>/s', '', $content); + // Remove self-closing tags + $content = preg_replace('//s', '', $content); + // Remove {{ }} blade echoes + $content = preg_replace('/\{\{.*?\}\}/s', '', $content); + // Remove {!! !!} unescaped echoes + $content = preg_replace('/\{!![\s\S]*?!!\}/s', '', $content); + // Remove @directives + $content = preg_replace('/@\w+(\([^)]*\))?/', '', $content); + + return $content; + } + + protected function extractHeadings(string $content): array + { + preg_match_all('/^#{2,3}\s+(.+)$/m', $content, $matches); + + return $matches[1] ?? []; + } + + protected function tokenize(string $text): array + { + return collect(preg_split('/\s+/', Str::lower($text))) + ->filter(fn ($word) => strlen($word) > 2) + ->values() + ->toArray(); + } + + protected function calculateScore(array $page, array $queryTerms): float + { + $score = 0; + $titleLower = Str::lower($page['title']); + $descLower = Str::lower($page['description']); + $contentLower = Str::lower($page['content']); + $headingsLower = Str::lower(implode(' ', $page['headings'])); + + foreach ($queryTerms as $term) { + // Title matches (highest weight) + if (Str::contains($titleLower, $term)) { + $score += 10; + } + + // Heading matches + if (Str::contains($headingsLower, $term)) { + $score += 5; + } + + // Description matches + if (Str::contains($descLower, $term)) { + $score += 3; + } + + // Content matches + $contentMatches = substr_count($contentLower, $term); + $score += min($contentMatches, 5); // Cap at 5 content matches + } + + return $score; + } + + protected function extractSnippet(string $content, array $queryTerms, int $length = 200): string + { + $contentLower = Str::lower($content); + + foreach ($queryTerms as $term) { + $pos = strpos($contentLower, $term); + if ($pos !== false) { + $start = max(0, $pos - 50); + $snippet = substr($content, $start, $length); + + if ($start > 0) { + $snippet = '...'.$snippet; + } + if ($start + $length < strlen($content)) { + $snippet .= '...'; + } + + return preg_replace('/\s+/', ' ', trim($snippet)); + } + } + + return Str::limit(preg_replace('/\s+/', ' ', $content), $length); + } +} diff --git a/routes/web.php b/routes/web.php index ec50f34f..7bbf8d59 100644 --- a/routes/web.php +++ b/routes/web.php @@ -40,6 +40,19 @@ // OpenCollective donation claim route Route::get('opencollective/claim', App\Livewire\ClaimDonationLicense::class)->name('opencollective.claim'); +// MCP Server routes +Route::prefix('mcp')->group(function () { + Route::get('sse', [App\Http\Controllers\McpController::class, 'sse'])->name('mcp.sse'); + Route::post('message', [App\Http\Controllers\McpController::class, 'message'])->name('mcp.message'); + Route::get('health', [App\Http\Controllers\McpController::class, 'health'])->name('mcp.health'); + + // REST API endpoints + Route::get('api/search', [App\Http\Controllers\McpController::class, 'searchApi'])->name('mcp.api.search'); + Route::get('api/page/{platform}/{version}/{section}/{slug}', [App\Http\Controllers\McpController::class, 'pageApi'])->name('mcp.api.page'); + Route::get('api/apis/{platform}/{version}', [App\Http\Controllers\McpController::class, 'apisApi'])->name('mcp.api.apis'); + Route::get('api/navigation/{platform}/{version}', [App\Http\Controllers\McpController::class, 'navigationApi'])->name('mcp.api.navigation'); +}); + Route::view('/', 'welcome')->name('welcome'); Route::view('pricing', 'pricing')->name('pricing'); Route::view('alt-pricing', 'alt-pricing')->name('alt-pricing')->middleware('signed');