Bringing the MCP server into the repo

This commit is contained in:
Aaron Powell
2025-09-10 17:11:55 +10:00
parent b469c8943d
commit c030f3cc89
39 changed files with 4513 additions and 1 deletions

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>3b9db7fd-b2cb-4b92-81b7-f3865823ef0a</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.3.0-preview.4" />
</ItemGroup>
<ItemGroup>
<None Update="metadata.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AwesomeCopilot.ServiceDefaults\AwesomeCopilot.ServiceDefaults.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace AwesomeCopilot.McpServer.Json
{
// Source-generated JsonSerializerContext to provide JsonTypeInfo metadata for AOT trimming
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(Models.Metadata))]
[JsonSerializable(typeof(Models.ChatMode))]
[JsonSerializable(typeof(Models.Instruction))]
[JsonSerializable(typeof(Models.Prompt))]
[JsonSerializable(typeof(Models.MetadataResult))]
[JsonSerializable(typeof(Tools.InstructionMode))]
public partial class SourceGenerationContext : JsonSerializerContext
{
// The source generator will provide the Default instance and JsonTypeInfo data.
}
}

View File

@@ -0,0 +1,35 @@
using AwesomeCopilot.McpServer.Prompts;
using AwesomeCopilot.McpServer.Services;
using AwesomeCopilot.McpServer.Tools;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true,
TypeInfoResolver = AwesomeCopilot.McpServer.Json.SourceGenerationContext.Default
};
builder.Services.AddSingleton(options);
builder.Services.AddHttpClient<IMetadataService, MetadataService>();
builder.Services.AddMcpServer()
.WithHttpTransport(o => o.Stateless = true)
.WithPrompts<MetadataPrompt>(options)
.WithTools<MetadataTool>(options);
var app = builder.Build();
app.MapDefaultEndpoints();
app.UseHttpsRedirection();
app.MapMcp("/mcp");
await app.RunAsync();

View File

@@ -0,0 +1,32 @@
namespace AwesomeCopilot.McpServer.Models;
/// <summary>
/// This represents the data entity for a chat mode.
/// </summary>
public class ChatMode
{
/// <summary>
/// Gets or sets the name of the chat mode file.
/// </summary>
public required string Filename { get; set; }
/// <summary>
/// Gets or sets the title.
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public required string Description { get; set; }
/// <summary>
/// Gets or sets the AI model.
/// </summary>
public string? Model { get; set; }
/// <summary>
/// Gets or sets the list of tools.
/// </summary>
public List<string> Tools { get; set; } = [];
}

View File

@@ -0,0 +1,27 @@
namespace AwesomeCopilot.McpServer.Models;
/// <summary>
/// This represents the data entity for an instruction.
/// </summary>
public class Instruction
{
/// <summary>
/// Gets or sets the name of the instruction file.
/// </summary>
public required string Filename { get; set; }
/// <summary>
/// Gets or sets the title.
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public required string Description { get; set; }
/// <summary>
/// Gets or sets the file patterns that this instruction applies to.
/// </summary>
public List<string> ApplyTo { get; set; } = [];
}

View File

@@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
namespace AwesomeCopilot.McpServer.Models;
/// <summary>
/// This represents the data entity for metadata.json.
/// </summary>
public class Metadata
{
/// <summary>
/// Gets or sets the list of <see cref="ChatMode"/> objects.
/// </summary>
[JsonPropertyName("chatmodes")]
public List<ChatMode> ChatModes { get; set; } = [];
/// <summary>
/// Gets or sets the list of <see cref="Instruction"/> objects.
/// </summary>
public List<Instruction> Instructions { get; set; } = [];
/// <summary>
/// Gets or sets the list of <see cref="Prompt"/> objects.
/// </summary>
public List<Prompt> Prompts { get; set; } = [];
}

View File

