MCP(Model Context Protocol)는 AI 모델과 외부 데이터 소스, 도구를 연결하는 표준 프로토콜입니다. AI가 사용자의 작업 문맥을 이해하고 다양한 정보를 전달할 수 있도록 해주며

닷넷에서 로컬에 작동하는 MCP Echo Local Server를 먼저 만들어본후 MCP를 액터모델을 이용해 Client-Server Model로 변신을 하는 변종실험을 해보겠습니다.

콘솔로 새프로젝트


필요 최소 Nuget 패키지



서버작동코드

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole(consoleLogOptions =>
{
    // Configure all logs to go to stderr
    consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
});

builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();

await builder.Build().RunAsync();


MCP 코드

using System.ComponentModel;
using ModelContextProtocol.Server;

namespace McpServer.Tools;

[McpServerToolType]
public static class EchoTool
{
    [McpServerTool, Description("Echoes the message back to the client.")]
    public static string Echo(string message) => $"Hello from C#: {message}";

    [McpServerTool, Description("Echoes in reverse the message sent by the client.")]
    public static string ReverseEcho(string message) => new string(message.Reverse().ToArray());
}


Description은 이것을 이용하는 LLM에게 툴의 설명을 주며 , LLM과 상호작용해 해당기능을 수행합니다.

MCP TestTool로는 VisualStudio Code + Copilot이 이용되었습니다.

VisualStudio Code




Visual Studio에 MCP 작동코드를 추가합니다.

{
    "mcp": {
        "servers": {
            "McpServer": {
              "type": "stdio",
              "command": "dotnet",
              "args": [
                "run",
                "--project",
                "D:\\Code\\Webnori\\NetCoreLabs\\McpServer\\McpServer.csproj",
				"--no-build",               
				]
            }
          }
    }
}

MCP Tool 선택



TEST

여기까지가 닷넷을 통해 MCP-Server 를 만들고 테스트해보는 가장 심플한 방법이자 가장 많이 알려진 방법입니다.


MCP에 노트작성기능과 검색기능 그리고 사용히스토리기능을 하는 액터베이스의 서버기능을 만들어 MCP-Server를 업그레이드 시도해보겠습니다.


액터모델과 함께 시도하는 구성도


코파일럿에서 여기서 구현된 MCP를 이용한 LLM샘플을 살펴보고 구현코드를 마지막으로 살펴보겠습니다.

작성된 MCP TOOL


노트작성


데이터 저장확인


키워드검색



반경검색


사용히스토리


MCP를 구현하는 대상은 모두 유닛테스트화가 되어 있으며

RAG에 활용이 되는 벡터검색도 가능하며 임베딩된 벡터값의 유사도를 검색할수 있습니다.

    [Fact(DisplayName = "SearchNoteByVectorAreOk")]
    public void SearchNoteByVectorAreOk()
    {
        var actorSystem = _akkaService.GetActorSystem();
        
        TestProbe testProbe = this.CreateTestProbe(actorSystem);
        
        var searchActor = actorSystem.ActorOf(Props.Create(() => new SearchActor()));
        
        searchActor.Tell(testProbe.Ref);

        testProbe.ExpectMsg("done-ready");

        Within(TimeSpan.FromMilliseconds(3000), () =>
        {
            searchActor.Tell(new SearchNoteByVectorCommand()
            {
                Vector = new float[] { 1.0f, 2.0f, 3.0f },
                TopN = 10
            });
            
            var result = testProbe.ExpectMsg<SearchNoteActorResult>();
            
            // Output results
            foreach (var note in result.Notes)
            {
                output.WriteLine($"Title: {note.Title}, Content: {note.Content}, Category: {note.Category}" +
                                 $" Latitude: {note.Latitude}, Longitude: {note.Longitude}");
            }

            Assert.NotNull(result.Notes);
        });
    }


노트저장소

public class NoteRepository
{
    private readonly IDocumentStore _store;
    
    
    public NoteRepository()
    {
        _store = new DocumentStore
        {
            Urls = new[] { "http://localhost:9000" },
            Database = "net-core-labs"
        };
        _store.Initialize();
        
        new NoteIndex().Execute(_store);
    }
    
