Skip to content

Commit 8d48b5c

Browse files
committed
first commit 🔥
1 parent a6867e7 commit 8d48b5c

6 files changed

Lines changed: 337 additions & 9 deletions

File tree

docs/mcp-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ This command will:
2828
"command": "php",
2929
"args": [
3030
"artisan",
31-
"mcp:serve",
31+
"mcp:start",
3232
"laravilt-plugins"
3333
]
3434
}

src/Commands/InstallMcpServerCommand.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,10 @@ protected function createMcpConfig(): void
140140
'command' => 'php',
141141
'args' => [
142142
'artisan',
143-
'mcp:serve',
143+
'mcp:start',
144144
'laravilt-plugins',
145145
],
146+
'cwd' => base_path(),
146147
];
147148

148149
// Write config

src/Mcp/LaraviltPluginsServer.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Laravilt\Plugins\Mcp\Tools\ListPluginsTool;
1010
use Laravilt\Plugins\Mcp\Tools\PluginInfoTool;
1111
use Laravilt\Plugins\Mcp\Tools\PluginStructureTool;
12+
use Laravilt\Plugins\Mcp\Tools\SearchDocsTool;
1213

1314
class LaraviltPluginsServer extends Server
1415
{
@@ -33,8 +34,9 @@ class LaraviltPluginsServer extends Server
3334
- Get detailed information about specific plugins
3435
- Generate new plugins with various features
3536
- Generate components within plugins (models, controllers, migrations, etc.)
36-
- View plugin directory structures
37+
- View plugin directory structures (excluding vendor/node_modules)
3738
- List available component types
39+
- Search plugin documentation to understand features and architecture
3840
3941
All plugins are located in the packages/laravilt directory.
4042
MARKDOWN;
@@ -51,5 +53,6 @@ class LaraviltPluginsServer extends Server
5153
GenerateComponentTool::class,
5254
ListComponentTypesTool::class,
5355
PluginStructureTool::class,
56+
SearchDocsTool::class,
5457
];
5558
}

src/Mcp/Tools/PluginStructureTool.php

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,113 @@ public function schema(JsonSchema $schema): array
5252
protected function getDirectoryTree(string $path, string $prefix = ''): string
5353
{
5454
$tree = '';
55-
$items = File::allFiles($path);
55+
$ignoredPatterns = $this->getIgnoredPatterns($path);
5656

57-
foreach ($items as $item) {
58-
$relativePath = str_replace($path.'/', '', $item->getPathname());
59-
$tree .= $prefix.$relativePath."\n";
57+
// Create a custom recursive directory iterator that skips ignored paths
58+
$directory = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS);
59+
$filter = new class($directory, $ignoredPatterns, $path) extends \RecursiveFilterIterator
60+
{
61+
private array $ignoredPatterns;
62+
63+
private string $basePath;
64+
65+
public function __construct(\RecursiveDirectoryIterator $iterator, array $ignoredPatterns, string $basePath)
66+
{
67+
parent::__construct($iterator);
68+
$this->ignoredPatterns = $ignoredPatterns;
69+
$this->basePath = rtrim($basePath, '/');
70+
}
71+
72+
public function accept(): bool
73+
{
74+
$item = $this->current();
75+
$relativePath = str_replace($this->basePath.'/', '', $item->getPathname());
76+
77+
// If this is a directory and it matches an ignore pattern, skip it entirely
78+
if ($item->isDir() && $this->shouldIgnore($relativePath)) {
79+
return false;
80+
}
81+
82+
// For files, check if their path matches an ignore pattern
83+
return ! $this->shouldIgnore($relativePath);
84+
}
85+
86+
public function getChildren(): ?\RecursiveFilterIterator
87+
{
88+
return new self($this->getInnerIterator()->getChildren(), $this->ignoredPatterns, $this->basePath);
89+
}
90+
91+
private function shouldIgnore(string $path): bool
92+
{
93+
foreach ($this->ignoredPatterns as $pattern) {
94+
// Remove leading slash for comparison
95+
$pattern = ltrim($pattern, '/');
96+
97+
// Handle directory patterns (ending with /)
98+
if (str_ends_with($pattern, '/')) {
99+
$pattern = rtrim($pattern, '/');
100+
if (str_starts_with($path, $pattern.'/') || $path === $pattern) {
101+
return true;
102+
}
103+
}
104+
// Handle wildcard patterns
105+
elseif (str_contains($pattern, '*')) {
106+
// Convert glob pattern to regex
107+
$regex = '/^'.str_replace(['\*'], ['.*'], preg_quote($pattern, '/')).'$/';
108+
if (preg_match($regex, $path)) {
109+
return true;
110+
}
111+
}
112+
// Handle exact match or prefix match
113+
else {
114+
if ($path === $pattern || str_starts_with($path, $pattern.'/')) {
115+
return true;
116+
}
117+
}
118+
}
119+
120+
return false;
121+
}
122+
};
123+
124+
$iterator = new \RecursiveIteratorIterator($filter, \RecursiveIteratorIterator::SELF_FIRST);
125+
126+
foreach ($iterator as $item) {
127+
// Only include files, not directories
128+
if ($item->isFile()) {
129+
$relativePath = str_replace($path.'/', '', $item->getPathname());
130+
$tree .= $prefix.$relativePath."\n";
131+
}
60132
}
61133

62134
return $tree;
63135
}
136+
137+
/**
138+
* Get ignored patterns from .gitignore file.
139+
*/
140+
protected function getIgnoredPatterns(string $path): array
141+
{
142+
$patterns = [
143+
'vendor/',
144+
'node_modules/',
145+
];
146+
147+
$gitignorePath = $path.'/.gitignore';
148+
if (File::exists($gitignorePath)) {
149+
$gitignoreContent = File::get($gitignorePath);
150+
$lines = explode("\n", $gitignoreContent);
151+
152+
foreach ($lines as $line) {
153+
$line = trim($line);
154+
// Skip empty lines and comments
155+
if (empty($line) || str_starts_with($line, '#')) {
156+
continue;
157+
}
158+
$patterns[] = $line;
159+
}
160+
}
161+
162+
return $patterns;
163+
}
64164
}

