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 s_snapshots = new(StringComparer.OrdinalIgnoreCase); public static void RefreshFromClients(IEnumerable 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 BuildSyntheticSkills(IEnumerable? servers) { if (servers == null) return []; var snapshots = GetSnapshotMap(); var skills = new List(); var usedNames = new HashSet(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(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? servers) { var snapshots = GetSnapshotMap(); var parts = new List(); 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 GetSnapshotMap() { lock (s_gate) return new Dictionary(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 { $"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 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 Tools, IReadOnlyList Resources, DateTime UpdatedAtUtc); }