    public void AddNote(NoteDocument note)
    {
        using (var session = _store.OpenSession())
        {
            session.Store(note);
            session.SaveChanges();
        }
    }
    
    public List<NoteDocument> SearchByText(string title,string content, string category)
    {
        using (var session = _store.OpenSession())
        {
            // 명시적으로 변수로 선언
            var titleValue = title;
            var contentValue = content;
            var categoryValue = category;

            IRavenQueryable<NoteDocument> query = session.Query<NoteDocument>();
            
            if (!string.IsNullOrEmpty(titleValue))
            {
                query = query.Search(r => r.Title, titleValue);         // 제목 풀텍스트 검색
            }

            if (!string.IsNullOrEmpty(contentValue))
            {
                query = query.Search(r => r.Content, contentValue);     // 컨텐츠 풀텍스트 검색
            }

            if (!string.IsNullOrEmpty(categoryValue))
            {
                query = query.Where(r => r.Category == categoryValue); // 카테고리 필터
            }
            
            var results = query.ToList();
            
            return results;
        }
    }
    
    public List<NoteDocument> SearchByRadius(double latitude, double longitude, double radiusKm)
    {
        using (var session = _store.OpenSession())
        {
            return session.Query<NoteDocument>()
                .Spatial(
                    r => r.Point(x => x.Latitude, x => x.Longitude),
                    criteria => criteria.WithinRadius(radiusKm, latitude, longitude))
                .ToList();
        }
    }
    
    public List<NoteDocument> SearchByVector(float[] vector, int topN = 5)
    {
        using (var session = _store.OpenSession())
        {
            var results = session.Query<NoteDocument>()
                .VectorSearch(
                    field => field.WithEmbedding(x => x.TagsEmbeddedAsSingle, VectorEmbeddingType.Single),
                    queryVector => queryVector.ByEmbedding(new RavenVector<float>(vector)),
                    0.85f,
                    topN)
                .Customize(x => x.WaitForNonStaleResults())
                .ToList();

            return results;
        }
    }
    
}



검색저장소를 이용하는 SearchActor

public class SearchActor : ReceiveActor
{
    private readonly ILoggingAdapter _logger = Context.GetLogger();

    private IActorRef? testProbe;
    
    private readonly NoteRepository noteRepository;
    
    private IActorRef? historyActor;
    
    public SearchActor()
    {
        noteRepository = new NoteRepository();
        
        Receive<IActorRef>(actorRef =>
        {
            testProbe = actorRef;

            testProbe.Tell("done-ready");
        });
        
        Receive<SetHistoryActorCommand>(msg =>
        {
            historyActor = msg.HistoryActor;
            
            if (testProbe != null)
            {
                testProbe.Tell("done-set-history");
            }
        });

        Receive<SearchNoteByTextCommand>(command =>
        {
            _logger.Info($"SearchNoteByTextCommand: {command.Title}, {command.Content}, {command.Category}");
            
            var notes = noteRepository.SearchByText(command.Title, command.Content, command.Category);
            
            _logger.Info($"SearchNoteByTextCommand: {notes.Count} notes found");
            
            Sender.Tell(new SearchNoteActorResult()
            {
                Notes = notes
            });
            
            if (testProbe != null)
            {
                testProbe.Tell(new SearchNoteActorResult()
                {
                    Notes = notes
                });
            }
            
            if(historyActor != null)
            {
                historyActor.Tell(notes);
            }
            
        });
        
        Receive<SearchNoteByRadiusActorCommand>(command =>
        {
            var notes = noteRepository.SearchByRadius(command.Latitude, command.Longitude, command.Radius);
            
            Sender.Tell(new SearchNoteActorResult()
            {
                Notes = notes
            });
            
            if (testProbe != null)
            {
                testProbe.Tell(new SearchNoteActorResult()
                {
                    Notes = notes
                });
            }
            
            if(historyActor != null)
            {
                historyActor.Tell(notes);
            }
            
        });
        
        Receive<SearchNoteByVectorCommand>(command =>
        {
            var notes = noteRepository.SearchByVector(command.Vector, command.TopN);
            
            Sender.Tell(new SearchNoteActorResult()
            {
                Notes = notes
            });
            
            if (testProbe != null)
            {
                testProbe.Tell(new SearchNoteActorResult()
                {
                    Notes = notes
                });
            }
            
            if(historyActor != null)
            {
                historyActor.Tell(notes);
            }
        });
    }
}


