Skip to content

Commit af5237f

Browse files
committed
Merge branch 'main' into v3
2 parents 7eeb3e8 + ee0f990 commit af5237f

File tree

4 files changed

+663
-0
lines changed

4 files changed

+663
-0
lines changed
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Services\DocsSearchService;
6+
use Illuminate\Http\JsonResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Support\Str;
9+
use Symfony\Component\HttpFoundation\StreamedResponse;
10+
11+
class McpController extends Controller
12+
{
13+
public function __construct(
14+
protected DocsSearchService $docsSearch
15+
) {}
16+
17+
/**
18+
* SSE endpoint for MCP clients
19+
*/
20+
public function sse(Request $request): StreamedResponse
21+
{
22+
$sessionId = Str::uuid()->toString();
23+
24+
return response()->stream(function () use ($sessionId) {
25+
// Send session info
26+
$this->sendSseEvent([
27+
'type' => 'session',
28+
'sessionId' => $sessionId,
29+
]);
30+
31+
// Send server info
32+
$this->sendSseEvent([
33+
'type' => 'serverInfo',
34+
'name' => 'nativephp-docs',
35+
'version' => '1.0.0',
36+
'capabilities' => ['tools' => new \stdClass],
37+
]);
38+
39+
// Send available tools
40+
$this->sendSseEvent([
41+
'type' => 'tools',
42+
'tools' => $this->getToolDefinitions(),
43+
]);
44+
45+
// Keep connection alive
46+
while (true) {
47+
if (connection_aborted()) {
48+
break;
49+
}
50+
echo ": keepalive\n\n";
51+
ob_flush();
52+
flush();
53+
sleep(30);
54+
}
55+
}, 200, [
56+
'Content-Type' => 'text/event-stream',
57+
'Cache-Control' => 'no-cache',
58+
'Connection' => 'keep-alive',
59+
'X-Accel-Buffering' => 'no',
60+
]);
61+
}
62+
63+
/**
64+
* JSON-RPC message endpoint for tool calls
65+
*/
66+
public function message(Request $request): JsonResponse
67+
{
68+
$method = $request->input('method');
69+
$params = $request->input('params', []);
70+
$id = $request->input('id');
71+
72+
try {
73+
$result = match ($method) {
74+
'tools/list' => ['tools' => $this->getToolDefinitions()],
75+
'tools/call' => $this->handleToolCall($params['name'] ?? '', $params['arguments'] ?? []),
76+
default => throw new \InvalidArgumentException("Unknown method: {$method}"),
77+
};
78+
79+
return response()->json([
80+
'jsonrpc' => '2.0',
81+
'id' => $id,
82+
'result' => $result,
83+
]);
84+
} catch (\Throwable $e) {
85+
return response()->json([
86+
'jsonrpc' => '2.0',
87+
'id' => $id,
88+
'error' => [
89+
'code' => -32000,
90+
'message' => $e->getMessage(),
91+
],
92+
]);
93+
}
94+
}
95+
96+
/**
97+
* Health check endpoint
98+
*/
99+
public function health(): JsonResponse
100+
{
101+
$versions = $this->docsSearch->getVersions();
102+
$pageCount = count($this->docsSearch->search('', null, null, 1000));
103+
104+
return response()->json([
105+
'status' => 'ok',
106+
'versions' => $versions,
107+
'pages' => $pageCount,
108+
]);
109+
}
110+
111+
// REST API endpoints for simpler integrations
112+
113+
public function searchApi(Request $request): JsonResponse
114+
{
115+
$query = $request->input('q', '');
116+
$platform = $request->input('platform');
117+
$version = $request->input('version');
118+
$limit = (int) $request->input('limit', 10);
119+
120+
if (empty($query)) {
121+
return response()->json(['error' => 'Missing query parameter: q'], 400);
122+
}
123+
124+
$results = $this->docsSearch->search($query, $platform, $version, $limit);
125+
126+
return response()->json(['results' => $results]);
127+
}
128+
129+
public function pageApi(string $platform, string $version, string $section, string $slug): JsonResponse
130+
{
131+
$page = $this->docsSearch->getPage($platform, $version, $section, $slug);
132+
133+
if (! $page) {
134+
return response()->json(['error' => 'Page not found'], 404);
135+
}
136+
137+
return response()->json(['page' => $page]);
138+
}
139+
140+
public function apisApi(string $platform, string $version): JsonResponse
141+
{
142+
$apis = $this->docsSearch->listApis($platform, $version);
143+
144+
return response()->json(['apis' => $apis]);
145+
}
146+
147+
public function navigationApi(string $platform, string $version): JsonResponse
148+
{
149+
$nav = $this->docsSearch->getNavigation($platform, $version);
150+
151+
return response()->json(['navigation' => $nav]);
152+
}
153+
154+
protected function getToolDefinitions(): array
155+
{
156+
$latestVersions = $this->docsSearch->getLatestVersions();
157+
158+
return [
159+
[
160+
'name' => 'search_docs',
161+
'description' => "Search NativePHP documentation. Latest versions: desktop v{$latestVersions['desktop']}, mobile v{$latestVersions['mobile']}.",
162+
'inputSchema' => [
163+
'type' => 'object',
164+
'properties' => [
165+
'query' => [
166+
'type' => 'string',
167+
'description' => 'Search query (e.g., "camera permissions", "window management")',
168+
],
169+
'platform' => [
170+
'type' => 'string',
171+
'enum' => ['desktop', 'mobile'],
172+
'description' => 'Filter by platform (optional)',
173+
],
174+
'version' => [
175+
'type' => 'string',
176+
'description' => 'Filter by version number (optional)',
177+
],
178+
'limit' => [
179+
'type' => 'number',
180+
'description' => 'Max results to return (default: 10)',
181+
],
182+
],
183+
'required' => ['query'],
184+
],
185+
],
186+
[
187+
'name' => 'get_page',
188+
'description' => 'Get full content of a documentation page by path (e.g., "mobile/3/apis/camera")',
189+
'inputSchema' => [
190+
'type' => 'object',
191+
'properties' => [
192+
'path' => [
193+
'type' => 'string',
194+
'description' => 'Page path: platform/version/section/slug',
195+
],
196+
],
197+
'required' => ['path'],
198+
],
199+
],
200+
[
201+
'name' => 'list_apis',
202+
'description' => 'List all native APIs for a platform/version',
203+
'inputSchema' => [
204+
'type' => 'object',
205+
'properties' => [
206+
'platform' => [
207+
'type' => 'string',
208+
'enum' => ['desktop', 'mobile'],
209+
'description' => 'Platform to list APIs for',
210+
],
211+
'version' => [
212+
'type' => 'string',
213+
'description' => 'Version number',
214+
],
215+
],
216+
'required' => ['platform', 'version'],
217+
],
218+
],
219+
[
220+
'name' => 'get_navigation',
221+
'description' => 'Get the docs navigation structure for a platform/version',
222+
'inputSchema' => [
223+
'type' => 'object',
224+
'properties' => [
225+
'platform' => [
226+
'type' => 'string',
227+
'enum' => ['desktop', 'mobile'],
228+
'description' => 'Platform',
229+
],
230+
'version' => [
231+
'type' => 'string',
232+
'description' => 'Version number',
233+
],
234+
],
235+
'required' => ['platform', 'version'],
236+
],
237+
],
238+
];
239+
}
240+
241+
protected function handleToolCall(string $name, array $args): array
242+
{
243+
return match ($name) {
244+
'search_docs' => $this->toolSearchDocs($args),
245+
'get_page' => $this->toolGetPage($args),
246+
'list_apis' => $this->toolListApis($args),
247+
'get_navigation' => $this->toolGetNavigation($args),
248+
default => [
249+
'content' => [['type' => 'text', 'text' => "Unknown tool: {$name}"]],
250+
'isError' => true,
251+
],
252+
};
253+
}
254+
255+
protected function toolSearchDocs(array $args): array
256+
{
257+
$query = $args['query'] ?? '';
258+
$platform = $args['platform'] ?? null;
259+
$version = $args['version'] ?? null;
260+
$limit = $args['limit'] ?? 10;
261+
262+
$results = $this->docsSearch->search($query, $platform, $version, $limit);
263+
264+
if (empty($results)) {
265+
$filterDesc = '';
266+
if ($platform) {
267+
$filterDesc .= " in {$platform}";
268+
}
269+
if ($version) {
270+
$filterDesc .= " v{$version}";
271+
}
272+
273+
return [
274+
'content' => [['type' => 'text', 'text' => "No results found for \"{$query}\"{$filterDesc}"]],
275+
];
276+
}
277+
278+
$formatted = collect($results)->map(function ($r, $i) {
279+
$num = $i + 1;
280+
281+
return "{$num}. **{$r['title']}** ({$r['platform']}/v{$r['version']}/{$r['section']})\n Path: {$r['id']}\n {$r['snippet']}";
282+
})->join("\n\n");
283+
284+
return [
285+
'content' => [['type' => 'text', 'text' => 'Found '.count($results)." results for \"{$query}\":\n\n{$formatted}"]],
286+
];
287+
}
288+
289+
protected function toolGetPage(array $args): array
290+
{
291+
$path = $args['path'] ?? '';
292+
$page = $this->docsSearch->getPageByPath($path);
293+
294+
if (! $page) {
295+
return [
296+
'content' => [['type' => 'text', 'text' => "Page not found: {$path}"]],
297+
];
298+
}
299+
300+
$text = "# {$page['title']}\n\n";
301+
$text .= "**Platform:** {$page['platform']} | **Version:** {$page['version']} | **Section:** {$page['section']}\n\n";
302+
$text .= $page['content'];
303+
304+
return [
305+
'content' => [['type' => 'text', 'text' => $text]],
306+
];
307+
}
308+
309+
protected function toolListApis(array $args): array
310+
{
311+
$platform = $args['platform'] ?? '';
312+
$version = $args['version'] ?? '';
313+
314+
$apis = $this->docsSearch->listApis($platform, $version);
315+
316+
if (empty($apis)) {
317+
return [
318+
'content' => [['type' => 'text', 'text' => "No APIs found for {$platform} v{$version}"]],
319+
];
320+
}
321+
322+
$formatted = collect($apis)->map(function ($api) {
323+
$desc = $api['description'] ?: 'No description';
324+
325+
return "- **{$api['title']}** ({$api['slug']})\n {$desc}";
326+
})->join("\n");
327+
328+
return [
329+
'content' => [['type' => 'text', 'text' => "# {$platform} v{$version} APIs\n\n{$formatted}"]],
330+
];
331+
}
332+
333+
protected function toolGetNavigation(array $args): array
334+
{
335+
$platform = $args['platform'] ?? '';
336+
$version = $args['version'] ?? '';
337+
338+
$nav = $this->docsSearch->getNavigation($platform, $version);
339+
340+
if (empty($nav)) {
341+
return [
342+
'content' => [['type' => 'text', 'text' => "No navigation found for {$platform} v{$version}"]],
343+
];
344+
}
345+
346+
$formatted = collect($nav)->map(function ($pages, $section) {
347+
$pageList = collect($pages)->map(fn ($p) => " - {$p['title']} ({$p['slug']})")->join("\n");
348+
349+
return "## {$section}\n{$pageList}";
350+
})->join("\n\n");
351+
352+
return [
353+
'content' => [['type' => 'text', 'text' => "# {$platform} v{$version} Navigation\n\n{$formatted}"]],
354+
];
355+
}
356+
357+
protected function sendSseEvent(array $data): void
358+
{
359+
echo 'data: '.json_encode($data)."\n\n";
360+
ob_flush();
361+
flush();
362+
}
363+
}

app/Http/Middleware/VerifyCsrfToken.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware
1414
protected $except = [
1515
'stripe/webhook',
1616
'opencollective/contribution',
17+
'mcp/*',
1718
];
1819
}

0 commit comments

Comments
 (0)