diff --git a/app/Mcp/Servers/KnowledgeServer.php b/app/Mcp/Servers/KnowledgeServer.php new file mode 100644 index 0000000..93da666 --- /dev/null +++ b/app/Mcp/Servers/KnowledgeServer.php @@ -0,0 +1,33 @@ +get('project')) ? $request->get('project') : $this->projectDetector->detect(); + + /** @var array|null $categories */ + $categories = is_array($request->get('categories')) ? $request->get('categories') : null; + $maxTokens = is_int($request->get('max_tokens')) ? min($request->get('max_tokens'), 16000) : 4000; + $limit = is_int($request->get('limit')) ? min($request->get('limit'), 100) : 50; + + $entries = $this->fetchEntries($categories, $limit, $project); + + if ($entries === []) { + $available = $this->qdrant->listCollections(); + $projects = array_map( + fn (string $c): string => str_replace('knowledge_', '', $c), + $available + ); + + return Response::text(json_encode([ + 'project' => $project, + 'entries' => [], + 'total' => 0, + 'message' => "No knowledge entries found for project '{$project}'.", + 'available_projects' => array_values($projects), + ], JSON_THROW_ON_ERROR)); + } + + $ranked = $this->rankEntries($entries); + $grouped = $this->groupByCategory($ranked); + $truncated = $this->truncateToTokenBudget($grouped, $maxTokens); + + $totalEntries = array_sum(array_map('count', $truncated)); + + return Response::text(json_encode([ + 'project' => $project, + 'categories' => $truncated, + 'total' => $totalEntries, + 'available' => count($entries), + ], JSON_THROW_ON_ERROR)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'project' => $schema->string() + ->description('Project namespace. Auto-detected from git if omitted.'), + 'categories' => $schema->array() + ->description('Filter to specific categories (e.g., ["architecture", "debugging"]).'), + 'max_tokens' => $schema->integer() + ->description('Maximum approximate token budget for response (default 4000).') + ->default(4000), + 'limit' => $schema->integer() + ->description('Maximum entries to fetch (default 50).') + ->default(50), + ]; + } + + /** + * @param array|null $categories + * @return array> + */ + private function fetchEntries(?array $categories, int $limit, string $project): array + { + if ($categories !== null && $categories !== []) { + $entries = []; + $perCategory = max(1, intdiv($limit, count($categories))); + + foreach ($categories as $category) { + $results = $this->qdrant->scroll( + ['category' => $category], + $perCategory, + $project + ); + + foreach ($results->all() as $entry) { + $entries[] = $entry; + } + } + + return $entries; + } + + return $this->qdrant->scroll([], $limit, $project)->all(); + } + + /** + * @param array> $entries + * @return array> + */ + private function rankEntries(array $entries): array + { + $now = time(); + + usort($entries, function (array $a, array $b) use ($now): int { + $scoreA = $this->entryScore($a, $now); + $scoreB = $this->entryScore($b, $now); + + return $scoreB <=> $scoreA; + }); + + return $entries; + } + + /** + * @param array $entry + */ + private function entryScore(array $entry, int $now): float + { + $usageCount = (int) ($entry['usage_count'] ?? 0); + $updatedAt = $entry['updated_at'] ?? ''; + $timestamp = is_string($updatedAt) && $updatedAt !== '' ? strtotime($updatedAt) : $now; + + if ($timestamp === false) { + $timestamp = $now; + } + + $daysAgo = max(1, (int) (($now - $timestamp) / 86400)); + + return ($usageCount * 2.0) + (100.0 / $daysAgo); + } + + /** + * @param array> $entries + * @return array>> + */ + private function groupByCategory(array $entries): array + { + $grouped = []; + + foreach ($entries as $entry) { + $category = is_string($entry['category'] ?? null) && ($entry['category'] ?? '') !== '' + ? $entry['category'] + : 'uncategorized'; + + $grouped[$category][] = $this->formatEntry($entry); + } + + $ordered = []; + + foreach (self::CATEGORY_ORDER as $cat) { + if (isset($grouped[$cat])) { + $ordered[$cat] = $grouped[$cat]; + unset($grouped[$cat]); + } + } + + ksort($grouped); + + return array_merge($ordered, $grouped); + } + + /** + * @param array>> $grouped + * @return array>> + */ + private function truncateToTokenBudget(array $grouped, int $maxTokens): array + { + $maxChars = $maxTokens * self::CHARS_PER_TOKEN; + $charCount = 0; + $result = []; + + foreach ($grouped as $category => $entries) { + $categoryEntries = []; + + foreach ($entries as $entry) { + $entryJson = json_encode($entry, JSON_THROW_ON_ERROR); + $entryLen = strlen($entryJson); + + if ($charCount + $entryLen > $maxChars) { + break 2; + } + + $categoryEntries[] = $entry; + $charCount += $entryLen; + } + + if ($categoryEntries !== []) { + $result[$category] = $categoryEntries; + } + } + + return $result; + } + + /** + * @param array $entry + * @return array + */ + private function formatEntry(array $entry): array + { + $effectiveConfidence = $this->metadata->calculateEffectiveConfidence($entry); + + return [ + 'id' => $entry['id'], + 'title' => $entry['title'] ?? '', + 'content' => $entry['content'] ?? '', + 'confidence' => $effectiveConfidence, + 'freshness' => $this->metadata->isStale($entry) ? 'stale' : 'fresh', + 'priority' => $entry['priority'] ?? 'medium', + 'tags' => $entry['tags'] ?? [], + ]; + } +} diff --git a/app/Mcp/Tools/CorrectTool.php b/app/Mcp/Tools/CorrectTool.php new file mode 100644 index 0000000..fe1f0d3 --- /dev/null +++ b/app/Mcp/Tools/CorrectTool.php @@ -0,0 +1,62 @@ +get('id'); + $correctedContent = $request->get('corrected_content'); + + if (! is_string($id) || $id === '') { + return Response::error('Provide the ID of the entry to correct.'); + } + + if (! is_string($correctedContent) || strlen($correctedContent) < 10) { + return Response::error('Provide corrected content of at least 10 characters.'); + } + + try { + $result = $this->correctionService->correct($id, $correctedContent); + + return Response::text(json_encode([ + 'status' => 'corrected', + 'corrected_entry_id' => $result['corrected_entry_id'], + 'original_id' => $id, + 'superseded_ids' => $result['superseded_ids'], + 'conflicts_resolved' => $result['conflicts_found'], + 'message' => "Entry corrected. New entry: {$result['corrected_entry_id']}. " + .count($result['superseded_ids']).' related entries superseded.', + ], JSON_THROW_ON_ERROR)); + } catch (\RuntimeException $e) { + return Response::error('Correction failed: '.$e->getMessage()); + } + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->string() + ->description('ID of the entry to correct (from a previous recall result).') + ->required(), + 'corrected_content' => $schema->string() + ->description('The corrected information that replaces the wrong content.') + ->required(), + ]; + } +} diff --git a/app/Mcp/Tools/RecallTool.php b/app/Mcp/Tools/RecallTool.php new file mode 100644 index 0000000..f55c8a2 --- /dev/null +++ b/app/Mcp/Tools/RecallTool.php @@ -0,0 +1,154 @@ +get('query'); + + if (! is_string($query) || strlen($query) < 2) { + return Response::error('A search query of at least 2 characters is required.'); + } + + $project = is_string($request->get('project')) ? $request->get('project') : $this->projectDetector->detect(); + $limit = is_int($request->get('limit')) ? min($request->get('limit'), 20) : 5; + $global = (bool) ($request->get('global') ?? false); + + $filters = array_filter([ + 'category' => is_string($request->get('category')) ? $request->get('category') : null, + 'tag' => is_string($request->get('tag')) ? $request->get('tag') : null, + ]); + + if ($global) { + return $this->searchGlobal($query, $filters, $limit); + } + + $results = $this->tieredSearch->search($query, $filters, $limit, project: $project); + + if ($results->isEmpty()) { + return Response::text(json_encode([ + 'results' => [], + 'meta' => [ + 'query' => $query, + 'project' => $project, + 'total' => 0, + ], + ], JSON_THROW_ON_ERROR)); + } + + $formatted = $results->map(fn (array $entry): array => $this->formatEntry($entry))->values()->all(); + + return Response::text(json_encode([ + 'results' => $formatted, + 'meta' => [ + 'query' => $query, + 'project' => $project, + 'total' => count($formatted), + 'search_tier' => $results->first()['tier_label'] ?? 'unknown', + ], + ], JSON_THROW_ON_ERROR)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'query' => $schema->string() + ->description('Natural language search query (e.g., "how do we handle database migrations", "Laravel testing patterns")') + ->required(), + 'project' => $schema->string() + ->description('Project namespace. Auto-detected from git if omitted.'), + 'category' => $schema->string() + ->enum(['architecture', 'patterns', 'decisions', 'gotchas', 'debugging', 'testing', 'deployment', 'security']) + ->description('Filter results by category.'), + 'tag' => $schema->string() + ->description('Filter results by tag.'), + 'limit' => $schema->integer() + ->description('Maximum results to return (default 5, max 20).') + ->default(5), + 'global' => $schema->boolean() + ->description('Search across all projects instead of just the current one.') + ->default(false), + ]; + } + + /** + * @param array $filters + */ + private function searchGlobal(string $query, array $filters, int $limit): Response + { + $collections = $this->qdrant->listCollections(); + $allResults = []; + + foreach ($collections as $collection) { + $projectName = str_replace('knowledge_', '', $collection); + $results = $this->tieredSearch->search($query, $filters, $limit, project: $projectName); + + foreach ($results as $entry) { + $entry['project'] = $projectName; + $allResults[] = $this->formatEntry($entry); + } + } + + usort($allResults, fn (array $a, array $b): int => $b['relevance_score'] <=> $a['relevance_score']); + $allResults = array_slice($allResults, 0, $limit); + + return Response::text(json_encode([ + 'results' => $allResults, + 'meta' => [ + 'query' => $query, + 'project' => 'global', + 'total' => count($allResults), + 'collections_searched' => count($collections), + ], + ], JSON_THROW_ON_ERROR)); + } + + /** + * @param array $entry + * @return array + */ + private function formatEntry(array $entry): array + { + $effectiveConfidence = $this->metadata->calculateEffectiveConfidence($entry); + $isStale = $this->metadata->isStale($entry); + + return [ + 'id' => $entry['id'], + 'title' => $entry['title'] ?? '', + 'content' => $entry['content'] ?? '', + 'category' => $entry['category'] ?? null, + 'tags' => $entry['tags'] ?? [], + 'confidence' => $effectiveConfidence, + 'freshness' => $isStale ? 'stale' : 'fresh', + 'relevance_score' => round((float) ($entry['tiered_score'] ?? $entry['score'] ?? 0.0), 3), + 'project' => $entry['project'] ?? $entry['_project'] ?? null, + ]; + } +} diff --git a/app/Mcp/Tools/RememberTool.php b/app/Mcp/Tools/RememberTool.php new file mode 100644 index 0000000..f077a12 --- /dev/null +++ b/app/Mcp/Tools/RememberTool.php @@ -0,0 +1,131 @@ +get('title'); + $content = $request->get('content'); + + if (! is_string($title) || strlen($title) < 5) { + return Response::error('A title of at least 5 characters is required.'); + } + + if (! is_string($content) || strlen($content) < 10) { + return Response::error('Content of at least 10 characters is required.'); + } + + $project = is_string($request->get('project')) ? $request->get('project') : $this->projectDetector->detect(); + + /** @var array|null $tags */ + $tags = is_array($request->get('tags')) ? $request->get('tags') : []; + + $entry = [ + 'id' => Str::uuid()->toString(), + 'title' => $title, + 'content' => $content, + 'category' => is_string($request->get('category')) ? $request->get('category') : null, + 'tags' => $tags, + 'priority' => is_string($request->get('priority')) ? $request->get('priority') : 'medium', + 'confidence' => is_int($request->get('confidence')) ? $request->get('confidence') : 50, + 'status' => 'draft', + 'evidence' => is_string($request->get('evidence')) ? $request->get('evidence') : null, + 'last_verified' => now()->toIso8601String(), + ]; + + // Auto-populate git context + if ($this->gitContext->isGitRepository()) { + $context = $this->gitContext->getContext(); + $entry['repo'] = $context['repo'] ?? null; + $entry['branch'] = $context['branch'] ?? null; + $entry['commit'] = $context['commit'] ?? null; + $entry['author'] = $context['author'] ?? null; + } + + // Write gate validation + $gateResult = $this->writeGate->evaluate($entry); + if (! $gateResult['passed']) { + return Response::error('Write gate rejected: '.$gateResult['reason'].'. Improve the entry quality and try again.'); + } + + // Store with duplicate detection + try { + $this->qdrant->upsert($entry, $project, true); + } catch (DuplicateEntryException $e) { + return Response::text(json_encode([ + 'status' => 'duplicate_detected', + 'existing_id' => $e->existingId, + 'similarity' => $e->similarityScore !== null ? round($e->similarityScore * 100, 1) : null, + 'message' => "Similar entry already exists (ID: {$e->existingId}). Use the `correct` tool to update it, or add more distinct content.", + ], JSON_THROW_ON_ERROR)); + } + + // Queue for Ollama auto-tagging + if ((bool) config('search.ollama.enabled', true)) { + $this->enhancementQueue->queue($entry); + } + + return Response::text(json_encode([ + 'status' => 'created', + 'id' => $entry['id'], + 'title' => $entry['title'], + 'project' => $project, + 'confidence' => $entry['confidence'], + 'message' => 'Knowledge entry captured successfully.', + ], JSON_THROW_ON_ERROR)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'title' => $schema->string() + ->description('Short summary of the knowledge (5-200 chars). e.g., "Pest arch() tests prevent namespace violations"') + ->required(), + 'content' => $schema->string() + ->description('Detailed description of the discovery or insight (10-10000 chars).') + ->required(), + 'category' => $schema->string() + ->enum(['architecture', 'patterns', 'decisions', 'gotchas', 'debugging', 'testing', 'deployment', 'security']) + ->description('Knowledge category. Omit to let auto-tagging classify it.'), + 'tags' => $schema->array() + ->description('Tags for categorization (max 10). e.g., ["laravel", "pest", "testing"]'), + 'priority' => $schema->string() + ->enum(['critical', 'high', 'medium', 'low']) + ->description('Priority level.') + ->default('medium'), + 'confidence' => $schema->integer() + ->description('How confident you are in this knowledge (0-100). Default 50.') + ->default(50), + 'evidence' => $schema->string() + ->description('Supporting evidence or reference URL.'), + 'project' => $schema->string() + ->description('Project namespace. Auto-detected from git if omitted.'), + ]; + } +} diff --git a/app/Mcp/Tools/StatsTool.php b/app/Mcp/Tools/StatsTool.php new file mode 100644 index 0000000..bcd2994 --- /dev/null +++ b/app/Mcp/Tools/StatsTool.php @@ -0,0 +1,59 @@ +get('project')) ? $request->get('project') : $this->projectDetector->detect(); + + $collections = $this->qdrant->listCollections(); + $projects = []; + + foreach ($collections as $collection) { + $projectName = str_replace('knowledge_', '', $collection); + $count = $this->qdrant->count($projectName); + $projects[$projectName] = $count; + } + + $currentProjectCount = $projects[$project] ?? 0; + $totalEntries = array_sum($projects); + + return Response::text(json_encode([ + 'current_project' => $project, + 'current_project_entries' => $currentProjectCount, + 'total_entries' => $totalEntries, + 'projects' => $projects, + 'project_count' => count($projects), + ], JSON_THROW_ON_ERROR)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'project' => $schema->string() + ->description('Project to focus on. Auto-detected from git if omitted.'), + ]; + } +} diff --git a/app/Providers/McpServiceProvider.php b/app/Providers/McpServiceProvider.php new file mode 100644 index 0000000..ecb0a59 --- /dev/null +++ b/app/Providers/McpServiceProvider.php @@ -0,0 +1,63 @@ +app->singleton(Registrar::class, fn (): Registrar => new Registrar); + + $this->mergeConfigFrom( + base_path('vendor/laravel/mcp/config/mcp.php'), + 'mcp' + ); + } + + public function boot(): void + { + $this->registerContainerCallbacks(); + $this->loadRoutes(); + + if ($this->app->runningInConsole()) { + $this->commands([StartCommand::class]); + } + } + + private function registerContainerCallbacks(): void + { + $this->app->resolving(Request::class, function (Request $request, $app): void { + if ($app->bound('mcp.request')) { + /** @var Request $currentRequest */ + $currentRequest = $app->make('mcp.request'); + + $request->setArguments($currentRequest->all()); + $request->setSessionId($currentRequest->sessionId()); + $request->setMeta($currentRequest->meta()); + } + }); + } + + private function loadRoutes(): void + { + $path = base_path('routes/ai.php'); + + if (file_exists($path)) { + require $path; + } + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d..a501d1b 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\McpServiceProvider::class, ]; diff --git a/composer.json b/composer.json index cb0d516..4707cf3 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "php": "^8.2", "illuminate/database": "^12.17", "laravel-zero/framework": "^12.0.2", + "laravel/mcp": "^0.6.0", "saloonphp/saloon": "^3.14", "symfony/uid": "^8.0" }, diff --git a/composer.lock b/composer.lock index 172480e..95abd29 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2fb8042162e7b54f70de58d01f92ce7c", + "content-hash": "81a5724d1efaf7499f2dea856b5b6775", "packages": [ { "name": "brick/math", @@ -225,6 +225,83 @@ ], "time": "2025-08-10T19:31:58+00:00" }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -289,6 +366,73 @@ ], "time": "2025-10-31T18:51:33+00:00" }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, { "name": "filp/whoops", "version": "2.18.4", @@ -360,6 +504,77 @@ ], "time": "2025-08-08T12:00:00+00:00" }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.4", @@ -747,6 +962,92 @@ ], "time": "2025-08-23T21:21:41+00:00" }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:27:06+00:00" + }, { "name": "illuminate/bus", "version": "v12.49.0", @@ -1387,21 +1688,37 @@ "time": "2026-01-19T15:15:34+00:00" }, { - "name": "illuminate/macroable", - "version": "v12.49.0", + "name": "illuminate/http", + "version": "v12.53.0", "source": { "type": "git", - "url": "https://github.com/illuminate/macroable.git", - "reference": "e862e5648ee34004fa56046b746f490dfa86c613" + "url": "https://github.com/illuminate/http.git", + "reference": "b6351a4b8d3b6b1aef4b08cb98892045b20aa0ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/macroable/zipball/e862e5648ee34004fa56046b746f490dfa86c613", - "reference": "e862e5648ee34004fa56046b746f490dfa86c613", + "url": "https://api.github.com/repos/illuminate/http/zipball/b6351a4b8d3b6b1aef4b08cb98892045b20aa0ad", + "reference": "b6351a4b8d3b6b1aef4b08cb98892045b20aa0ad", "shasum": "" }, "require": { - "php": "^8.2" + "ext-filter": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "illuminate/collections": "^12.0", + "illuminate/macroable": "^12.0", + "illuminate/session": "^12.0", + "illuminate/support": "^12.0", + "php": "^8.2", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php85": "^1.33" + }, + "suggest": { + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image()." }, "type": "library", "extra": { @@ -1411,7 +1728,7 @@ }, "autoload": { "psr-4": { - "Illuminate\\Support\\": "" + "Illuminate\\Http\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -1424,36 +1741,31 @@ "email": "taylor@laravel.com" } ], - "description": "The Illuminate Macroable package.", + "description": "The Illuminate Http package.", "homepage": "https://laravel.com", "support": { "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2024-07-23T16:31:01+00:00" + "time": "2026-02-23T15:41:33+00:00" }, { - "name": "illuminate/pipeline", - "version": "v12.49.0", + "name": "illuminate/json-schema", + "version": "v12.53.0", "source": { "type": "git", - "url": "https://github.com/illuminate/pipeline.git", - "reference": "b6a14c20d69a44bf0a6fba664a00d23ca71770ee" + "url": "https://github.com/illuminate/json-schema.git", + "reference": "207509531ee53b2c5b966c51c9c63355f8e96e1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/pipeline/zipball/b6a14c20d69a44bf0a6fba664a00d23ca71770ee", - "reference": "b6a14c20d69a44bf0a6fba664a00d23ca71770ee", + "url": "https://api.github.com/repos/illuminate/json-schema/zipball/207509531ee53b2c5b966c51c9c63355f8e96e1e", + "reference": "207509531ee53b2c5b966c51c9c63355f8e96e1e", "shasum": "" }, "require": { - "illuminate/contracts": "^12.0", - "illuminate/macroable": "^12.0", - "illuminate/support": "^12.0", - "php": "^8.2" - }, - "suggest": { - "illuminate/database": "Required to use database transactions (^12.0)." + "illuminate/contracts": "^10.50.0|^11.47.0|^12.40.2", + "php": "^8.1" }, "type": "library", "extra": { @@ -1463,7 +1775,7 @@ }, "autoload": { "psr-4": { - "Illuminate\\Pipeline\\": "" + "Illuminate\\JsonSchema\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -1476,21 +1788,119 @@ "email": "taylor@laravel.com" } ], - "description": "The Illuminate Pipeline package.", + "description": "The Illuminate Json Schema package.", "homepage": "https://laravel.com", "support": { "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-08-20T13:36:50+00:00" + "time": "2026-02-24T14:03:17+00:00" }, { - "name": "illuminate/process", + "name": "illuminate/macroable", "version": "v12.49.0", "source": { "type": "git", - "url": "https://github.com/illuminate/process.git", - "reference": "b3c34ed7d090fc995addf8c928b700bf34a93956" + "url": "https://github.com/illuminate/macroable.git", + "reference": "e862e5648ee34004fa56046b746f490dfa86c613" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/e862e5648ee34004fa56046b746f490dfa86c613", + "reference": "e862e5648ee34004fa56046b746f490dfa86c613", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2024-07-23T16:31:01+00:00" + }, + { + "name": "illuminate/pipeline", + "version": "v12.49.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/pipeline.git", + "reference": "b6a14c20d69a44bf0a6fba664a00d23ca71770ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/pipeline/zipball/b6a14c20d69a44bf0a6fba664a00d23ca71770ee", + "reference": "b6a14c20d69a44bf0a6fba664a00d23ca71770ee", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^12.0", + "illuminate/macroable": "^12.0", + "illuminate/support": "^12.0", + "php": "^8.2" + }, + "suggest": { + "illuminate/database": "Required to use database transactions (^12.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Pipeline\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Pipeline package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-08-20T13:36:50+00:00" + }, + { + "name": "illuminate/process", + "version": "v12.49.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/process.git", + "reference": "b3c34ed7d090fc995addf8c928b700bf34a93956" }, "dist": { "type": "zip", @@ -1586,6 +1996,129 @@ }, "time": "2025-12-09T15:11:22+00:00" }, + { + "name": "illuminate/routing", + "version": "v12.53.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/routing.git", + "reference": "e20c98c90bc005a3785f630802c29dab8c6631b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/routing/zipball/e20c98c90bc005a3785f630802c29dab8c6631b1", + "reference": "e20c98c90bc005a3785f630802c29dab8c6631b1", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-hash": "*", + "illuminate/collections": "^12.0", + "illuminate/container": "^12.0", + "illuminate/contracts": "^12.0", + "illuminate/http": "^12.0", + "illuminate/macroable": "^12.0", + "illuminate/pipeline": "^12.0", + "illuminate/session": "^12.0", + "illuminate/support": "^12.0", + "php": "^8.2", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "symfony/routing": "^7.2.0" + }, + "suggest": { + "illuminate/console": "Required to use the make commands (^12.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Routing\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Routing package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-12T20:05:46+00:00" + }, + { + "name": "illuminate/session", + "version": "v12.53.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/session.git", + "reference": "98802e67dd5e059c0b978b3fe8f5f0a3ac17ec4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/session/zipball/98802e67dd5e059c0b978b3fe8f5f0a3ac17ec4e", + "reference": "98802e67dd5e059c0b978b3fe8f5f0a3ac17ec4e", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-session": "*", + "illuminate/collections": "^12.0", + "illuminate/contracts": "^12.0", + "illuminate/filesystem": "^12.0", + "illuminate/support": "^12.0", + "php": "^8.2", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0" + }, + "suggest": { + "illuminate/console": "Required to use the session:table command (^12.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Session\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Session package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-14T23:03:41+00:00" + }, { "name": "illuminate/support", "version": "v12.49.0", @@ -1726,6 +2259,120 @@ }, "time": "2026-01-20T17:18:54+00:00" }, + { + "name": "illuminate/translation", + "version": "v12.53.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/translation.git", + "reference": "18aa24aba6f2ab2447b9b903ae7360725fe5bdd0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/translation/zipball/18aa24aba6f2ab2447b9b903ae7360725fe5bdd0", + "reference": "18aa24aba6f2ab2447b9b903ae7360725fe5bdd0", + "shasum": "" + }, + "require": { + "illuminate/collections": "^12.0", + "illuminate/contracts": "^12.0", + "illuminate/filesystem": "^12.0", + "illuminate/macroable": "^12.0", + "illuminate/support": "^12.0", + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Translation\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Translation package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-06T12:12:31+00:00" + }, + { + "name": "illuminate/validation", + "version": "v12.53.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/validation.git", + "reference": "bfd544135cb4784c8664a0b5de7cf16830768e8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/validation/zipball/bfd544135cb4784c8664a0b5de7cf16830768e8b", + "reference": "bfd544135cb4784c8664a0b5de7cf16830768e8b", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "egulias/email-validator": "^3.2.5|^4.0", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^12.0", + "illuminate/container": "^12.0", + "illuminate/contracts": "^12.0", + "illuminate/macroable": "^12.0", + "illuminate/support": "^12.0", + "illuminate/translation": "^12.0", + "php": "^8.2", + "symfony/http-foundation": "^7.2", + "symfony/mime": "^7.2", + "symfony/polyfill-php83": "^1.33" + }, + "suggest": { + "illuminate/database": "Required to use the database presence verifier (^12.0).", + "ramsey/uuid": "Required to use Validator::validateUuid() (^4.7)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Validation\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Validation package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-20T15:20:18+00:00" + }, { "name": "illuminate/view", "version": "v12.49.0", @@ -2042,34 +2689,107 @@ "email": "enunomaduro@gmail.com" }, { - "name": "Owen Voke", - "email": "development@voke.dev" + "name": "Owen Voke", + "email": "development@voke.dev" + } + ], + "description": "The Laravel Zero Framework.", + "homepage": "https://laravel-zero.com", + "keywords": [ + "Laravel Zero", + "cli", + "console", + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel-zero/laravel-zero/issues", + "source": "https://github.com/laravel-zero/laravel-zero" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-01-24T09:18:21+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.6.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "28860a10ca0cc5433e25d897ba7af844e6c7b6a2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/28860a10ca0cc5433e25d897ba7af844e6c7b6a2", + "reference": "28860a10ca0cc5433e25d897ba7af844e6c7b6a2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" } ], - "description": "The Laravel Zero Framework.", - "homepage": "https://laravel-zero.com", + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", "keywords": [ - "Laravel Zero", - "cli", - "console", - "framework", - "laravel" + "laravel", + "mcp" ], "support": { - "issues": "https://github.com/laravel-zero/laravel-zero/issues", - "source": "https://github.com/laravel-zero/laravel-zero" + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" }, - "funding": [ - { - "url": "https://www.paypal.com/paypalme/enunomaduro", - "type": "custom" - }, - { - "url": "https://github.com/nunomaduro", - "type": "github" - } - ], - "time": "2026-01-24T09:18:21+00:00" + "time": "2026-02-24T08:43:06+00:00" }, { "name": "laravel/prompts", @@ -3929,10 +4649,239 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools to manage errors and ease debugging PHP code", + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-20T16:42:42+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "dc2c0eba1af673e736bb851d747d266108aea746" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", + "reference": "dc2c0eba1af673e736bb851d747d266108aea746", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T11:45:34+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/finder/tree/v7.4.5" }, "funding": [ { @@ -3952,49 +4901,46 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-01-26T15:07:59+00:00" }, { - "name": "symfony/event-dispatcher", - "version": "v7.4.4", + "name": "symfony/http-foundation", + "version": "v7.4.6", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746" + "url": "https://github.com/symfony/http-foundation.git", + "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fd97d5e926e988a363cef56fbbf88c5c528e9065", + "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/event-dispatcher-contracts": "^2.5|^3" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/service-contracts": "<2.5" - }, - "provide": { - "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "2.0|3.0" + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" + "Symfony\\Component\\HttpFoundation\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -4014,10 +4960,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.6" }, "funding": [ { @@ -4037,40 +4983,87 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:34+00:00" + "time": "2026-02-21T16:25:55+00:00" }, { - "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "name": "symfony/http-kernel", + "version": "v7.4.6", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "url": "https://github.com/symfony/http-kernel.git", + "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", + "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/event-dispatcher": "^1" + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" - } + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Contracts\\EventDispatcher\\": "" - } + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4078,26 +5071,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to dispatching event", + "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.6" }, "funding": [ { @@ -4108,37 +5093,58 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-02-26T08:30:57+00:00" }, { - "name": "symfony/finder", - "version": "v7.4.5", + "name": "symfony/mime", + "version": "v7.4.6", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "url": "https://github.com/symfony/mime.git", + "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/mime/zipball/9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", + "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Finder\\": "" + "Symfony\\Component\\Mime\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -4158,10 +5164,14 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Finds files and directories via an intuitive fluent interface", + "description": "Allows manipulating MIME messages", "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/mime/tree/v7.4.6" }, "funding": [ { @@ -4181,7 +5191,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-02-05T15:57:06+00:00" }, { "name": "symfony/polyfill-ctype", @@ -4348,6 +5358,93 @@ ], "time": "2025-06-27T09:58:17+00:00" }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, { "name": "symfony/polyfill-intl-normalizer", "version": "v1.33.0", @@ -4990,6 +6087,91 @@ ], "time": "2026-01-26T15:07:59+00:00" }, + { + "name": "symfony/routing", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", + "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-25T16:50:00+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.6.1", diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 0000000..78f90f7 --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,6 @@ +group('mcp-tools'); + +beforeEach(function (): void { + $this->qdrant = Mockery::mock(QdrantService::class); + $this->metadata = Mockery::mock(EntryMetadataService::class); + $this->projectDetector = Mockery::mock(ProjectDetectorService::class); + + $this->tool = new ContextTool( + $this->qdrant, + $this->metadata, + $this->projectDetector, + ); +}); + +describe('context tool', function (): void { + it('returns empty results with available projects when no entries found', function (): void { + $this->projectDetector->shouldReceive('detect')->once()->andReturn('empty-project'); + $this->qdrant->shouldReceive('scroll') + ->once() + ->andReturn(collect()); + $this->qdrant->shouldReceive('listCollections') + ->once() + ->andReturn(['knowledge_default', 'knowledge_odin']); + + $request = new Request([]); + $response = $this->tool->handle($request); + + expect($response->isError())->toBeFalse(); + + $data = json_decode((string) $response->content(), true); + expect($data['total'])->toBe(0) + ->and($data['project'])->toBe('empty-project') + ->and($data['available_projects'])->toContain('default', 'odin'); + }); + + it('returns grouped and ranked entries', function (): void { + $this->projectDetector->shouldReceive('detect')->once()->andReturn('test-project'); + $this->qdrant->shouldReceive('scroll') + ->once() + ->andReturn(collect([ + [ + 'id' => 'entry-1', + 'title' => 'Architecture Pattern', + 'content' => 'Use hexagonal architecture.', + 'category' => 'architecture', + 'tags' => ['patterns'], + 'priority' => 'high', + 'usage_count' => 5, + 'updated_at' => now()->toIso8601String(), + 'confidence' => 80, + ], + [ + 'id' => 'entry-2', + 'title' => 'Debug Tip', + 'content' => 'Check logs first.', + 'category' => 'debugging', + 'tags' => [], + 'priority' => 'medium', + 'usage_count' => 2, + 'updated_at' => now()->subDays(30)->toIso8601String(), + 'confidence' => 60, + ], + ])); + + $this->metadata->shouldReceive('calculateEffectiveConfidence')->twice()->andReturn(75, 55); + $this->metadata->shouldReceive('isStale')->twice()->andReturn(false, true); + + $request = new Request([]); + $response = $this->tool->handle($request); + + $data = json_decode((string) $response->content(), true); + expect($data['total'])->toBe(2) + ->and($data['categories'])->toHaveKey('architecture') + ->and($data['categories'])->toHaveKey('debugging'); + }); + + it('filters by specific categories', function (): void { + $this->projectDetector->shouldReceive('detect')->once()->andReturn('test-project'); + $this->qdrant->shouldReceive('scroll') + ->with(['category' => 'architecture'], Mockery::any(), 'test-project') + ->once() + ->andReturn(collect([ + [ + 'id' => 'entry-1', + 'title' => 'Arch Pattern', + 'content' => 'Content here.', + 'category' => 'architecture', + 'tags' => [], + 'priority' => 'high', + 'usage_count' => 1, + 'updated_at' => now()->toIso8601String(), + 'confidence' => 80, + ], + ])); + + $this->metadata->shouldReceive('calculateEffectiveConfidence')->once()->andReturn(80); + $this->metadata->shouldReceive('isStale')->once()->andReturn(false); + + $request = new Request(['categories' => ['architecture']]); + $response = $this->tool->handle($request); + + $data = json_decode((string) $response->content(), true); + expect($data['total'])->toBe(1); + }); + + it('uses explicit project parameter', function (): void { + $this->projectDetector->shouldNotReceive('detect'); + $this->qdrant->shouldReceive('scroll') + ->withArgs(fn ($f, $l, $project) => $project === 'odin') + ->once() + ->andReturn(collect()); + $this->qdrant->shouldReceive('listCollections')->once()->andReturn([]); + + $request = new Request(['project' => 'odin']); + $this->tool->handle($request); + }); +}); diff --git a/tests/Unit/Mcp/Tools/CorrectToolTest.php b/tests/Unit/Mcp/Tools/CorrectToolTest.php new file mode 100644 index 0000000..2374d27 --- /dev/null +++ b/tests/Unit/Mcp/Tools/CorrectToolTest.php @@ -0,0 +1,81 @@ +group('mcp-tools'); + +beforeEach(function (): void { + $this->correctionService = Mockery::mock(CorrectionService::class); + $this->tool = new CorrectTool($this->correctionService); +}); + +describe('correct tool', function (): void { + it('returns error when id is missing', function (): void { + $request = new Request(['corrected_content' => 'New corrected content here.']); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeTrue(); + }); + + it('returns error when corrected content is missing', function (): void { + $request = new Request(['id' => 'entry-123']); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeTrue(); + }); + + it('returns error when corrected content is too short', function (): void { + $request = new Request(['id' => 'entry-123', 'corrected_content' => 'Short']); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeTrue(); + }); + + it('corrects entry and returns result', function (): void { + $this->correctionService->shouldReceive('correct') + ->with('entry-123', 'This is the corrected information that replaces the original.') + ->once() + ->andReturn([ + 'corrected_entry_id' => 'new-entry-456', + 'superseded_ids' => ['entry-789'], + 'conflicts_found' => 1, + ]); + + $request = new Request([ + 'id' => 'entry-123', + 'corrected_content' => 'This is the corrected information that replaces the original.', + ]); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeFalse(); + + $data = json_decode((string) $response->content(), true); + expect($data['status'])->toBe('corrected') + ->and($data['corrected_entry_id'])->toBe('new-entry-456') + ->and($data['original_id'])->toBe('entry-123') + ->and($data['conflicts_resolved'])->toBe(1); + }); + + it('handles correction service failures', function (): void { + $this->correctionService->shouldReceive('correct') + ->once() + ->andThrow(new RuntimeException('Entry not found')); + + $request = new Request([ + 'id' => 'nonexistent-id', + 'corrected_content' => 'Corrected content that will fail to apply.', + ]); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeTrue(); + }); +}); diff --git a/tests/Unit/Mcp/Tools/RecallToolTest.php b/tests/Unit/Mcp/Tools/RecallToolTest.php new file mode 100644 index 0000000..fe44314 --- /dev/null +++ b/tests/Unit/Mcp/Tools/RecallToolTest.php @@ -0,0 +1,132 @@ +group('mcp-tools'); + +beforeEach(function (): void { + $this->tieredSearch = Mockery::mock(TieredSearchService::class); + $this->qdrant = Mockery::mock(QdrantService::class); + $this->metadata = Mockery::mock(EntryMetadataService::class); + $this->projectDetector = Mockery::mock(ProjectDetectorService::class); + + $this->tool = new RecallTool( + $this->tieredSearch, + $this->qdrant, + $this->metadata, + $this->projectDetector, + ); +}); + +describe('recall tool', function (): void { + it('returns error when query is missing', function (): void { + $request = new Request([]); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeTrue(); + }); + + it('returns error when query is too short', function (): void { + $request = new Request(['query' => 'a']); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeTrue(); + }); + + it('returns empty results when nothing found', function (): void { + $this->projectDetector->shouldReceive('detect')->once()->andReturn('test-project'); + $this->tieredSearch->shouldReceive('search') + ->once() + ->andReturn(collect()); + + $request = new Request(['query' => 'test query']); + $response = $this->tool->handle($request); + + expect($response->isError())->toBeFalse(); + + $data = json_decode((string) $response->content(), true); + expect($data['results'])->toBeEmpty() + ->and($data['meta']['total'])->toBe(0) + ->and($data['meta']['project'])->toBe('test-project'); + }); + + it('returns formatted results with confidence and freshness', function (): void { + $this->projectDetector->shouldReceive('detect')->once()->andReturn('test-project'); + $this->tieredSearch->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'entry-1', + 'title' => 'Test Entry', + 'content' => 'Some content', + 'category' => 'architecture', + 'tags' => ['laravel'], + 'tiered_score' => 0.95, + 'tier_label' => 'exact', + 'confidence' => 80, + 'updated_at' => now()->toIso8601String(), + ], + ])); + + $this->metadata->shouldReceive('calculateEffectiveConfidence')->once()->andReturn(75); + $this->metadata->shouldReceive('isStale')->once()->andReturn(false); + + $request = new Request(['query' => 'test query']); + $response = $this->tool->handle($request); + + $data = json_decode((string) $response->content(), true); + expect($data['results'])->toHaveCount(1) + ->and($data['results'][0]['id'])->toBe('entry-1') + ->and($data['results'][0]['confidence'])->toBe(75) + ->and($data['results'][0]['freshness'])->toBe('fresh') + ->and($data['meta']['total'])->toBe(1); + }); + + it('uses explicit project when provided', function (): void { + $this->projectDetector->shouldNotReceive('detect'); + $this->tieredSearch->shouldReceive('search') + ->withArgs(fn ($q, $f, $l, $forceTier, $p) => $p === 'my-project') + ->once() + ->andReturn(collect()); + + $request = new Request(['query' => 'test', 'project' => 'my-project']); + $this->tool->handle($request); + }); + + it('searches globally across all collections', function (): void { + $this->projectDetector->shouldReceive('detect')->once()->andReturn('default'); + $this->qdrant->shouldReceive('listCollections') + ->once() + ->andReturn(['knowledge_project_a', 'knowledge_project_b']); + + $this->tieredSearch->shouldReceive('search') + ->twice() + ->andReturn(collect()); + + $request = new Request(['query' => 'test', 'global' => true]); + $response = $this->tool->handle($request); + + $data = json_decode((string) $response->content(), true); + expect($data['meta']['collections_searched'])->toBe(2); + }); + + it('respects limit parameter', function (): void { + $this->projectDetector->shouldReceive('detect')->once()->andReturn('default'); + $this->tieredSearch->shouldReceive('search') + ->withArgs(fn ($q, $f, $limit) => $limit === 10) + ->once() + ->andReturn(collect()); + + $request = new Request(['query' => 'test', 'limit' => 10]); + $this->tool->handle($request); + }); +}); diff --git a/tests/Unit/Mcp/Tools/RememberToolTest.php b/tests/Unit/Mcp/Tools/RememberToolTest.php new file mode 100644 index 0000000..2a90673 --- /dev/null +++ b/tests/Unit/Mcp/Tools/RememberToolTest.php @@ -0,0 +1,157 @@ +group('mcp-tools'); + +beforeEach(function (): void { + $this->qdrant = Mockery::mock(QdrantService::class); + $this->writeGate = Mockery::mock(WriteGateService::class); + $this->gitContext = Mockery::mock(GitContextService::class); + $this->projectDetector = Mockery::mock(ProjectDetectorService::class); + $this->enhancementQueue = Mockery::mock(EnhancementQueueService::class); + + $this->tool = new RememberTool( + $this->qdrant, + $this->writeGate, + $this->gitContext, + $this->projectDetector, + $this->enhancementQueue, + ); +}); + +describe('remember tool', function (): void { + it('returns error when title is missing', function (): void { + $request = new Request(['content' => 'Some long content here']); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeTrue(); + }); + + it('returns error when title is too short', function (): void { + $request = new Request(['title' => 'Hi', 'content' => 'Some long content here']); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeTrue(); + }); + + it('returns error when content is missing', function (): void { + $request = new Request(['title' => 'Valid Title']); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeTrue(); + }); + + it('returns error when content is too short', function (): void { + $request = new Request(['title' => 'Valid Title', 'content' => 'Short']); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeTrue(); + }); + + it('creates entry successfully with git context', function (): void { + $this->projectDetector->shouldReceive('detect')->once()->andReturn('test-project'); + $this->gitContext->shouldReceive('isGitRepository')->once()->andReturn(true); + $this->gitContext->shouldReceive('getContext')->once()->andReturn([ + 'repo' => 'knowledge', + 'branch' => 'main', + 'commit' => 'abc123', + 'author' => 'test', + ]); + $this->writeGate->shouldReceive('evaluate')->once()->andReturn(['passed' => true]); + $this->qdrant->shouldReceive('upsert')->once()->andReturn(true); + $this->enhancementQueue->shouldReceive('queue')->once(); + + config(['search.ollama.enabled' => true]); + + $request = new Request([ + 'title' => 'Test Discovery', + 'content' => 'This is an important discovery about the system.', + ]); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeFalse(); + + $data = json_decode((string) $response->content(), true); + expect($data['status'])->toBe('created') + ->and($data['project'])->toBe('test-project'); + }); + + it('returns write gate rejection', function (): void { + $this->projectDetector->shouldReceive('detect')->once()->andReturn('default'); + $this->gitContext->shouldReceive('isGitRepository')->once()->andReturn(false); + $this->writeGate->shouldReceive('evaluate')->once()->andReturn([ + 'passed' => false, + 'reason' => 'Content too generic', + ]); + + $request = new Request([ + 'title' => 'Generic Title Here', + 'content' => 'This is some generic content for testing.', + ]); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeTrue(); + }); + + it('handles duplicate detection gracefully', function (): void { + $this->projectDetector->shouldReceive('detect')->once()->andReturn('default'); + $this->gitContext->shouldReceive('isGitRepository')->once()->andReturn(false); + $this->writeGate->shouldReceive('evaluate')->once()->andReturn(['passed' => true]); + $this->qdrant->shouldReceive('upsert')->once()->andThrow( + DuplicateEntryException::similarityMatch('existing-id', 0.98) + ); + + $request = new Request([ + 'title' => 'Duplicate Entry', + 'content' => 'This content already exists in the knowledge base.', + ]); + + $response = $this->tool->handle($request); + + expect($response->isError())->toBeFalse(); + + $data = json_decode((string) $response->content(), true); + expect($data['status'])->toBe('duplicate_detected') + ->and($data['existing_id'])->toBe('existing-id'); + }); + + it('uses explicit project when provided', function (): void { + $this->projectDetector->shouldNotReceive('detect'); + $this->gitContext->shouldReceive('isGitRepository')->once()->andReturn(false); + $this->writeGate->shouldReceive('evaluate')->once()->andReturn(['passed' => true]); + $this->qdrant->shouldReceive('upsert') + ->withArgs(fn ($entry, $project) => $project === 'custom-project') + ->once() + ->andReturn(true); + $this->enhancementQueue->shouldReceive('queue')->once(); + + config(['search.ollama.enabled' => true]); + + $request = new Request([ + 'title' => 'Project Specific', + 'content' => 'This belongs to a specific project namespace.', + 'project' => 'custom-project', + ]); + + $response = $this->tool->handle($request); + + $data = json_decode((string) $response->content(), true); + expect($data['project'])->toBe('custom-project'); + }); +}); diff --git a/tests/Unit/Mcp/Tools/StatsToolTest.php b/tests/Unit/Mcp/Tools/StatsToolTest.php new file mode 100644 index 0000000..729ac9e --- /dev/null +++ b/tests/Unit/Mcp/Tools/StatsToolTest.php @@ -0,0 +1,63 @@ +group('mcp-tools'); + +beforeEach(function (): void { + $this->qdrant = Mockery::mock(QdrantService::class); + $this->projectDetector = Mockery::mock(ProjectDetectorService::class); + + $this->tool = new StatsTool($this->qdrant, $this->projectDetector); +}); + +describe('stats tool', function (): void { + it('returns stats across all projects', function (): void { + $this->projectDetector->shouldReceive('detect')->once()->andReturn('knowledge'); + $this->qdrant->shouldReceive('listCollections') + ->once() + ->andReturn(['knowledge_default', 'knowledge_odin', 'knowledge_knowledge']); + $this->qdrant->shouldReceive('count') + ->with('default') + ->once() + ->andReturn(4549); + $this->qdrant->shouldReceive('count') + ->with('odin') + ->once() + ->andReturn(3); + $this->qdrant->shouldReceive('count') + ->with('knowledge') + ->once() + ->andReturn(18); + + $request = new Request([]); + $response = $this->tool->handle($request); + + expect($response->isError())->toBeFalse(); + + $data = json_decode((string) $response->content(), true); + expect($data['current_project'])->toBe('knowledge') + ->and($data['current_project_entries'])->toBe(18) + ->and($data['total_entries'])->toBe(4570) + ->and($data['project_count'])->toBe(3) + ->and($data['projects']['default'])->toBe(4549); + }); + + it('uses explicit project parameter', function (): void { + $this->projectDetector->shouldNotReceive('detect'); + $this->qdrant->shouldReceive('listCollections')->once()->andReturn(['knowledge_odin']); + $this->qdrant->shouldReceive('count')->with('odin')->once()->andReturn(3); + + $request = new Request(['project' => 'odin']); + $response = $this->tool->handle($request); + + $data = json_decode((string) $response->content(), true); + expect($data['current_project'])->toBe('odin') + ->and($data['current_project_entries'])->toBe(3); + }); +});