작성된 MCP Context

[McpServerToolType]
public static class NoteTool
{
    [McpServerTool, Description("웹노리 노트에 노트를 추가합니다.")]
    public static async Task<string> AddNote(ActorService actorService, 
        [Description("노트의 제목으로 필수값입니다.")] string title,
        [Description("노트의 컨텐츠값으로 필수값입니다.")] string content,
        [Description("노트의 카테고리입니다.")] string? category, 
        [Description("노트에 위치정보가 있다면 latitude를 입력")] double? latitude, 
        [Description("노트에 위치정보가 있다면 longitude 입력")] double? longitude,
        [Description("노트에 임베딩된 값이 있다면 입력")] float[]? tagsEmbeddedAsSingle
        )
    {
        var note = new NoteDocument
        {
            Title = title,
            Category = category,
            Content = content,
            Latitude = latitude,
            Longitude = longitude,
            CreatedAt = DateTime.UtcNow,
            TagsEmbeddedAsSingle = new RavenVector<float>(tagsEmbeddedAsSingle)
        };
        
        actorService.RecordActor.Tell(new AddNoteCommand()
        {
            Title = title,
            Category = category,
            Content = content,
            Latitude = latitude,
            Longitude = longitude,
            TagsEmbeddedAsSingle = new RavenVector<float>(tagsEmbeddedAsSingle)
        }, ActorRefs.NoSender);
        
        return JsonSerializer.Serialize(note);
    }
    
    [McpServerTool, Description("웹노리 노트에서 Text검색을 합니다. 최소 하나값이 필수입니다.")]
    public static async Task<string> SearchNoteByText(ActorService actorService, 
        [Description("웹노리 노트에서 타이틀을 키워드 검색합니다.")] string? title, 
        [Description("웹노리 노트에서 컨텐츠를 키워드 검색합니다.")] string? content, 
        [Description("웹노리 노트에서 카테고리를 키워드 검색합니다.")] string? category)
    {
        var result = await actorService.SearchActor.Ask(new SearchNoteByTextCommand()
        {
            Title = title,
            Content = content,
            Category = category
        }, TimeSpan.FromSeconds(5));
        
        if (result is SearchNoteActorResult searchResult)
        {
            return JsonSerializer.Serialize(searchResult.Notes);
        }
        
        return "Failed to get note history.";
    }
    
    [McpServerTool, Description("웹노리 노트에서 반경검색을 합니다. 모두 필수값입니다.")]
    public static async Task<string> SearchNoteByRadius(ActorService actorService, 
        [Description("This is the latitude of the note ")] double latitude, 
        [Description("This is the longitude of the note ")] double longitude, 
        [Description("This is the radius(m) of the note ")] double radius)
    {
        var result = await actorService.SearchActor.Ask(new SearchNoteByRadiusActorCommand()
        {
            Latitude = latitude,
            Longitude = longitude,
            Radius = radius
        }, TimeSpan.FromSeconds(5));
        
        if (result is SearchNoteActorResult searchResult)
        {
            return JsonSerializer.Serialize(searchResult.Notes);
        }
        
        return "Failed to get note history.";
    }
    
