AX Agent ?? ?? ??? MCP ?? ??? ???? ??? ??? ??

- 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? ??)
This commit is contained in:
2026-04-14 19:15:12 +09:00
parent 3747a92c12
commit 946c31e275
17 changed files with 956 additions and 81 deletions

View File

@@ -0,0 +1,257 @@
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);
}

View File

@@ -60,7 +60,7 @@ public class SkillManagerTool : IAgentTool
{
var skills = SkillService.Skills;
if (skills.Count == 0)
return ToolResult.Ok("로드된 스킬이 없습니다. 번들 스킬, %APPDATA%\\AxCopilot\\skills\\, 추가 스킬 폴더, 프로젝트 .claude\\skills\\를 확인하세요.");
return ToolResult.Ok("로드된 스킬이 없습니다. 번들 스킬, %APPDATA%\\AxCopilot\\skills\\, 추가 스킬 폴더, 프로젝트 .claude\\skills\\, MCP 스킬 소스를 확인하세요.");
var sb = new StringBuilder();
sb.AppendLine($"사용 가능한 스킬 ({skills.Count}개):\n");

View File

@@ -33,18 +33,25 @@ public static class SkillService
/// <summary>스킬 폴더에서 *.skill.md / SKILL.md 파일을 로드합니다.</summary>
public static void LoadSkills(string? customFolder = null, string? projectRoot = null, IEnumerable<string>? additionalFolders = null)
{
var app = System.Windows.Application.Current as App;
var llm = app?.SettingsService?.Settings.Llm;
var normalizedCustomFolder = NormalizeExistingDirectory(customFolder);
var normalizedProjectRoot = NormalizeExistingDirectory(projectRoot);
var normalizedAdditionalFolders = NormalizeDistinctDirectories(additionalFolders);
var sources = BuildSkillSources(normalizedCustomFolder, normalizedProjectRoot, normalizedAdditionalFolders).ToList();
var loadSignature = ComputeLoadSignature(sources);
var mcpSkills = BuildMcpSkills(llm?.McpServers, llm);
var loadSignature = ComputeLoadSignature(sources, llm, mcpSkills);
if (_skills.Count > 0 && string.Equals(_lastLoadSignature, loadSignature, StringComparison.OrdinalIgnoreCase))
return;
var allSkills = GetBundledSkills().ToList();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var bundled in allSkills)
seen.Add(bundled.Name);
var candidates = new List<SkillLoadCandidate>();
var order = 0;
foreach (var bundled in GetBundledSkills())
candidates.Add(new SkillLoadCandidate(order++, bundled));
foreach (var mcpSkill in mcpSkills)
candidates.Add(new SkillLoadCandidate(order++, mcpSkill));
foreach (var source in sources)
{
@@ -57,8 +64,8 @@ public static class SkillService
var skill = source.Kind == SkillSourceKind.LegacyCommand
? ParseLegacyCommandFile(file, source.Directory, source.Scope)
: ParseSkillFile(file, source.Directory, source.Scope);
if (skill != null && seen.Add(skill.Name))
allSkills.Add(skill);
if (skill != null)
candidates.Add(new SkillLoadCandidate(order++, skill));
}
catch (Exception ex)
{
@@ -67,6 +74,17 @@ public static class SkillService
}
}
var allSkills = candidates
.GroupBy(candidate => candidate.Skill.Name, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(candidate => GetSkillSourcePriority(candidate.Skill.SourceScope))
.ThenBy(candidate => candidate.LoadOrder)
.Select(candidate => candidate.Skill)
.First())
.OrderByDescending(skill => GetSkillSourcePriority(skill.SourceScope))
.ThenBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
// 런타임 의존성 검증
foreach (var skill in allSkills)
{
@@ -338,6 +356,8 @@ public static class SkillService
void AddSource(string? directory, string scope, SkillSourceKind kind = SkillSourceKind.SkillFolder)
{
if (!IsSkillSourceEnabled(scope, llm))
return;
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
return;
@@ -350,8 +370,11 @@ public static class SkillService
AddSource(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills"), "user");
AddSource(customFolder, "custom");
foreach (var folder in additionalFolders)
AddSource(folder, "additional");
if (llm?.EnableAdditionalSkillDiscovery ?? true)
{
foreach (var folder in additionalFolders)
AddSource(folder, "additional");
}
if (llm?.EnablePluginSkillDiscovery ?? true)
{
@@ -374,7 +397,10 @@ public static class SkillService
return sources;
}
private static string ComputeLoadSignature(IEnumerable<SkillSourceDescriptor> sources)
private static string ComputeLoadSignature(
IEnumerable<SkillSourceDescriptor> sources,
Models.LlmSettings? llm,
IReadOnlyList<SkillDefinition> mcpSkills)
{
var parts = new List<string>();
foreach (var source in sources)
@@ -388,9 +414,68 @@ public static class SkillService
parts.Add($"{source.Kind}:{source.Scope}:{source.Directory}:{files.Count}:{latestWrite}");
}
parts.Add($"skill-policy:{llm?.EnableManagedSkillSource ?? true}:{llm?.EnableUserSkillSource ?? true}:{llm?.EnableAdditionalSkillDiscovery ?? true}:{llm?.EnableProjectSkillDiscovery ?? true}:{llm?.EnablePluginSkillDiscovery ?? true}:{llm?.EnableMcpSkillDiscovery ?? true}:{llm?.EnablePluginOnlySkillMode ?? false}:{llm?.EnableLegacyCommandSkills ?? true}");
parts.Add($"mcp:{McpSkillCatalog.ComputeSignature(llm?.McpServers)}:{mcpSkills.Count}");
return string.Join("|", parts);
}
private static IReadOnlyList<SkillDefinition> BuildMcpSkills(
IEnumerable<Models.McpServerEntry>? servers,
Models.LlmSettings? llm)
{
if (!IsSkillSourceEnabled("mcp", llm))
return [];
return McpSkillCatalog.BuildSyntheticSkills(servers);
}
internal static bool IsSkillSourceEnabled(string scope, Models.LlmSettings? llm)
{
if (string.IsNullOrWhiteSpace(scope))
return true;
var normalized = scope.Trim().ToLowerInvariant();
if (normalized == "bundled")
return true;
if (llm == null)
return true;
if (llm.EnablePluginOnlySkillMode)
{
return normalized is "managed" or "plugin";
}
return normalized switch
{
"managed" => llm.EnableManagedSkillSource,
"user" or "custom" => llm.EnableUserSkillSource,
"additional" => llm.EnableAdditionalSkillDiscovery,
"project" => llm.EnableProjectSkillDiscovery,
"plugin" => llm.EnablePluginSkillDiscovery,
"mcp" => llm.EnableMcpSkillDiscovery,
"legacy" => llm.EnableLegacyCommandSkills,
_ => true,
};
}
internal static int GetSkillSourcePriority(string? sourceScope)
{
return (sourceScope ?? "").Trim().ToLowerInvariant() switch
{
"project" => 900,
"mcp" => 850,
"plugin" => 800,
"additional" => 760,
"custom" => 740,
"user" => 700,
"managed" => 650,
"bundled" => 600,
"legacy" => 550,
_ => 500,
};
}
private static IEnumerable<string> EnumerateProjectSkillFolders(string? projectRoot)
{
foreach (var root in EnumerateAncestorDirectories(projectRoot))
@@ -527,7 +612,57 @@ public static class SkillService
완료한 작업, 남은 이슈, 주의할 파일, 다음 권장 액션을 분리해서 작성하세요.
""",
whenToUse: "작업 마무리, 인수인계, 세션 요약, 다음 액션 정리가 필요할 때",
allowedTools: "git_tool, file_read, task_list")
allowedTools: "git_tool, file_read, task_list"),
CreateBundledSkill(
"stuck-recovery",
"막힘 복구",
"막힌 작업을 다시 전진시키기 위한 원인 정리와 우회 경로를 제안합니다.",
"""
현재 작업이 멈춘 이유를 입력, 환경, 도구, 정책, 검증 결과 관점에서 나눠 분석하세요.
즉시 시도할 수 있는 우회 경로와, 사용자의 결정을 받아야 하는 항목을 분리해 정리하세요.
""",
whenToUse: "반복 실패, 권한 차단, 환경 문제, 막힘 복구가 필요한 작업",
allowedTools: "file_read, grep, build_run, process, task_list"),
CreateBundledSkill(
"context-memory",
"컨텍스트 메모리",
"지속적으로 참고해야 할 사실과 후속 체크포인트를 짧게 정리합니다.",
"""
현재 작업에서 다음 반복이나 다음 세션에도 유지해야 할 핵심 사실만 추려 정리하세요.
파일 경로, 결정 사항, 미해결 리스크, 다음 확인 지점을 짧은 메모 형식으로 요약하세요.
""",
whenToUse: "세션이 길어지거나, 다음 반복을 위해 핵심 사실을 압축해 남겨야 할 때",
allowedTools: "memory, file_read, task_list"),
CreateBundledSkill(
"config-tune",
"설정 튜닝",
"현재 설정/정책 상태를 바탕으로 실행 품질과 안전성을 조정합니다.",
"""
현재 작업 목적에 맞춰 설정을 점검하고, 변경이 필요한 항목과 그대로 둬야 하는 항목을 구분하세요.
성능, 권한, 스킬 source, 도구 노출 관점에서 추천 변경안을 우선순위로 제시하세요.
""",
whenToUse: "권한, 스킬 source, 도구 노출, 자동화 설정을 조정해야 할 때",
allowedTools: "skill_manager, env_tool, mcp_list_resources"),
CreateBundledSkill(
"change-guard",
"변경 가드",
"수정 전에 영향 범위와 회귀 가능성을 빠르게 짚어줍니다.",
"""
변경 대상의 영향 범위, 연관 파일, 테스트 누락 가능성을 먼저 정리하세요.
실제 수정 전에 어떤 확인이 필요한지와 수정 후 어떤 검증을 해야 하는지를 함께 제시하세요.
""",
whenToUse: "수정 전에 영향 범위와 회귀 리스크를 빠르게 훑어야 할 때",
allowedTools: "file_read, grep, lsp_code_intel, folder_map"),
CreateBundledSkill(
"delivery-pack",
"전달 패키지",
"배포/공유 직전 필요한 결과물과 커뮤니케이션 패키지를 정리합니다.",
"""
배포 또는 공유 직전에 필요한 요약, 변경 포인트, 검증 상태, 사용자 전달 문구를 정리하세요.
문서, PPT, 보고서가 함께 필요한 경우 어떤 산출물을 어떤 순서로 만들지 제안하세요.
""",
whenToUse: "배포 직전, 보고 직전, 문서/PPT와 함께 전달 패키지를 정리해야 할 때",
allowedTools: "document_plan, document_assemble, pptx_create, docx_create, markdown_create")
];
}
@@ -1172,13 +1307,7 @@ public static class SkillService
score += 4;
if (!skill.UserInvocable)
score += 1;
score += skill.SourceScope switch
{
"project" => 3,
"plugin" => 2,
"bundled" => 1,
_ => 0,
};
score += Math.Max(0, GetSkillSourcePriority(skill.SourceScope) / 200);
if (skill.DisableModelInvocation)
score += 1;
@@ -1338,11 +1467,14 @@ public static class SkillService
return input;
var result = input;
var app = System.Windows.Application.Current as App;
var llm = app?.SettingsService?.Settings.Llm;
var blockedReason = GetInlineShellBlockedReason(skill, llm);
foreach (Match match in matches.Cast<Match>().Reverse())
{
ct.ThrowIfCancellationRequested();
var command = match.Groups[1].Value.Trim();
var output = await ExecuteInlineShellAsync(command, shellKind, workFolder, ct).ConfigureAwait(false);
var output = blockedReason ?? await ExecuteInlineShellAsync(command, shellKind, workFolder, ct).ConfigureAwait(false);
result = result.Remove(match.Index, match.Length).Insert(match.Index, output);
}
@@ -1425,6 +1557,19 @@ public static class SkillService
}
}
internal static string? GetInlineShellBlockedReason(SkillDefinition skill, Models.LlmSettings? llm)
{
if (llm is { EnableSkillInlineShell: false })
return "[inline-shell disabled by settings]";
return (skill.SourceScope ?? "").Trim().ToLowerInvariant() switch
{
"plugin" when !(llm?.AllowPluginSkillInlineShell ?? false) => "[inline-shell blocked for plugin skills]",
"mcp" when !(llm?.AllowMcpSkillInlineShell ?? false) => "[inline-shell blocked for mcp skills]",
_ => null,
};
}
private static string NormalizeEffort(string raw)
{
var effort = raw.Trim().ToLowerInvariant();
@@ -1734,6 +1879,8 @@ public sealed record CompiledSkillInvocation(
string SystemPrompt,
string DisplayText);
internal sealed record SkillLoadCandidate(int LoadOrder, SkillDefinition Skill);
internal sealed record SkillSourceDescriptor(string Directory, string Scope, SkillSourceKind Kind);
internal enum SkillSourceKind

View File

@@ -53,6 +53,7 @@ public class ToolRegistry : IDisposable
}
}
McpSkillCatalog.RefreshFromClients(_mcpClients.Values);
return registered;
}