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
2 changes: 2 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#!/usr/bin/env bash

docker build -t csla-mcp-server:latest -f csla-mcp-server/Dockerfile .
2 changes: 1 addition & 1 deletion csla-examples/ReadOnlyProperty.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Read-Only Property

This snippet demonstrates how to define a read-only property using the CSLA property registration system. This is useful for creating read-only properties that are part of an editable business class that derives from `BusinessBase<T>` or a read-only business class that dervies from ReadOnlyBase<T>.
This snippet demonstrates how to define a read-only property using the CSLA property registration system. This is useful for creating read-only properties that are part of an editable business class that derives from `BusinessBase<T>` or a read-only business class that derives from `ReadOnlyBase<T>`.

Note that the property has a private setter, which is typically used in conjunction with the `LoadProperty` method to set the property's value internally within the class. The `LoadProperty` method bypasses any business rules or validation, making it suitable for initializing read-only properties.

Expand Down
189 changes: 154 additions & 35 deletions csla-mcp-server/Tools/CslaCodeTool.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text.Json;
using System.Text.RegularExpressions;
using CslaMcpServer.Services;

namespace CslaMcpServer.Tools
Expand All @@ -13,7 +14,7 @@ public class CslaCodeTool

public class SearchResult
{
public int Score { get; set; }
public double Score { get; set; }
public string FileName { get; set; } = string.Empty;
}

Expand All @@ -23,10 +24,12 @@ public class SemanticMatch
public float SimilarityScore { get; set; }
}

public class CombinedSearchResult
public class ConsolidatedSearchResult
{
public List<SemanticMatch> SemanticMatches { get; set; } = new List<SemanticMatch>();
public List<SearchResult> WordMatches { get; set; } = new List<SearchResult>();
public string FileName { get; set; } = string.Empty;
public double Score { get; set; }
public double? VectorScore { get; set; }
public double? WordScore { get; set; }
}

public class ErrorResult
Expand All @@ -35,7 +38,7 @@ public class ErrorResult
public string Message { get; set; } = string.Empty;
}