src/Mcp/Tools/SearchDocsTool.php

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
namespace Laravilt\Plugins\Mcp\Tools;
4+
5+
use Illuminate\JsonSchema\JsonSchema;
6+
use Illuminate\Support\Facades\File;
7+
use Laravel\Mcp\Request;
8+
use Laravel\Mcp\Response;
9+
use Laravel\Mcp\Server\Tool;
10+
11+
class SearchDocsTool extends Tool
12+
{
13+
/**
14+
* The tool's description.
15+
*/
16+
protected string $description = 'Search the Laravilt Plugins documentation to understand features, architecture, and usage';
17+
18+
/**
19+
* Handle the tool request.
20+
*/
21+
public function handle(Request $request): Response
22+
{
23+
$query = $request->string('query');
24+
$pluginsPath = base_path('packages/laravilt/plugins');
25+
26+
// Collect all documentation files
27+
$docFiles = $this->getDocumentationFiles($pluginsPath);
28+
29+
// Search through documentation
30+
$results = $this->searchDocumentation($docFiles, $query);
31+
32+
if (empty($results)) {
33+
return Response::text("No documentation found matching '{$query}'.");
34+
}
35+
36+
// Format results
37+
$output = "Documentation Search Results for: {$query}\n\n";
38+
$output .= 'Found '.count($results)." relevant section(s):\n\n";
39+
40+
foreach ($results as $result) {
41+
$output .= "📄 {$result['file']}\n";
42+
$output .= str_repeat('=', 60)."\n\n";
43+
$output .= $result['content']."\n\n";
44+
$output .= str_repeat('-', 60)."\n\n";
45+
}
46+
47+
return Response::text($output);
48+
}
49+
50+
/**
51+
* Get the tool's input schema.
52+
*
53+
* @return array<string, \Illuminate\JsonSchema\JsonSchema>
54+
*/
55+
public function schema(JsonSchema $schema): array
56+
{
57+
return [
58+
'query' => $schema->string()
59+
->description('Search query (e.g., "plugin generation", "MCP tools", "factory pattern", "component types")')
60+
->required(),
61+
];
62+
}
63+
64+
/**
65+
* Get all documentation files.
66+
*/
67+
protected function getDocumentationFiles(string $path): array
68+
{
69+
$files = [];
70+
71+
// README.md - Main documentation
72+
if (File::exists($path.'/README.md')) {
73+
$files[] = [
74+
'path' => $path.'/README.md',
75+
'name' => 'README.md',
76+
'content' => File::get($path.'/README.md'),
77+
];
78+
}
79+
80+
// All docs/ files
81+
$docsPath = $path.'/docs';
82+
if (File::isDirectory($docsPath)) {
83+
foreach (File::allFiles($docsPath) as $file) {
84+
if ($file->getExtension() === 'md') {
85+
$relativePath = str_replace($path.'/', '', $file->getPathname());
86+
$files[] = [
87+
'path' => $file->getPathname(),
88+
'name' => $relativePath,
89+
'content' => File::get($file->getPathname()),
90+
];
91+
}
92+
}
93+
}
94+
95+
return $files;
96+
}
97+
98+
/**
99+
* Search documentation files for relevant content.
100+
*/
101+
protected function searchDocumentation(array $files, string $query): array
102+
{
103+
$results = [];
104+
$queryLower = strtolower($query);
105+
$keywords = explode(' ', $queryLower);
106+
107+
foreach ($files as $file) {
108+
$content = $file['content'];
109+
$contentLower = strtolower($content);
110+
111+
// Check if any keyword matches
112+
$matchCount = 0;
113+
foreach ($keywords as $keyword) {
114+
if (stripos($contentLower, $keyword) !== false) {
115+
$matchCount++;
116+
}
117+
}
118+
119+
// If matches found, extract relevant sections
120+
if ($matchCount > 0) {
121+
$sections = $this->extractRelevantSections($content, $query, $keywords);
122+
123+
foreach ($sections as $section) {
124+
$results[] = [
125+
'file' => $file['name'],
126+
'content' => $section,
127+
'relevance' => $matchCount,
128+
];
129+
}
130+
}
131+
}
132+
133+
// Sort by relevance (highest first)
134+
usort($results, fn ($a, $b) => $b['relevance'] <=> $a['relevance']);
135+
136+
// Limit to top 5 most relevant results
137+
return array_slice($results, 0, 5);
138+
}
139+
140+
/**
141+
* Extract relevant sections from content based on query.
142+
*/
143+
protected function extractRelevantSections(string $content, string $query, array $keywords): array
144+
{
145+
$sections = [];
146+
$lines = explode("\n", $content);
147+
148+
// Split by markdown headers to get sections
149+
$currentSection = '';
150+
$currentHeader = '';
151+
$inRelevantSection = false;
152+
153+
foreach ($lines as $line) {
154+
// Check if line is a header
155+
if (preg_match('/^#+\s+(.+)$/', $line, $matches)) {
156+
// Save previous section if it was relevant
157+
if ($inRelevantSection && ! empty(trim($currentSection))) {
158+
$sections[] = trim($currentHeader."\n\n".$currentSection);
159+
}
160+
161+
// Start new section
162+
$currentHeader = $line;
163+
$currentSection = '';
164+
165+
// Check if header is relevant
166+
$headerLower = strtolower($matches[1]);
167+
$inRelevantSection = false;
168+
foreach ($keywords as $keyword) {
169+
if (stripos($headerLower, $keyword) !== false) {
170+
$inRelevantSection = true;
171+
break;
172+
}
173+
}
174+
} else {
175+
// Add to current section
176+
$currentSection .= $line."\n";
177+
178+
// Check if content line is relevant
179+
if (! $inRelevantSection) {
180+
$lineLower = strtolower($line);
181+
foreach ($keywords as $keyword) {
182+
if (stripos($lineLower, $keyword) !== false) {
183+
$inRelevantSection = true;
184+
break;
185+
}
186+
}
187+
}
188+
}
189+
}
190+
191+
// Save last section if relevant
192+
if ($inRelevantSection && ! empty(trim($currentSection))) {
193+
$sections[] = trim($currentHeader."\n\n".$currentSection);
194+
}
195+
196+
// If no sections found, return intro (first 50 lines that match)
197+
if (empty($sections)) {
198+
$matchingLines = [];
199+
foreach ($lines as $line) {
200+
$lineLower = strtolower($line);
201+
foreach ($keywords as $keyword) {
202+
if (stripos($lineLower, $keyword) !== false) {
203+
$matchingLines[] = $line;
204+
if (count($matchingLines) >= 50) {
205+
break 2;
206+
}
207+
break;
208+
}
209+
}
210+
}
211+
212+
if (! empty($matchingLines)) {
213+
$sections[] = implode("\n", $matchingLines);
214+
}
215+
}
216+
217+
return $sections;
218+
}
219+
}

0 commit comments

Comments
 (0)