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');