[McpServerTool, Description("Searches CSLA .NET code samples and snippets for examples of how to implement code that makes use of #cslanet. Returns a JSON object with two sections: SemanticMatches (vector-based semantic similarity) and WordMatches (traditional keyword matching). Both sections are ordered by their respective scores.")]
[McpServerTool, Description("Searches CSLA .NET code samples and snippets for examples of how to implement code that makes use of #cslanet. Returns a JSON array of consolidated search results that merge semantic and word search scores.")]
public static string Search([Description("Keywords used to match against CSLA code samples and snippets. For example, read-write property, editable root, read-only list.")]string message)
{
Console.WriteLine($"[CslaCodeTool.Search] Called with message: '{message}'");
Expand All @@ -62,25 +65,45 @@ public static string Search([Description("Keywords used to match against CSLA co

Console.WriteLine($"[CslaCodeTool.Search] Found {csFiles.Length} .cs files and {mdFiles.Length} .md files");

// Extract words longer than 4 characters from the message
var searchWords = message
// Extract words from the message, preserving order for multi-word combinations
var allWords = message
.Split(new char[] { ' ', '\t', '\n', '\r', '.', ',', ';', ':', '!', '?', '(', ')', '[', ']', '{', '}', '"', '\'', '-', '_' },
StringSplitOptions.RemoveEmptyEntries)
.Where(word => word.Length > 3)
.Select(word => word.ToLowerInvariant())
.Distinct()
.ToList();

Console.WriteLine($"[CslaCodeTool.Search] Extracted search words: [{string.Join(", ", searchWords)}]");
// Create single words (remove duplicates)
var singleWords = allWords.Distinct().ToList();

// Create 2-word combinations from adjacent words
var twoWordPhrases = new List<string>();
for (int i = 0; i < allWords.Count - 1; i++)
{
var phrase = $"{allWords[i]} {allWords[i + 1]}";
if (!twoWordPhrases.Contains(phrase))
{
twoWordPhrases.Add(phrase);
}
}

// Combine single words and 2-word phrases
var searchTerms = new List<string>();
searchTerms.AddRange(singleWords);
searchTerms.AddRange(twoWordPhrases);

if (!searchWords.Any())
Console.WriteLine($"[CslaCodeTool.Search] Extracted single words: [{string.Join(", ", singleWords)}]");
Console.WriteLine($"[CslaCodeTool.Search] Extracted 2-word phrases: [{string.Join(", ", twoWordPhrases)}]");
Console.WriteLine($"[CslaCodeTool.Search] Total search terms: {searchTerms.Count}");

if (!searchTerms.Any())
{
Console.WriteLine("[CslaCodeTool.Search] No search words found, returning empty results");
return JsonSerializer.Serialize(new List<SearchResult>());
Console.WriteLine("[CslaCodeTool.Search] No search terms found, returning empty results");
return JsonSerializer.Serialize(new List<ConsolidatedSearchResult>());
}

// Create tasks for parallel execution
var wordSearchTask = Task.Run(() => PerformWordSearch(allFiles, searchWords));
var wordSearchTask = Task.Run(() => PerformWordSearch(allFiles, searchTerms));
var semanticSearchTask = Task.Run(() => PerformSemanticSearch(message));

// Wait for both tasks to complete
Expand All @@ -89,15 +112,12 @@ public static string Search([Description("Keywords used to match against CSLA co
var wordMatches = wordSearchTask.Result;
var semanticMatches = semanticSearchTask.Result;

var combinedResult = new CombinedSearchResult
{
SemanticMatches = semanticMatches,
WordMatches = wordMatches
};
// Create consolidated results
var consolidatedResults = ConsolidateSearchResults(semanticMatches, wordMatches);

Console.WriteLine($"[CslaCodeTool.Search] Returning combined results");
Console.WriteLine($"[CslaCodeTool.Search] Returning {consolidatedResults.Count} consolidated results");

return JsonSerializer.Serialize(combinedResult, new JsonSerializerOptions { WriteIndented = true });
return JsonSerializer.Serialize(consolidatedResults, new JsonSerializerOptions { WriteIndented = true });
}
catch (Exception ex)
{
Expand All @@ -111,7 +131,66 @@ public static string Search([Description("Keywords used to match against CSLA co
}
}

private static List<SearchResult> PerformWordSearch(IEnumerable<string> allFiles, List<string> searchWords)
private static List<ConsolidatedSearchResult> ConsolidateSearchResults(List<SemanticMatch> semanticMatches, List<SearchResult> wordMatches)
{
Console.WriteLine("[CslaCodeTool.ConsolidateSearchResults] Starting result consolidation");

var consolidatedResults = new Dictionary<string, ConsolidatedSearchResult>();

// Add semantic matches
foreach (var semantic in semanticMatches)
{
if (!consolidatedResults.ContainsKey(semantic.FileName))
{
consolidatedResults[semantic.FileName] = new ConsolidatedSearchResult
{
FileName = semantic.FileName,
VectorScore = semantic.SimilarityScore,
WordScore = null,
Score = semantic.SimilarityScore
};
}
}

// Add word matches and merge with semantic matches
foreach (var word in wordMatches)
{
if (consolidatedResults.ContainsKey(word.FileName))
{
// File exists in both - calculate average
var existing = consolidatedResults[word.FileName];
existing.WordScore = word.Score;
existing.Score = (existing.VectorScore.GetValueOrDefault(0) + word.Score) / 2.0;
Console.WriteLine($"[CslaCodeTool.ConsolidateSearchResults] Merged scores for '{word.FileName}': Vector={existing.VectorScore:F3}, Word={existing.WordScore:F3}, Average={existing.Score:F3}");
}
else
{
// File only in word matches
consolidatedResults[word.FileName] = new ConsolidatedSearchResult
{
FileName = word.FileName,
VectorScore = null,
WordScore = word.Score,
Score = word.Score
};
}
}

// Sort by score descending, then by filename
var sortedResults = consolidatedResults.Values
.OrderByDescending(r => r.Score)
.ThenBy(r => r.FileName)
.ToList();

Console.WriteLine($"[CslaCodeTool.ConsolidateSearchResults] Consolidated {consolidatedResults.Count} unique files");
Console.WriteLine($"[CslaCodeTool.ConsolidateSearchResults] Files with both scores: {consolidatedResults.Values.Count(r => r.VectorScore.HasValue && r.WordScore.HasValue)}");
Console.WriteLine($"[CslaCodeTool.ConsolidateSearchResults] Files with only vector scores: {consolidatedResults.Values.Count(r => r.VectorScore.HasValue && !r.WordScore.HasValue)}");
Console.WriteLine($"[CslaCodeTool.ConsolidateSearchResults] Files with only word scores: {consolidatedResults.Values.Count(r => !r.VectorScore.HasValue && r.WordScore.HasValue)}");

return sortedResults;
}

private static List<SearchResult> PerformWordSearch(IEnumerable<string> allFiles, List<string> searchTerms)
{
Console.WriteLine("[CslaCodeTool.PerformWordSearch] Starting word search");
var results = new List<SearchResult>();
Expand All @@ -123,18 +202,21 @@ private static List<SearchResult> PerformWordSearch(IEnumerable<string> allFiles
var content = File.ReadAllText(file);
var totalScore = 0;

foreach (var word in searchWords)
foreach (var term in searchTerms)
{
var count = CountWordOccurrences(content, word);
var count = CountWordOccurrences(content, term);
if (count > 0)
{
totalScore += count;
// Give higher weight to multi-word phrases
var weight = term.Contains(' ') ? 2 : 1;
totalScore += count * weight;
Console.WriteLine($"[CslaCodeTool.PerformWordSearch] Found {count} matches for '{term}' in '{Path.GetFileName(file)}' (weight: {weight})");
}
}

if (totalScore > 0)
{
Console.WriteLine($"[CslaCodeTool.PerformWordSearch] Found matches in '{Path.GetFileName(file)}' with score {totalScore}");
Console.WriteLine($"[CslaCodeTool.PerformWordSearch] Found matches in '{Path.GetFileName(file)}' with total score {totalScore}");
results.Add(new SearchResult
{
Score = totalScore,
Expand All @@ -149,13 +231,44 @@ private static List<SearchResult> PerformWordSearch(IEnumerable<string> allFiles
}
}

// Normalize scores using max-score normalization
var normalizedResults = NormalizeWordSearchResults(results);

// Order by score descending, then by filename
var sortedResults = results.OrderByDescending(r => r.Score).ThenBy(r => r.FileName).ToList();
var sortedResults = normalizedResults.OrderByDescending(r => r.Score).ThenBy(r => r.FileName).ToList();

Console.WriteLine($"[CslaCodeTool.PerformWordSearch] Found {sortedResults.Count} word match results");
return sortedResults;
}

private static List<SearchResult> NormalizeWordSearchResults(List<SearchResult> results)
{
if (!results.Any())
{
Console.WriteLine("[CslaCodeTool.NormalizeWordSearchResults] No results to normalize");
return results;
}

var maxScore = results.Max(r => r.Score);
Console.WriteLine($"[CslaCodeTool.NormalizeWordSearchResults] Normalizing {results.Count} results with max score: {maxScore}");

if (maxScore <= 0)
{
Console.WriteLine("[CslaCodeTool.NormalizeWordSearchResults] Max score is 0 or negative, returning original results");
return results;
}

var normalizedResults = results.Select(r => new SearchResult
{
FileName = r.FileName,
Score = r.Score / maxScore
}).ToList();

Console.WriteLine($"[CslaCodeTool.NormalizeWordSearchResults] Normalized scores range from {normalizedResults.Min(r => r.Score):F3} to {normalizedResults.Max(r => r.Score):F3}");

return normalizedResults;
}

private static List<SemanticMatch> PerformSemanticSearch(string message)
{
Console.WriteLine("[CslaCodeTool.PerformSemanticSearch] Starting semantic search");
Expand Down Expand Up @@ -184,18 +297,24 @@ private static List<SemanticMatch> PerformSemanticSearch(string message)
return semanticMatches;
}

private static int CountWordOccurrences(string content, string word)
private static int CountWordOccurrences(string content, string searchTerm)
{
int count = 0;
int index = 0;

while ((index = content.IndexOf(word, index, StringComparison.OrdinalIgnoreCase)) != -1)
// Handle multi-word phrases
if (searchTerm.Contains(' '))
{
count++;
index += word.Length;
// For phrases, we need to ensure word boundaries at the beginning and end
var escapedTerm = Regex.Escape(searchTerm);
var pattern = $@"\b{escapedTerm}\b";
var matches = Regex.Matches(content, pattern, RegexOptions.IgnoreCase);
return matches.Count;
}
else
{
// For single words, use word boundaries to ensure we only match complete words
var pattern = $@"\b{Regex.Escape(searchTerm)}\b";
var matches = Regex.Matches(content, pattern, RegexOptions.IgnoreCase);
return matches.Count;
}

return count;
}

[McpServerTool, Description("Fetches a specific CSLA .NET code sample or snippet by name. Returns the content of the file that can be used to properly implement code that uses #cslanet.")]
Expand Down