diff --git a/app/Http/Controllers/McpController.php b/app/Http/Controllers/McpController.php index 0c1e2e14d..c61ebb922 100644 --- a/app/Http/Controllers/McpController.php +++ b/app/Http/Controllers/McpController.php @@ -6,9 +6,6 @@ use App\Services\DocsSearchService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Sleep; -use Illuminate\Support\Str; -use Symfony\Component\HttpFoundation\StreamedResponse; class McpController extends Controller { @@ -16,52 +13,6 @@ public function __construct( protected DocsSearchService $docsSearch ) {} - /** - * SSE endpoint for MCP clients - */ - public function sse(Request $request): StreamedResponse - { - $sessionId = Str::uuid()->toString(); - - return response()->stream(function () use ($sessionId): void { - // 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::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 */ @@ -129,9 +80,9 @@ public function searchApi(McpSearchRequest $request): JsonResponse return response()->json(['results' => $results]); } - public function pageApi(string $platform, string $version, string $section, string $slug): JsonResponse + public function pageApi(string $platform, string $version, string $path): JsonResponse { - $page = $this->docsSearch->getPage($platform, $version, $section, $slug); + $page = $this->docsSearch->getPageByPath("{$platform}/{$version}/{$path}"); if (! $page) { return response()->json(['error' => 'Page not found'], 404); @@ -188,7 +139,7 @@ protected function getToolDefinitions(): array ], [ 'name' => 'get_page', - 'description' => 'Get full content of a documentation page by path (e.g., "mobile/3/apis/camera")', + 'description' => 'Get full content of a documentation page by path (e.g., "mobile/4/plugins/core/camera")', 'inputSchema' => [ 'type' => 'object', 'properties' => [ @@ -370,11 +321,4 @@ protected function toolGetNavigation(array $args): array '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/Services/DocsSearchService.php b/app/Services/DocsSearchService.php index 7aed6cacf..249f7f4c5 100644 --- a/app/Services/DocsSearchService.php +++ b/app/Services/DocsSearchService.php @@ -49,7 +49,7 @@ public function getPage(string $platform, string $version, string $section, stri { $platform = $this->sanitizePlatform($platform); $version = $this->sanitizeVersion($version); - $section = $this->sanitizePathSegment($section); + $section = $this->sanitizeSectionPath($section); $slug = $this->sanitizePathSegment($slug); if (! $platform || ! $version || ! $section || ! $slug) { @@ -65,6 +65,11 @@ public function getPage(string $platform, string $version, string $section, stri return $this->parsePage($filePath, $platform, $version, $section); } + /** + * Resolve a page from a `platform/version/section/slug` path. The section + * may itself be nested (e.g. `mobile/4/plugins/core/camera`), so anything + * between the version and the slug is treated as the section path. + */ public function getPageByPath(string $path): ?array { $parts = explode('/', $path); @@ -73,7 +78,11 @@ public function getPageByPath(string $path): ?array return null; } - return $this->getPage($parts[0], $parts[1], $parts[2], $parts[3]); + $platform = array_shift($parts); + $version = array_shift($parts); + $slug = array_pop($parts); + + return $this->getPage($platform, $version, implode('/', $parts), $slug); } public function listApis(string $platform, string $version): array @@ -150,7 +159,7 @@ protected function sectionRanks(string $platform, string $version): array foreach (glob("{$base}/{$slug}/*/_index.md") ?: [] as $nested) { $nestedSlug = basename(dirname($nested)); $nestedOrder = YamlFrontMatter::parse(file_get_contents($nested))->matter('order') ?? 9999; - $rank[$nestedSlug] = $rank[$slug] + 1 + min($nestedOrder, 9998); + $rank["{$slug}/{$nestedSlug}"] = $rank[$slug] + 1 + min($nestedOrder, 9998); } } @@ -202,7 +211,9 @@ protected function getAllPages(?string $platform = null, ?string $version = null return []; } - $cacheKey = 'mcp_docs_pages_'.($platform ?? 'all').'_'.($version ?? 'all'); + // v2 keys: page ids now carry the full section path, so entries cached + // under the old shape must not be reused after a deploy. + $cacheKey = 'mcp_docs_pages_v2_'.($platform ?? 'all').'_'.($version ?? 'all'); if (config('app.env') !== 'local') { $cached = Cache::get($cacheKey); @@ -232,7 +243,10 @@ protected function getAllPages(?string $platform = null, ?string $version = null ->in($versionPath); foreach ($finder as $file) { - $section = basename(dirname($file->getPathname())); + // Relative to the version directory, so a page nested in a + // subsection keeps its full section path (`plugins/core`) + // and the ids we hand out stay resolvable by getPage(). + $section = $file->getRelativePath(); $page = $this->parsePage($file->getPathname(), $plat, $ver, $section); if ($page) { $pages[] = $page; @@ -393,6 +407,27 @@ protected function sanitizeVersion(?string $version): ?string return preg_match('/^[0-9]+$/', $version) ? $version : null; } + /** + * Validate a section path, which may nest (e.g. `plugins/core`). Every + * segment is checked on its own so traversal can't hide behind a separator. + */ + protected function sanitizeSectionPath(?string $section): ?string + { + if ($section === null || $section === '') { + return null; + } + + $segments = explode('/', $section); + + foreach ($segments as $segment) { + if (! $this->sanitizePathSegment($segment)) { + return null; + } + } + + return implode('/', $segments); + } + protected function sanitizePathSegment(?string $segment): ?string { if ($segment === null || $segment === '') { diff --git a/resources/views/components/footer.blade.php b/resources/views/components/footer.blade.php index e2dd53028..3027ff341 100644 --- a/resources/views/components/footer.blade.php +++ b/resources/views/components/footer.blade.php @@ -296,6 +296,14 @@ class="inline-block px-px py-1.5 transition duration-300 will-change-transform h Wall of Love +
+ Give your AI coding agent the NativePHP documentation, so it + stops guessing at APIs. +
+