    [McpServerTool, Description("웹노리 노트에서 벡터검색을 합니다. 모두 필수값입니다.")]
    public static async Task<string> SearchNoteByVector(ActorService actorService, 
        [Description("This is the vector of the note  If there is no value, enter null.")] float[] vector, 
        [Description("This is the top N of the note  If there is no value, enter null.")] int topN)
    {
        var result = await actorService.SearchActor.Ask(new SearchNoteByVectorCommand()
        {
            Vector = vector,
            TopN = topN
        }, TimeSpan.FromSeconds(5));
        
        if (result is SearchNoteActorResult searchResult)
        {
            return JsonSerializer.Serialize(searchResult.Notes);
        }
        
        return "Failed to get note history.";
    }
    
    
    [McpServerTool, Description("웹노리 노트에서 최근 추가된 노트를 가져옵니다.")]
    public static async Task<string> GetNoteHistory(ActorService actorService)
    {
        //SearchNoteActorResult
        var result = await actorService.HistoryActor.Ask(new GetNoteHistoryCommand(), TimeSpan.FromSeconds(5));
        
        if (result is SearchNoteActorResult searchResult)
        {
            return JsonSerializer.Serialize(searchResult.Notes);
        }
        
        return "Failed to get note history.";
    }
    
    [McpServerTool, Description("웹노리 노트에서 최근 검색된 노트를 가져옵니다.")]
    public static async Task<string> GetNoteSearchHistory(ActorService actorService)
    {
        //SearchNoteActorResult
        var result = await actorService.HistoryActor.Ask(new GetNoteSearchHistoryCommand(), TimeSpan.FromSeconds(5));
        
        if (result is SearchNoteActorResult searchResult)
        {
            return JsonSerializer.Serialize(searchResult.Notes);
        }
        
        return "Failed to get note history.";
    }

    
}



클라이언트 서버모델

public class ActorService
{
    private readonly ActorSystem actorSystem;
    
    public IActorRef SearchActor { get; set; }
    
    public IActorRef RecordActor { get; set; }
    
    public IActorRef HistoryActor { get; set; }
    
    public ActorService(bool serverMode)
    {
        Console.WriteLine($"ActorService initialized in {(serverMode ? "Server" : "Client")} mode.");

        // HOCON 설정
        var config = ConfigurationFactory.ParseString($@"
            akka {{
                actor {{
                    provider = ""Akka.Remote.RemoteActorRefProvider, Akka.Remote""
                }}
                remote {{
                    dot-netty.tcp {{
                        hostname = ""127.0.0.1""
                        port = {(serverMode ? 5500 : 0)} // 서버 모드일 때만 포트 5500 사용
                    }}
                }}
            }}
        ");

        actorSystem = ActorSystem.Create("MyActorSystem", config);

        if (serverMode)
        {
            // 서버 모드일 때만 작동액터 생성 : MCP Server 
            SearchActor = actorSystem.ActorOf<SearchActor>("search-actor");
            RecordActor = actorSystem.ActorOf<RecordActor>("record-actor");
            HistoryActor = actorSystem.ActorOf<HistoryActor>("history-actor");

            RecordActor.Tell(new SetHistoryActorCommand()
            {
                HistoryActor = HistoryActor
            });

            SearchActor.Tell(new SetHistoryActorCommand()
            {
                HistoryActor = HistoryActor
            });
        }
        else
        {
            // 클라이언트 모드일 때 원격 액터 참조 : MCP Client
            var remoteAddress = "akka.tcp://MyActorSystem@127.0.0.1:5500";
            SearchActor = actorSystem.ActorSelection($"{remoteAddress}/user/search-actor")
                .ResolveOne(TimeSpan.FromSeconds(3)).Result;

            RecordActor = actorSystem.ActorSelection($"{remoteAddress}/user/record-actor")
                .ResolveOne(TimeSpan.FromSeconds(3)).Result;

            HistoryActor = actorSystem.ActorSelection($"{remoteAddress}/user/history-actor")
                .ResolveOne(TimeSpan.FromSeconds(3)).Result;
        }
    }
    
}



MCP Server


MCP-Client


이상 MCP와 액터모델을 이용해 Client(Mcp,리모트액터호출)/Server Mode(액터기능제공) 로 분리작동해본 변종 실험이였습니다.

전체코드 : https://github.com/psmon/NetCoreLabs/tree/main/McpServer