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
62 changes: 3 additions & 59 deletions app/Http/Controllers/McpController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,62 +6,13 @@
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
{
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
*/
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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' => [
Expand Down Expand Up @@ -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();
}
}
45 changes: 40 additions & 5 deletions app/Services/DocsSearchService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 === '') {
Expand Down
8 changes: 8 additions & 0 deletions resources/views/components/footer.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,14 @@ class="inline-block px-px py-1.5 transition duration-300 will-change-transform h
Wall of Love
</a>
</li>
<li>
<a
href="{{ route('mcp') }}"
class="inline-block px-px py-1.5 transition duration-300 will-change-transform hover:translate-x-1 hover:text-gray-700 dark:hover:text-gray-300"
>
MCP
</a>
</li>
@feature(App\Features\ShowAuthButtons::class)
<li>
<a
Expand Down
154 changes: 154 additions & 0 deletions resources/views/mcp-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
Everything published in the NativePHP documentation — for both
[Mobile](/docs/mobile/getting-started/introduction) and
[Desktop](/docs/desktop/getting-started/introduction) — is available over
[MCP](https://modelcontextprotocol.io). Any agent that speaks the protocol —
Claude Code, Cursor, Copilot, and friends — can search and read the docs while
it works, instead of relying on whatever it happened to memorise during training.

It's hosted by us. There's nothing to install and no API key to create; just
point your agent at this URL:

```
https://nativephp.com/api/mcp/message
```

## Adding it to your agent

### Claude Code

Add it from the terminal:

```shell
claude mcp add --transport http nativephp-docs https://nativephp.com/api/mcp/message
```

Or commit the config to your repo as `.mcp.json` so everyone on the project
picks it up automatically:

```json
{
"mcpServers": {
"nativephp-docs": {
"type": "http",
"url": "https://nativephp.com/api/mcp/message"
}
}
}
```

### Cursor

Create `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` to enable it
everywhere:

```json
{
"mcpServers": {
"nativephp-docs": {
"type": "http",
"url": "https://nativephp.com/api/mcp/message"
}
}
}
```

### VS Code and GitHub Copilot

Create `.vscode/mcp.json`. Note that VS Code uses `servers` where the others
use `mcpServers`:

```json
{
"servers": {
"nativephp-docs": {
"type": "http",
"url": "https://nativephp.com/api/mcp/message"
}
}
}
```

### Agents that only speak stdio

Some agents can't talk to a remote server directly. Bridge to it with
`mcp-remote`:

```json
{
"mcpServers": {
"nativephp-docs": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://nativephp.com/api/mcp/message"
]
}
}
}
```

The server speaks the Streamable HTTP transport over a single endpoint, so
`/api/mcp/message` is the only URL your agent needs.

## What your agent can do

Once connected, your agent gets these tools.

### `search_docs`

Full-text search across every platform and version. Takes a `query`, plus an
optional `platform` (`mobile` or `desktop`), `version`, and `limit` (10 by
default). Every result comes back with its title, section, a matching snippet,
and the path you'd hand to `get_page`.

This is the one agents reach for most. _"How do I ask for camera permission?"_
gets answered from the docs rather than from memory.

### `get_page`

Fetches a full page by its `path`, in `platform/version/section/slug` form — for
example `mobile/4/the-basics/device`, or `mobile/4/plugins/core/camera` where the
section is itself nested. Paths come straight out of `search_docs` results, so
agents generally chain the two.

### `get_navigation`

Returns the whole sidebar for a `platform` and `version`, grouped by section and
in the order you see it on the site. Useful when an agent wants to orient itself
before searching, or to check whether a topic is documented at all.

### `list_apis`

Lists the pages in a version's `apis` section. That section only exists in the
Mobile v1 and v2 docs — from v3 onwards the native APIs are documented under
Plugins, and the Desktop docs have no `apis` section at all. For anything
current, use `get_navigation` or `search_docs` instead.

## Reading pages without MCP

Every docs page is served as raw markdown by adding `.md` to its URL, which is
often the quickest way to hand an agent one specific page:

```shell
curl https://nativephp.com/docs/mobile/4/plugins/core/camera.md
```

There's also a small REST API, if you'd rather script against it than wire up an
MCP client:

- `/api/mcp/search?q=camera&platform=mobile` — search results as JSON
- `/api/mcp/page/{platform}/{version}/{section}/{slug}` — a single page
- `/api/mcp/navigation/{platform}/{version}` — the docs navigation tree
- `/api/mcp/apis/{platform}/{version}` — the `apis` section listing
- `/api/mcp/health` — liveness check, and the versions currently published

Both the MCP and REST endpoints are rate limited to 60 requests per minute per
IP address.

## Pair it with Laravel Boost

This server tells your agent what NativePHP _can_ do.
[Laravel Boost](https://laravel.com/ai/boost) tells it about _your_ application:
your routes, models, config, and installed package versions. They complement
each other, and running both gives noticeably better results than either alone.
Loading