Files
AX-Copilot-Codex/src/AxCopilot/Services/McpClientService.cs
lacvet 33c1db4dae
Some checks failed
Release Gate / gate (push) Has been cancelled
에이전트 선택적 탐색 구조 개선과 경고 정리 반영
- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함

- FolderMapTool 기본 depth를 2로, include_files 기본값을 false로 낮추고 MultiReadTool 최대 파일 수를 8개로 줄여 초기 과탐색 폭을 보수적으로 조정함

- AgentLoopExplorationPolicy partial을 추가해 탐색 범위 분류, broad-scan corrective hint, exploration_breadth 성능 로그를 연결함

- AgentLoopService에 탐색 범위 가이드 주입과 실행 중 탐색 폭 추적을 추가하고, 좁은 질문에서 반복적인 folder_map/대량 multi_read를 교정하도록 정리함

- DocxToHtmlConverter nullable 경고를 수정해 Release 빌드 경고 0 / 오류 0 기준을 다시 충족함

- README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
2026-04-09 14:27:59 +09:00

333 lines
12 KiB
C#

using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services;
/// <summary>
/// MCP (Model Context Protocol) stdio 클라이언트.
/// 외부 MCP 서버 프로세스를 실행하고 JSON-RPC 2.0으로 통신합니다.
/// </summary>
public class McpClientService : IDisposable
{
private readonly McpServerEntry _config;
private Process? _process;
private StreamWriter? _writer;
private StreamReader? _reader;
private int _requestId;
internal bool _initialized;
private readonly List<McpToolDefinition> _tools = new();
private readonly List<McpResourceDefinition> _resources = new();
public string ServerName => _config.Name;
public bool IsConnected => _process is { HasExited: false };
public IReadOnlyList<McpToolDefinition> Tools => _tools;
public IReadOnlyList<McpResourceDefinition> Resources => _resources;
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
public McpClientService(McpServerEntry config)
{
_config = config;
}
/// <summary>MCP 서버 프로세스를 시작하고 초기화합니다.</summary>
public async Task<bool> ConnectAsync(CancellationToken ct = default)
{
try
{
if (string.IsNullOrEmpty(_config.Command))
{
LogService.Warn($"MCP '{_config.Name}': command가 비어있습니다.");
return false;
}
var psi = new ProcessStartInfo
{
FileName = _config.Command,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (var arg in _config.Args)
psi.ArgumentList.Add(arg);
foreach (var (key, value) in _config.Env)
psi.Environment[key] = value;
_process = Process.Start(psi);
if (_process == null)
{
LogService.Warn($"MCP '{_config.Name}': 프로세스 시작 실패.");
return false;
}
_writer = _process.StandardInput;
_reader = _process.StandardOutput;
// JSON-RPC: initialize
var initResult = await SendRequestAsync("initialize", new
{
protocolVersion = "2024-11-05",
capabilities = new { },
clientInfo = new { name = "AX Copilot", version = "0.7.3" },
}, ct);
if (initResult == null) return false;
// initialized notification
await SendNotificationAsync("notifications/initialized", ct);
_initialized = true;
// 도구 목록 가져오기
await RefreshToolsAsync(ct);
await RefreshResourcesAsync(ct);
LogService.Info($"MCP '{_config.Name}': 연결 성공 ({_tools.Count}개 도구).");
return true;
}
catch (Exception ex)
{
LogService.Warn($"MCP '{_config.Name}' 연결 실패: {ex.Message}");
return false;
}
}
/// <summary>서버에서 사용 가능한 도구 목록을 갱신합니다.</summary>
public async Task RefreshToolsAsync(CancellationToken ct = default)
{
_tools.Clear();
var result = await SendRequestAsync("tools/list", new { }, ct);
if (result == null) return;
try
{
if (result.Value.SafeTryGetProperty("tools", out var toolsArr))
{
foreach (var tool in toolsArr.EnumerateArray())
{
var def = new McpToolDefinition
{
Name = tool.GetProperty("name").SafeGetString() ?? "",
Description = tool.SafeTryGetProperty("description", out var desc) ? desc.SafeGetString() ?? "" : "",
ServerName = _config.Name,
};
if (tool.SafeTryGetProperty("inputSchema", out var schema) &&
schema.SafeTryGetProperty("properties", out var props))
{
foreach (var prop in props.EnumerateObject())
{
def.Parameters[prop.Name] = new McpParameterDef
{
Type = prop.Value.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "string" : "string",
Description = prop.Value.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "",
};
}
if (schema.SafeTryGetProperty("required", out var reqArr))
{
foreach (var req in reqArr.EnumerateArray())
{
var reqName = req.SafeGetString();
if (reqName != null && def.Parameters.TryGetValue(reqName, out var p))
p.Required = true;
}
}
}
_tools.Add(def);
}
}
}
catch (Exception ex)
{
LogService.Warn($"MCP '{_config.Name}' 도구 파싱 실패: {ex.Message}");
}
}
/// <summary>서버에서 사용 가능한 리소스 목록을 갱신합니다.</summary>
public async Task RefreshResourcesAsync(CancellationToken ct = default)
{
_resources.Clear();
var result = await SendRequestAsync("resources/list", new { }, ct);
if (result == null) return;
try
{
if (result.Value.SafeTryGetProperty("resources", out var resourcesArr))
{
foreach (var resource in resourcesArr.EnumerateArray())
{
_resources.Add(new McpResourceDefinition
{
Uri = resource.SafeTryGetProperty("uri", out var uri) ? uri.SafeGetString() ?? "" : "",
Name = resource.SafeTryGetProperty("name", out var name) ? name.SafeGetString() ?? "" : "",
Description = resource.SafeTryGetProperty("description", out var desc) ? desc.SafeGetString() ?? "" : "",
MimeType = resource.SafeTryGetProperty("mimeType", out var mime) ? mime.SafeGetString() ?? "" : "",
ServerName = _config.Name,
});
}
}
}
catch (Exception ex)
{
LogService.Warn($"MCP '{_config.Name}' 리소스 파싱 실패: {ex.Message}");
}
}
/// <summary>MCP 도구를 호출합니다.</summary>
public async Task<string> CallToolAsync(string toolName, Dictionary<string, object> arguments, CancellationToken ct = default)
{
var result = await SendRequestAsync("tools/call", new
{
name = toolName,
arguments,
}, ct);
if (result == null) return "[MCP 호출 실패]";
try
{
if (result.Value.SafeTryGetProperty("content", out var contentArr))
{
var sb = new StringBuilder();
foreach (var item in contentArr.EnumerateArray())
{
if (item.SafeTryGetProperty("text", out var text))
sb.AppendLine(text.SafeGetString());
}
return sb.ToString().TrimEnd();
}
if (result.Value.SafeTryGetProperty("isError", out var isErr) && isErr.GetBoolean())
{
return $"[MCP 오류] {result}";
}
}
catch { }
return result?.ToString() ?? "[MCP 빈 응답]";
}
public async Task<IReadOnlyList<McpResourceDefinition>> ListResourcesAsync(CancellationToken ct = default)
{
await RefreshResourcesAsync(ct);
return _resources.ToList();
}
public async Task<string> ReadResourceAsync(string uri, CancellationToken ct = default)
{
var result = await SendRequestAsync("resources/read", new { uri }, ct);
if (result == null) return "[MCP 리소스 읽기 실패]";
try
{
if (result.Value.SafeTryGetProperty("contents", out var contentsArr))
{
var sb = new StringBuilder();
foreach (var item in contentsArr.EnumerateArray())
{
if (item.SafeTryGetProperty("text", out var text))
sb.AppendLine(text.SafeGetString());
else if (item.SafeTryGetProperty("uri", out var itemUri))
sb.AppendLine($"uri: {itemUri.SafeGetString()}");
}
return sb.ToString().TrimEnd();
}
}
catch { }
return result?.ToString() ?? "[MCP 리소스 빈 응답]";
}
private async Task<JsonElement?> SendRequestAsync(string method, object parameters, CancellationToken ct)
{
if (_writer == null || _reader == null) return null;
var id = Interlocked.Increment(ref _requestId);
var request = new
{
jsonrpc = "2.0",
id,
method,
@params = parameters,
};
var json = JsonSerializer.Serialize(request, _jsonOpts);
await _writer.WriteLineAsync(json);
await _writer.FlushAsync();
// 응답 읽기 (동기식 대기, 타임아웃 10초)
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(10));
while (!cts.Token.IsCancellationRequested)
{
var line = await _reader.ReadLineAsync(cts.Token);
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
// notification은 건너뛰기
if (!root.SafeTryGetProperty("id", out _)) continue;
if (root.SafeTryGetProperty("result", out var result))
return result;
if (root.SafeTryGetProperty("error", out var error))
{
var msg = error.SafeTryGetProperty("message", out var m) ? m.SafeGetString() : "Unknown error";
LogService.Warn($"MCP '{_config.Name}' RPC 오류: {msg}");
return null;
}
}
catch { continue; }
}
return null;
}
private async Task SendNotificationAsync(string method, CancellationToken ct)
{
if (_writer == null) return;
var notification = new { jsonrpc = "2.0", method };
var json = JsonSerializer.Serialize(notification, _jsonOpts);
await _writer.WriteLineAsync(json);
await _writer.FlushAsync();
}
public void Dispose()
{
try
{
_writer?.Dispose();
_reader?.Dispose();
if (_process is { HasExited: false })
{
_process.Kill(entireProcessTree: true);
_process.Dispose();
}
}
catch { }
}
}