- MCP ?? ?????? synthetic skill? ???? McpSkillCatalog? ???? ToolRegistry ?? snapshot ?? ??? ??? - managed/user/additional/project/plugin/mcp/legacy ?? source ??, plugin-only ??, source? inline shell trust boundary? SkillService/AppSettings/Settings UI? ??? - SlashCommandCatalog? ChatWindow?? builtin command? skill? ???? ???? ??? ?? ? ????? dedupe?? MCP ???? ? synthetic skill ?? ??? SkillGallery/AgentSettings? ??? - README.md? docs/DEVELOPMENT.md? 2026-04-14 19:13 (KST) ?? ?? ??? ?? ??? ??? - ??: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase4\\ -p:IntermediateOutputPath=obj\\verify_phase4\\ (?? 0, ?? 0) - ??: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "SkillServiceRuntimePolicyTests|SlashCommandCatalogTests|McpSkillCatalogTests" -p:OutputPath=bin\\verify_phase4_tests\\ -p:IntermediateOutputPath=obj\\verify_phase4_tests\\ (?? 17, ?? WorkspaceContextGeneratorTests.cs nullable ?? 1? ??)
258 lines
10 KiB
C#
258 lines
10 KiB
C#
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using AxCopilot.Models;
|
|
using AxCopilot.Services;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
internal static class McpSkillCatalog
|
|
{
|
|
private static readonly object s_gate = new();
|
|
private static readonly Dictionary<string, McpServerSnapshot> s_snapshots = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public static void RefreshFromClients(IEnumerable<McpClientService> clients)
|
|
{
|
|
if (clients == null)
|
|
return;
|
|
|
|
lock (s_gate)
|
|
{
|
|
foreach (var client in clients)
|
|
{
|
|
if (client == null || string.IsNullOrWhiteSpace(client.ServerName))
|
|
continue;
|
|
|
|
var serverName = client.ServerName.Trim();
|
|
s_snapshots[serverName] = new McpServerSnapshot(
|
|
serverName,
|
|
client.Tools
|
|
.Select(tool => new McpToolDefinition
|
|
{
|
|
Name = tool.Name,
|
|
Description = tool.Description,
|
|
ServerName = tool.ServerName,
|
|
Parameters = tool.Parameters.ToDictionary(
|
|
pair => pair.Key,
|
|
pair => new McpParameterDef
|
|
{
|
|
Type = pair.Value.Type,
|
|
Description = pair.Value.Description,
|
|
Required = pair.Value.Required,
|
|
},
|
|
StringComparer.OrdinalIgnoreCase),
|
|
})
|
|
.ToList()
|
|
.AsReadOnly(),
|
|
client.Resources
|
|
.Select(resource => new McpResourceDefinition
|
|
{
|
|
Uri = resource.Uri,
|
|
Name = resource.Name,
|
|
Description = resource.Description,
|
|
MimeType = resource.MimeType,
|
|
ServerName = resource.ServerName,
|
|
})
|
|
.ToList()
|
|
.AsReadOnly(),
|
|
DateTime.UtcNow);
|
|
}
|
|
}
|
|
}
|
|
|
|
public static IReadOnlyList<SkillDefinition> BuildSyntheticSkills(IEnumerable<McpServerEntry>? servers)
|
|
{
|
|
if (servers == null)
|
|
return [];
|
|
|
|
var snapshots = GetSnapshotMap();
|
|
var skills = new List<SkillDefinition>();
|
|
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var server in servers
|
|
.Where(server => server != null && server.Enabled)
|
|
.OrderBy(server => server.Name, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var displayName = string.IsNullOrWhiteSpace(server.Name) ? "mcp-server" : server.Name.Trim();
|
|
var skillName = BuildUniqueSkillName(displayName, usedNames);
|
|
snapshots.TryGetValue(displayName, out var snapshot);
|
|
|
|
var allowedTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"mcp_list_resources",
|
|
"mcp_read_resource"
|
|
};
|
|
|
|
if (snapshot != null)
|
|
{
|
|
foreach (var tool in snapshot.Tools)
|
|
allowedTools.Add($"mcp_{tool.ServerName}_{tool.Name}");
|
|
}
|
|
|
|
skills.Add(new SkillDefinition
|
|
{
|
|
Id = skillName,
|
|
Name = skillName,
|
|
Label = $"{displayName} MCP",
|
|
Description = BuildDescription(displayName, snapshot),
|
|
SystemPrompt = BuildPromptBody(displayName, server, snapshot),
|
|
FilePath = $"[mcp]/{displayName}",
|
|
SkillRoot = "",
|
|
SourceScope = "mcp",
|
|
AllowedTools = string.Join(", ", allowedTools.OrderBy(tool => tool, StringComparer.OrdinalIgnoreCase)),
|
|
WhenToUse = BuildWhenToUse(displayName, snapshot),
|
|
Tabs = "all",
|
|
UserInvocable = true,
|
|
IsAvailable = true,
|
|
});
|
|
}
|
|
|
|
return skills;
|
|
}
|
|
|
|
public static string ComputeSignature(IEnumerable<McpServerEntry>? servers)
|
|
{
|
|
var snapshots = GetSnapshotMap();
|
|
var parts = new List<string>();
|
|
var orderedServers = (servers ?? [])
|
|
.Where(server => server != null)
|
|
.OrderBy(server => server.Name, StringComparer.OrdinalIgnoreCase);
|
|
foreach (var server in orderedServers)
|
|
{
|
|
var name = string.IsNullOrWhiteSpace(server.Name) ? "(unnamed)" : server.Name.Trim();
|
|
snapshots.TryGetValue(name, out var snapshot);
|
|
parts.Add(string.Join(":",
|
|
name,
|
|
server.Enabled ? "1" : "0",
|
|
server.Transport ?? "stdio",
|
|
server.Command ?? "",
|
|
server.Url ?? "",
|
|
snapshot?.Tools.Count ?? 0,
|
|
snapshot?.Resources.Count ?? 0,
|
|
snapshot?.UpdatedAtUtc.Ticks ?? 0));
|
|
}
|
|
|
|
return string.Join("|", parts);
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, McpServerSnapshot> GetSnapshotMap()
|
|
{
|
|
lock (s_gate)
|
|
return new Dictionary<string, McpServerSnapshot>(s_snapshots, StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string BuildDescription(string displayName, McpServerSnapshot? snapshot)
|
|
{
|
|
if (snapshot == null)
|
|
return $"{displayName} MCP 서버의 도구와 리소스를 탐색하는 보조 스킬입니다.";
|
|
|
|
var toolCount = snapshot.Tools.Count;
|
|
var resourceCount = snapshot.Resources.Count;
|
|
if (toolCount == 0 && resourceCount == 0)
|
|
return $"{displayName} MCP 서버에 연결되었지만 아직 노출된 도구나 리소스가 없습니다.";
|
|
|
|
return $"{displayName} MCP 서버의 도구 {toolCount}개와 리소스 {resourceCount}개를 우선 활용하도록 돕습니다.";
|
|
}
|
|
|
|
private static string BuildWhenToUse(string displayName, McpServerSnapshot? snapshot)
|
|
{
|
|
if (snapshot == null || (snapshot.Tools.Count == 0 && snapshot.Resources.Count == 0))
|
|
return $"{displayName} MCP 서버가 제공하는 리소스나 도구를 먼저 확인해야 하는 작업";
|
|
|
|
var keywords = snapshot.Resources
|
|
.SelectMany(resource => new[] { resource.Name, resource.Description, resource.MimeType })
|
|
.Concat(snapshot.Tools.SelectMany(tool => new[] { tool.Name, tool.Description }))
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(value => value!.Trim())
|
|
.Take(4)
|
|
.ToList();
|
|
|
|
return keywords.Count == 0
|
|
? $"{displayName} MCP 서버의 도구와 리소스가 필요한 작업"
|
|
: $"{displayName} MCP 서버의 {string.Join(", ", keywords)} 관련 정보를 찾거나 실행해야 하는 작업";
|
|
}
|
|
|
|
private static string BuildPromptBody(string displayName, McpServerEntry server, McpServerSnapshot? snapshot)
|
|
{
|
|
var lines = new List<string>
|
|
{
|
|
$"Use the MCP server \"{displayName}\" as the first source of truth when it is relevant.",
|
|
$"Transport: {NormalizeTransport(server.Transport)}",
|
|
$"1. Start with `mcp_list_resources(server_name: \"{displayName}\")` to discover available resources.",
|
|
"2. Read only the specific resources you need with `mcp_read_resource`.",
|
|
"3. If server-specific MCP tools are available, prefer the smallest relevant call before broader exploration.",
|
|
"4. Summarize the MCP evidence you used and clearly note any missing data."
|
|
};
|
|
|
|
if (snapshot == null)
|
|
{
|
|
lines.Add(string.Empty);
|
|
lines.Add("This session does not yet have a cached MCP metadata snapshot, so inspect the live resource list first.");
|
|
return string.Join(Environment.NewLine, lines);
|
|
}
|
|
|
|
if (snapshot.Tools.Count > 0)
|
|
{
|
|
lines.Add(string.Empty);
|
|
lines.Add("Available server-specific MCP tools:");
|
|
foreach (var tool in snapshot.Tools.Take(8))
|
|
{
|
|
var description = string.IsNullOrWhiteSpace(tool.Description) ? "" : $" - {tool.Description}";
|
|
lines.Add($"- mcp_{tool.ServerName}_{tool.Name}{description}");
|
|
}
|
|
}
|
|
|
|
if (snapshot.Resources.Count > 0)
|
|
{
|
|
lines.Add(string.Empty);
|
|
lines.Add("Known MCP resources:");
|
|
foreach (var resource in snapshot.Resources.Take(8))
|
|
{
|
|
var descriptor = new StringBuilder();
|
|
descriptor.Append($"- {resource.Name}");
|
|
if (!string.IsNullOrWhiteSpace(resource.MimeType))
|
|
descriptor.Append($" ({resource.MimeType})");
|
|
if (!string.IsNullOrWhiteSpace(resource.Description))
|
|
descriptor.Append($" - {resource.Description}");
|
|
lines.Add(descriptor.ToString());
|
|
}
|
|
}
|
|
|
|
return string.Join(Environment.NewLine, lines);
|
|
}
|
|
|
|
private static string BuildUniqueSkillName(string displayName, ISet<string> usedNames)
|
|
{
|
|
var baseName = $"mcp:{NormalizeToken(displayName)}";
|
|
var current = baseName;
|
|
var suffix = 2;
|
|
while (!usedNames.Add(current))
|
|
{
|
|
current = $"{baseName}-{suffix}";
|
|
suffix++;
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
private static string NormalizeToken(string input)
|
|
{
|
|
var normalized = Regex.Replace((input ?? "").Trim().ToLowerInvariant(), @"[^a-z0-9]+", "-").Trim('-');
|
|
return string.IsNullOrWhiteSpace(normalized) ? "server" : normalized;
|
|
}
|
|
|
|
private static string NormalizeTransport(string? transport)
|
|
{
|
|
return (transport ?? "stdio").Trim().ToLowerInvariant() switch
|
|
{
|
|
"sse" => "server-sent events",
|
|
_ => "stdio",
|
|
};
|
|
}
|
|
|
|
private sealed record McpServerSnapshot(
|
|
string ServerName,
|
|
IReadOnlyList<McpToolDefinition> Tools,
|
|
IReadOnlyList<McpResourceDefinition> Resources,
|
|
DateTime UpdatedAtUtc);
|
|
}
|