Versions Compared

Key

  • This line was added.
  • This line was removed.
  • Formatting was changed.

...

닷넷에서 로컬에 작동하는 MCP EchoServer를 먼저 만들어본후 MCP에 액터모델을 연결해 Client-Server Model로 확장하는 변종실험으로  간단한 노트작성및 검색기능을 탑재해보았습니다.

 


콘솔로 새프로젝트

  • 타켓 프레임워크 : 9.0 으로하기
    • 사용된 ModelContextProtocol-Preview 버전이 타깃 프레임워크 9.0부터 지원을 하네요

...


필요 최소 Nuget 패키지

  • ModelContextProtocol
  • ModelContextProtocol.NET.Server

 

 



서버작동코드

Code Block
themeEmacs

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 코드

Code Block
themeEmacs

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과 상호작용해 해당기능을 수행합니다.

...

VisualStudio Code

  • Agent모드로 전환해줍니다.

 

...



  • Add MCP Server를 해줍니다.

...


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

Code Block
themeEmacs

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

...

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


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

 


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

draw.io Board Diagram
bordertrue
diagramNamemcp with actor
simpleViewerfalse
width
linksauto
tbstyletop
lboxtrue
diagramWidth1463
revision1

  • MCP Client : MCP Tool을 셋업할때 MCP Server라고 부르지만~ 여기서 구성되는 전체그림에서는 Client이기때문에 Client로 표현했습니다. 
  • MCP Server : MCP Client에게 액터로 구성된 기능들을 제공합니다.
    • RavenDB : 도큐먼트 DB로 몽고DB와 유사하지만 AICD,풀텍스트검색,반경검색,벡터검색,그래프검색등 다양한 검색을 보편적으로 지원하며 MCP및 RAG 연구할때 AI연구 DB로 심플하게 이용할수 있습니다.  - RavenDB with Akka.net
    • RecoderActor : 노트를 작성합니다.
    • SearchActor : 노트검색기능을 제공합니다.
    • HistoryActor : 노트작성및 검색 이용기록을 인메모리로 보유해 요청하면 이력을 제공합니다.

...


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

...

  • Agent LLM에서 Tool을 선택해 이용할수있습니다.
  • 노트는 보편적단어이니 구현된 컨텍스트 설명에 웹노리노트라고 명시해 프롬프트 작성시 웹노리 노트 작성해라고 하면 여기서 구현된 MCP툴이 작동됩니다.

노트작성

 


데이터 저장확인

  • LLM에 의해 데이터가 작 작성되었습니다.

...


키워드검색

 


반경검색

 


사용히스토리

  • 최근작성과 최근검색 히스토리를 제공합니다. - DB사용없이 인메모리

 


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

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

Code Block
themeEmacs

    [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);
        });
    }

...


노트저장소

Code Block
themeEmacs

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

Code Block
themeEmacs

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);
            }
        });
    }
}
  • historyActor를 초기화중 지정이 될수있으며, 검색결과 이용기록을 보존합니다. 최근 검색한 노트를 요청해 다시 이용할수 있습니다.

...

작성된 MCP Context

Code Block
themeEmacs

[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.";
    }

    
}

 

...

  • Local Actor를 이용할수도 있지만 복제구성을 해 Client-Server 작동으로 구분하였으며 여기서는 별도로 구동된 Remote에 위치한 서버의 액터를 이용합니다. 
  • 초기개발은 단일Local StandAlone으로만 작동가능하며 이후 전략에따라 실제 작동액터는 별도 구성된 서버로 코드변경없이 분리할수 있는것이 액터모델의 장점입니다.


클라이언트 서버모델

Code Block
themeEmacs

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;
        }
    }
    
}
  • LLM이 이용하는 것은 MCP Client이며 , Client는 LLM실행시마다 Context가 다시 초기화되기때문에~ 상태를 유지하는 Server 객체를 이용합니다.
  • 동일 작동코드에서 다른 지점은 이부분으로 Client는 Server가 생성한 액터모델을 Remote로 이용하게 됩니다.
    • 원격액터는 클러스터 배치및~ 액터 샤딩을 통한 멀티테넌스처리도 가능하게됩니다.  여기서는 싱글톤 객체와 유사하게 구성되었습니다.
  • Server 독립된 서버 기능을 이용하기위해 기능을 이용해 MCP가 추가 이용하려고하면 API Endpoint화 하는것이 일반적이지만, 변종실험으로 로컬로 작성된 액터는 코드의 큰변경없이 Remote를 탑재해 클라이언트/서버모드 또는 클러스터모드로 확장할수 있습니다.

...

  • RestAPI Endpoint화 하고 이벤트를 내부 분산처리하는경우 Kafka를 추가로 이용할수 있겠지만 여기서는 AkakRemote Protocol을 이용해 그러한 기능들이 한방에 포함되었습니다.

MCP Server

  • MCP에 대응하는 단위테스트를 먼저작성해 연결하는 바톰~업 방식을 이용하였으며 , 닷넷 메인 IDE에서는 서버 디버깅및 Server를 구동했습니다.

 


MCP-Client

  • 동일저장소에서 clientMode 옵션을 주면 클라이언트 모드로 작동하며 MCP자체에 대응합니다. 

 


닷넷진영 MCP확장가능성

ML.NET

...


Cluter 분산처리

...


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

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