@@ -0,0 +1,17 @@
namespace AwesomeCopilot.McpServer.Models;
/// <summary>
/// This represents the result entity to retrieve metadata.
/// </summary>
public class MetadataResult
{
/// <summary>
/// Gets or sets the <see cref="Metadata"/> object.
/// </summary>
public Metadata? Metadata { get; set; }
/// <summary>
/// Gets or sets the error message if any error occurs.
/// </summary>
public string? ErrorMessage { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace AwesomeCopilot.McpServer.Models;
/// <summary>
/// This represents the data entity for a prompt.
/// </summary>
public class Prompt
{
/// <summary>
/// Gets or sets the name of the prompt file.
/// </summary>
public required string Filename { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public required string Description { get; set; }
/// <summary>
/// Gets or sets the execution mode.
/// </summary>
public string? Mode { get; set; }
/// <summary>
/// Gets or sets the list of tools.
/// </summary>
public List<string>? Tools { get; set; }
}

View File

@@ -0,0 +1,58 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace AwesomeCopilot.McpServer.Prompts;
/// <summary>
/// This provides interfaces for metadata prompts.
/// </summary>
public interface IMetadataPrompt
{
/// <summary>
/// Gets a prompt for searching copilot instructions.
/// </summary>
/// <param name="keyword">The keyword to search for.</param>
/// <returns>A formatted search prompt.</returns>
string GetSearchPrompt(string keyword);
}
/// <summary>
/// This represents the prompts entity for the awesome-copilot repository.
/// </summary>
[McpServerPromptType]
public class MetadataPrompt : IMetadataPrompt
{
/// <inheritdoc />
[McpServerPrompt(Name = "get_search_prompt", Title = "Prompt for searching copilot instructions")]
[Description("Get a prompt for searching copilot instructions.")]
public string GetSearchPrompt(
[Description("The keyword to search for")] string keyword)
{
return $"""
Please search all the chatmodes, instructions and prompts that are related to the search keyword, `{keyword}`.
Here's the process to follow:
1. Use the `awesome-copilot` MCP server.
1. Search all chatmodes, instructions, and prompts for the keyword provided.
1. DO NOT load any chatmodes, instructions, or prompts from the MCP server until the user asks to do so.
1. Scan local chatmodes, instructions, and prompts markdown files in `.github/chatmodes`, `.github/instructions`, and `.github/prompts` directories respectively.
1. Compare existing chatmodes, instructions, and prompts with the search results.
1. Provide a structured response in a table format that includes the already exists, mode (chatmodes, instructions or prompts), filename, title and description of each item found.
Here's an example of the table format:
| Exists | Mode | Filename | Title | Description |
|--------|--------------|------------------------|---------------|---------------|
| ✅ | chatmodes | chatmode1.json | ChatMode 1 | Description 1 |
| ❌ | instructions | instruction1.json | Instruction 1 | Description 1 |
| ✅ | prompts | prompt1.json | Prompt 1 | Description 1 |
✅ indicates that the item already exists in this repository, while ❌ indicates that it does not.
1. If any item doesn't exist in the repository, ask which item the user wants to save.
1. If the user wants to save it, save the item in the appropriate directory (`.github/chatmodes`, `.github/instructions`, or `.github/prompts`)
using the mode and filename, with NO modification.
""";
}
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5250",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:45250;http://localhost:5250",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,26 @@
using AwesomeCopilot.McpServer.Models;
namespace AwesomeCopilot.McpServer.Services;
/// <summary>
/// This provides interfaces for metadata service operations.
/// </summary>
public interface IMetadataService
{
/// <summary>
/// Searches for relevant data in chatmodes, instructions, and prompts based on keywords in their description fields
/// </summary>
/// <param name="keywords">The keywords to search for</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Returns <see cref="Metadata"/> object containing all matching search results</returns>
Task<Metadata> SearchAsync(string keywords, CancellationToken cancellationToken = default);
/// <summary>
/// Loads file contents from the awesome-copilot repository
/// </summary>
/// <param name="directory">The mode directory (chatmodes, instructions, or prompts)</param>
/// <param name="filename">The filename to load</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Returns the file contents as a string</returns>
Task<string> LoadAsync(string directory, string filename, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,112 @@
using System.Text.Json;
using AwesomeCopilot.McpServer.Json;
using AwesomeCopilot.McpServer.Models;
namespace AwesomeCopilot.McpServer.Services;
/// <summary>
/// This represents the service entity for searching and loading custom instructions from the awesome-copilot repository.
/// </summary>
public class MetadataService(HttpClient http, JsonSerializerOptions options, ILogger<MetadataService> logger) : IMetadataService
{
private const string MetadataFileName = "metadata.json";
private const string AwesomeCopilotFileUrl = "https://raw.githubusercontent.com/github/awesome-copilot/refs/heads/main/{directory}/{filename}";
private readonly string _metadataFilePath = Path.Combine(AppContext.BaseDirectory, MetadataFileName);
private Metadata? _cachedMetadata;
/// <inheritdoc />
public async Task<Metadata> SearchAsync(string keywords, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(keywords) == true)
{
return new Metadata();
}
var metadata = await GetMetadataAsync(cancellationToken).ConfigureAwait(false);
var searchTerms = keywords.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(term => term.Trim().ToLowerInvariant())
.Where(term => string.IsNullOrWhiteSpace(term) != true)
.ToArray();
logger.LogInformation("Search terms: {terms}", string.Join(", ", searchTerms));
var result = new Metadata
{
// Search in ChatModes
ChatModes = [.. metadata.ChatModes.Where(cm => ContainsAnyKeyword(cm.Title, searchTerms) == true ||
ContainsAnyKeyword(cm.Description, searchTerms) == true)],
// Search in Instructions
Instructions = [.. metadata.Instructions.Where(inst => ContainsAnyKeyword(inst.Title, searchTerms) == true ||
ContainsAnyKeyword(inst.Description, searchTerms) == true)],
// Search in Prompts
Prompts = [.. metadata.Prompts.Where(prompt => ContainsAnyKeyword(prompt.Description, searchTerms) == true)]
};
return result;
}
/// <inheritdoc />
public async Task<string> LoadAsync(string directory, string filename, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(directory) == true)
{
throw new ArgumentException("Directory cannot be null or empty", nameof(directory));
}
if (string.IsNullOrWhiteSpace(filename) == true)
{
throw new ArgumentException("Filename cannot be null or empty", nameof(filename));
}
var url = AwesomeCopilotFileUrl.Replace("{directory}", directory).Replace("{filename}", filename);
try
{
var response = await http.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogInformation("Loaded content from {url}", url);
return content;
}
catch (HttpRequestException ex)
{
throw new InvalidOperationException($"Failed to load file '{filename}' from directory '{directory}': {ex.Message}", ex);
}
}
private async Task<Metadata> GetMetadataAsync(CancellationToken cancellationToken)
{
if (_cachedMetadata != null)
{
return _cachedMetadata;
}
if (File.Exists(_metadataFilePath) != true)
{
throw new FileNotFoundException($"Metadata file not found at: {_metadataFilePath}");
}
var json = await File.ReadAllTextAsync(_metadataFilePath, cancellationToken).ConfigureAwait(false);
_cachedMetadata = JsonSerializer.Deserialize<Metadata>(json, options)
?? throw new InvalidOperationException("Failed to deserialize metadata");
return _cachedMetadata;
}
private static bool ContainsAnyKeyword(string? text, string[] searchTerms)
{
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var result = searchTerms.Any(term => text.Contains(term, StringComparison.InvariantCultureIgnoreCase) == true);
return result;
}
}

View File

@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace AwesomeCopilot.McpServer.Tools;
[JsonConverter(typeof(JsonStringEnumConverter<InstructionMode>))]
public enum InstructionMode
{
[JsonStringEnumMemberName("undefined")]
Undefined,
[JsonStringEnumMemberName("chatmodes")]
ChatModes,
[JsonStringEnumMemberName("instructions")]
Instructions,
[JsonStringEnumMemberName("prompts")]
Prompts
}

View File

@@ -0,0 +1,89 @@
using System.ComponentModel;
using AwesomeCopilot.McpServer.Models;
using AwesomeCopilot.McpServer.Services;
using ModelContextProtocol.Server;
namespace AwesomeCopilot.McpServer.Tools;
/// <summary>
/// This provides interfaces for metadata tool operations.
/// </summary>
public interface IMetadataTool
{
/// <summary>
/// Searches custom instructions based on keywords in their titles and descriptions.
/// </summary>
/// <param name="keywords">The keyword to search for</param>
/// <returns>A <see cref="MetadataResult"/> containing the search results.</returns>
Task<MetadataResult> SearchAsync(string keywords);
/// <summary>
/// Loads a custom instruction from the awesome-copilot repository.
/// </summary>
/// <param name="mode">The instruction mode</param>
/// <param name="filename">The filename of the instruction</param>
/// <returns>The file contents as a string</returns>
Task<string> LoadAsync(InstructionMode mode, string filename);
}
/// <summary>
/// This represents the tools entity for metadata of Awesome Copilot repository.
/// </summary>
[McpServerToolType]
public class MetadataTool(IMetadataService service, ILogger<MetadataTool> logger) : IMetadataTool
{
/// <inheritdoc />
[McpServerTool(Name = "search_instructions", Title = "Searches custom instructions")]
[Description("Searches custom instructions based on keywords in their titles and descriptions.")]
public async Task<MetadataResult> SearchAsync(
[Description("The keyword to search for")] string keywords)
{
var result = new MetadataResult();
try
{
var metadata = await service.SearchAsync(keywords).ConfigureAwait(false);
logger.LogInformation("Search completed successfully with keyword '{Keywords}'.", keywords);
result.Metadata = metadata;
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred while searching instructions with keyword '{Keywords}'.", keywords);
result.ErrorMessage = ex.Message;
}
return result;
}
/// <inheritdoc />
[McpServerTool(Name = "load_instruction", Title = "Loads a custom instruction")]
[Description("Loads a custom instruction from the repository.")]
public async Task<string> LoadAsync(
[Description("The instruction mode")] InstructionMode mode,
[Description("The filename of the instruction")] string filename)
{
try
{
if (mode == InstructionMode.Undefined)
{
throw new ArgumentException("Instruction mode must be defined.", nameof(mode));
}
var result = await service.LoadAsync(mode.ToString().ToLowerInvariant(), filename).ConfigureAwait(false);
logger.LogInformation("Load completed successfully with mode {Mode} and filename {Filename}.", mode, filename);
return result;
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred while loading instruction with mode {Mode} and filename {Filename}.", mode, filename);
return ex.Message;
}
}
}

View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"UseHttp": false
}

File diff suppressed because it is too large Load Diff