Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions app/Mcp/Servers/KnowledgeServer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Mcp\Servers;

use App\Mcp\Tools\ContextTool;
use App\Mcp\Tools\CorrectTool;
use App\Mcp\Tools\RecallTool;
use App\Mcp\Tools\RememberTool;
use App\Mcp\Tools\StatsTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;

#[Name('Knowledge')]
#[Version('1.0.0')]
#[Instructions('Semantic knowledge base with vector search. Use `recall` to search, `remember` to capture discoveries, `correct` to fix wrong knowledge, `context` to load project-relevant entries, and `stats` for health checks. All tools auto-detect the current project from git context.')]
class KnowledgeServer extends Server
{
protected array $tools = [
RecallTool::class,
RememberTool::class,
CorrectTool::class,
ContextTool::class,
StatsTool::class,
];

protected array $resources = [];

protected array $prompts = [];
}
244 changes: 244 additions & 0 deletions app/Mcp/Tools/ContextTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
<?php

declare(strict_types=1);

namespace App\Mcp\Tools;

use App\Services\EntryMetadataService;
use App\Services\ProjectDetectorService;
use App\Services\QdrantService;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;

#[Description('Load project-relevant knowledge context. Returns entries grouped by category, ranked by usage and recency. Use at session start for deep project context.')]
#[IsReadOnly]
#[IsIdempotent]
class ContextTool extends Tool
{
private const CHARS_PER_TOKEN = 4;

private const CATEGORY_ORDER = [
'architecture',
'patterns',
'decisions',
'gotchas',
'debugging',
'testing',
'deployment',
'security',
];

public function __construct(
private readonly QdrantService $qdrant,
private readonly EntryMetadataService $metadata,
private readonly ProjectDetectorService $projectDetector,
) {}

public function handle(Request $request): Response
{
$project = is_string($request->get('project')) ? $request->get('project') : $this->projectDetector->detect();

/** @var array<string>|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;

Comment on lines +47 to +50
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate categories and enforce positive bounds before scroll() calls.

Line 47 currently permits mixed arrays; those values are forwarded as category filters on Line 111. Also, Line 48/49 allow non-positive max_tokens/limit.

Suggested fix
-        /** `@var` array<string>|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;
+        $rawCategories = $request->get('categories');
+        $categories = is_array($rawCategories)
+            ? array_values(array_filter(
+                $rawCategories,
+                static fn (mixed $category): bool => is_string($category) && $category !== ''
+            ))
+            : null;
+        $maxTokens = is_int($request->get('max_tokens'))
+            ? max(1, min($request->get('max_tokens'), 16000))
+            : 4000;
+        $limit = is_int($request->get('limit'))
+            ? max(1, min($request->get('limit'), 100))
+            : 50;

Also applies to: 105-114

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Mcp/Tools/ContextTool.php` around lines 47 - 50, The $categories,
$maxTokens and $limit inputs must be validated and normalized before any
scroll() calls: ensure $categories is either null or an array of non-empty
strings (e.g., if is_array($request->get('categories')) then filter/validate
elements and set to null when empty or any non-string entries removed) and use
that sanitized $categories when calling scroll(); ensure $maxTokens and $limit
are numeric, coerce/cast to int, enforce minimums of 1 and then apply the
existing maxima (min(...,16000) for $maxTokens and min(...,100) for $limit), and
replace the current is_int checks with this normalization so non-positive or
non-numeric values default to safe minimums; apply the same
validation/normalization logic wherever $categories/$maxTokens/$limit are set
around the scroll() usage (the block that populates and passes these to
scroll()).

$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<string>|null $categories
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $entries
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $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<int, array<string, mixed>> $entries
* @return array<string, array<int, array<string, mixed>>>
*/
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<string, array<int, array<string, mixed>>> $grouped
* @return array<string, array<int, array<string, mixed>>>
*/
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<string, mixed> $entry
* @return array<string, mixed>
*/
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'] ?? [],
];
}
}
62 changes: 62 additions & 0 deletions app/Mcp/Tools/CorrectTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace App\Mcp\Tools;

use App\Services\CorrectionService;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;

#[Description('Correct wrong knowledge. Supersedes the original entry, creates a corrected version, and propagates corrections to related conflicting entries.')]
class CorrectTool extends Tool
{
public function __construct(
private readonly CorrectionService $correctionService,
) {}

public function handle(Request $request): Response
{
$id = $request->get('id');
$correctedContent = $request->get('corrected_content');

if (! is_string($id) || $id === '') {
return Response::error('Provide the ID of the entry to correct.');
}
Comment on lines +26 to +28
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Allow numeric IDs for correction requests.

Line 26 currently rejects non-string IDs, but CorrectionService::correct() accepts string|int. This blocks correction when entries use numeric IDs.

Suggested fix
-        if (! is_string($id) || $id === '') {
+        if ((! is_string($id) && ! is_int($id)) || (is_string($id) && $id === '')) {
             return Response::error('Provide the ID of the entry to correct.');
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Mcp/Tools/CorrectTool.php` around lines 26 - 28, The ID validation in
CorrectTool.php currently disallows non-string IDs but
CorrectionService::correct() accepts string|int; update the check in the method
that returns "Provide the ID..." to allow integers and numeric strings (e.g.,
accept is_int($id) or (is_string($id) && $id !== '' ) or use is_numeric for
numeric strings), then ensure you pass a properly typed value to
CorrectionService::correct() (cast to int when numeric or to string otherwise)
so numeric entry IDs are accepted; reference the conditional that currently
calls Response::error and the call to CorrectionService::correct() to locate
where to change the validation and any necessary casting.


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(),
];
}
}
Loading
Loading