From 816ca82271558b4709d3cee41ff073674c50fba8 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 11 Aug 2026 13:14:33 +0100 Subject: [PATCH] Add Docs MCP Server page and fix get_page path resolution Documents the docs MCP server on a standalone /mcp page linked from the footer, covering per-agent setup, the tools, and the REST endpoints. Also fixes two defects the page would otherwise have to warn about: - search_docs returned ids get_page could not resolve. The section was derived from the parent directory basename, so a page nested in a subsection came back as mobile/4/core/camera while the file lives at plugins/core/camera.md. Sections now carry their full relative path, which also matches the public docs URLs. The page-list cache key is bumped so entries cached under the old shape are not reused. - Removes /api/mcp/sse. It never sent the endpoint event the HTTP+SSE transport requires, so compliant clients connected and hung, and it held a PHP-FPM worker per connection in a keepalive loop. Clients use the Streamable HTTP endpoint at /api/mcp/message. Co-Authored-By: Claude Opus 5 (1M context) --- app/Http/Controllers/McpController.php | 62 +------- app/Services/DocsSearchService.php | 45 +++++- resources/views/components/footer.blade.php | 8 + resources/views/mcp-content.md | 154 ++++++++++++++++++++ resources/views/mcp.blade.php | 74 ++++++++++ routes/api.php | 7 +- routes/web.php | 1 + tests/Feature/DocsMcpServerPageTest.php | 140 ++++++++++++++++++ tests/Feature/McpSecurityTest.php | 14 ++ 9 files changed, 439 insertions(+), 66 deletions(-) create mode 100644 resources/views/mcp-content.md create mode 100644 resources/views/mcp.blade.php create mode 100644 tests/Feature/DocsMcpServerPageTest.php 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 +
  • + + MCP + +
  • @feature(App\Features\ShowAuthButtons::class)
  • + {{-- Hero --}} +
    +
    + {{-- Blurred circle - Decorative --}} + + + {{-- Primary Heading --}} +

    + Docs MCP Server +

    + + {{-- Standfirst --}} +

    + Give your AI coding agent the NativePHP documentation, so it + stops guessing at APIs. +

    +
    + + {{-- Divider --}} + + + {{-- Content --}} +
    + {!! App\Support\CommonMark\CommonMark::convertToHtml(file_get_contents(resource_path('views/mcp-content.md'))) !!} +
    +
    + diff --git a/routes/api.php b/routes/api.php index 8d36506d6..e4f270a94 100644 --- a/routes/api.php +++ b/routes/api.php @@ -20,13 +20,16 @@ // MCP Server routes (no session/cookies - fixes CSRF 419 errors) Route::prefix('mcp')->group(function (): void { - Route::get('sse', [McpController::class, 'sse'])->name('mcp.sse'); Route::post('message', [McpController::class, 'message'])->name('mcp.message'); Route::get('health', [McpController::class, 'health'])->name('mcp.health'); // REST API endpoints Route::get('search', [McpController::class, 'searchApi'])->name('mcp.api.search'); - Route::get('page/{platform}/{version}/{section}/{slug}', [McpController::class, 'pageApi'])->name('mcp.api.page'); + // The trailing path is a wildcard so pages nested in a subsection + // (e.g. mobile/4/plugins/core/camera) resolve as well as flat ones. + Route::get('page/{platform}/{version}/{path}', [McpController::class, 'pageApi']) + ->where('path', '.*') + ->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'); }); diff --git a/routes/web.php b/routes/web.php index d3e8caaf9..390f62cdb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -227,6 +227,7 @@ Route::view('consulting', 'consulting')->name('consulting'); Route::view('the-vibes', 'the-vibes')->name('the-vibes'); Route::view('the-vibes-prospectus', 'the-vibes-prospectus')->name('the-vibes-prospectus'); +Route::view('mcp', 'mcp')->name('mcp'); // Public plugin directory routes Route::middleware(EnsureFeaturesAreActive::using(ShowPlugins::class))->group(function (): void { diff --git a/tests/Feature/DocsMcpServerPageTest.php b/tests/Feature/DocsMcpServerPageTest.php new file mode 100644 index 000000000..486be8344 --- /dev/null +++ b/tests/Feature/DocsMcpServerPageTest.php @@ -0,0 +1,140 @@ + 'test-token']); + Http::fake([ + '*' => Http::response(['blocks' => []], 200), + ]); + } + + #[Test] + public function it_documents_the_endpoint_clients_should_connect_to(): void + { + $this->withoutVite() + ->get(route('mcp')) + ->assertOk() + ->assertSee('Docs MCP Server') + ->assertSee('https://nativephp.com/api/mcp/message'); + } + + #[Test] + public function it_shows_the_config_snippets_without_evaluating_them_as_blade(): void + { + $this->withoutVite() + ->get(route('mcp')) + ->assertOk() + ->assertSee('mcpServers') + ->assertSee('"servers"') + ->assertSee('mcp-remote'); + } + + #[Test] + public function it_is_reachable_from_the_footer_on_every_page(): void + { + $content = $this->withoutVite() + ->get(route('welcome')) + ->assertOk() + ->getContent(); + + $this->assertMatchesRegularExpression( + '/]*>\s*MCP\s*<\/a>/', + $content, + 'The footer should link to the MCP page, labelled "MCP".', + ); + } + + #[Test] + public function the_documented_message_endpoint_lists_the_documented_tools(): void + { + $response = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + ]); + + $response->assertOk(); + + $tools = collect($response->json('result.tools'))->pluck('name')->all(); + + $this->assertEqualsCanonicalizing( + ['search_docs', 'get_page', 'list_apis', 'get_navigation'], + $tools, + ); + } + + /** + * The documented workflow is search, then fetch. A path that search hands + * back has to be one get_page can actually resolve, including for pages + * nested in a subsection. + */ + #[Test] + public function every_search_result_path_can_be_fetched_by_get_page(): void + { + $paths = collect(app(DocsSearchService::class)->search('camera', 'mobile', '4', 10)) + ->pluck('id'); + + $this->assertTrue($paths->contains('mobile/4/plugins/core/camera')); + + foreach ($paths as $path) { + $response = $this->postJson('/api/mcp/message', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => 'get_page', 'arguments' => ['path' => $path]], + ]); + + $this->assertStringNotContainsString( + 'Page not found', + $response->json('result.content.0.text'), + "search_docs returned {$path}, which get_page could not resolve.", + ); + } + } + + #[Test] + public function the_sse_route_is_gone_so_clients_cannot_hang_on_it(): void + { + $this->getJson('/api/mcp/sse')->assertNotFound(); + + $this->assertNull( + app('router')->getRoutes()->getByName('mcp.sse'), + 'The SSE route never sent the endpoint event its transport requires.', + ); + } + + #[Test] + public function the_documented_raw_markdown_url_serves_a_page(): void + { + $this->get('/docs/mobile/4/plugins/core/camera.md') + ->assertOk() + ->assertHeader('Content-Type', 'text/plain; charset=utf-8'); + } + + #[Test] + public function the_docs_no_longer_carry_a_duplicate_copy_of_this_page(): void + { + $this->assertSame( + [], + glob(resource_path('views/docs/*/*/**/mcp-server.md')) ?: [], + 'The MCP server is documented once, at '.route('mcp').'.', + ); + } +} diff --git a/tests/Feature/McpSecurityTest.php b/tests/Feature/McpSecurityTest.php index 9d7af19ad..216686f9f 100644 --- a/tests/Feature/McpSecurityTest.php +++ b/tests/Feature/McpSecurityTest.php @@ -53,6 +53,20 @@ public function test_page_api_rejects_path_traversal(): void $response->assertStatus(404); } + public function test_page_api_rejects_traversal_hidden_in_a_nested_section(): void + { + $response = $this->getJson('/api/mcp/page/mobile/4/plugins/../../../../../../etc/passwd'); + + $response->assertStatus(404); + } + + public function test_page_api_rejects_an_absolute_looking_section(): void + { + $response = $this->getJson('/api/mcp/page/mobile/4//etc/passwd'); + + $response->assertStatus(404); + } + public function test_apis_endpoint_rejects_invalid_platform(): void { $response = $this->getJson('/api/mcp/apis/../1');