Исходные данные: ПК на базе AMD Ryzen™ AI 9 HX 370, ОЗУ 96 Гб из которых половина отдана под видеопамять. LM Studio, движок Vulkan llama.cpp (Windows). Модель для инференса: qwen/qwen3-coder-30b на архитектуре
qwen3moe, выдает от 10 до 18 токенов в секунду. Модель для эмбеддингов: nomic-embed-text-v2-moe-GGUF на архитектуреnomic-bert-moe. На этой конфигурации рекомендую работать с моделями с архитектурой MoE (Mixture of Experts). Другие ("плотные") модели дают скорость значительно меньше.
Попробуем написать MCP (Model Context Protocol) сервер на c#, развязывающий руки нашим локальным ИИ моделям в LM Studio: поиск в интернете, поиск в локальной папке среди word pdf и прочих документов. Конечно этот MCP сервер можно применить и для другого софта поддерживающего этот протокол.
Идея возникла после прочтения статьи "Учим LM Studio ходить в интернет при ответах на вопросы". Поддержка mcp появилась в LM Studio сравнительно недавно. Захотелось написать что-то своё но с перламутровыми пуговицами. Вообще есть уже готовые MCP сервера например здесь: https://mcpservers.org/all. Но я честно не пробовал оттуда что ни будь установить.
Итак. Как пишет гугл, протокол MCP был разработан и представлен компанией Anthropic в ноябре 2024 года. Этот протокол позволяет модели выполнять любые действия: от поиска в интернете, до управление умным домом. Этакий глобальный набор инструментов (tools) который я ранее реализовывал в этой статье "Алиса, подвинься" для моделей которые умеют их вызывать (Function Calling) для ответа пользователю. Сайт: https://modelcontextprotocol.io
У мелкософта уже всё схвачено, есть примеры на C#, Java, JavaScript, Python, TypeScript, Rust: https://github.com/microsoft/mcp-for-beginners. Начнем писать свой сервер. Добавим NuGet пакеты: Microsoft.Extensions.Hosting и ModelContextProtocol.
КодProgram.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
// Configure all logs to go to stderr (stdout is used for the MCP protocol messages).
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
// Add the MCP services: the transport to use (stdio) and the tools to register.
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<RandomNumberTools>(); //<--- Наш первый функционал
await builder.Build().RunAsync();
RandomNumberTools.cs генерирующий числа от 0 до 100.
using System.ComponentModel;
using ModelContextProtocol.Server;
public class RandomNumberTools
{
[McpServerTool]
[Description("Generates a random number between the specified minimum and maximum values.")]
public int GetRandomNumber(
[Description("Minimum value (inclusive)")] int min = 0,
[Description("Maximum value (exclusive)")] int max = 100)
{
return Random.Shared.Next(min, max);
}
}
Переключаемся в режим "Power User" либо "Developer". Появляется значок настроек. Нажимаем на нём, затем "Program", "Install", "Edit mcp.json".
Скриншоты


Теперь мы можем указать имя нашего сервера "my-mcp-example" и полный путь для его запуска в mcp.json:
{
"mcpServers": {
"my-mcp-example": {
"command": "C:\\Users\\user\\Desktop\\mcp-server\\SampleMcpServer\\bin\\Debug\\net8.0\\win-x64\\SampleMcpServer.exe"
}
}
}Жмём "Save" и видим наш сервер с доступным функционалом:
СкриншотыВключаем MCP сервер:

Выбираем спросить перед запуском у пользователя "Ask before running", либо всегда разрешать без вашего запроса "Always allow":

Готово! Осталось вернуться в чат и спросить модель:
напиши случайное число от 10 до 100
Видно как модель запустила нашу функцию GetRandomNumber (хотя она отображается как get_random_number), заполнила данными которые мы ей озвучили, и получила результат в виде json, который и использовала для ответа пользователю.
У ИИ есть некоторая проблема с точными математическими вычислениями. Но зачем напрягать локальную модель, тратить ватты на ризонинг модели, если можно дать ей в руки калькулятор?
CalcTools.csusing System.ComponentModel;
using ModelContextProtocol.Server;
public class CalcTools
{
[McpServerTool, Description("Adds two numbers and returns the result.")]
public static double Add(
[Description("First number")] double a,
[Description("Second number")] double b)
{
return a + b;
}
[McpServerTool, Description("Subtracts the second number from the first and returns the result.")]
public static double Subtract(
[Description("First number")] double a,
[Description("Second number")] double b)
{
return a - b;
}
[McpServerTool, Description("Multiplies two numbers and returns the result.")]
public static double Multiply(
[Description("First number")] double a,
[Description("Second number")] double b)
{
return a * b;
}
[McpServerTool, Description("Divides the first number by the second and returns the result.")]
public static double Divide(
[Description("Dividend (number to be divided)")] double a,
[Description("Divisor (number to divide by)")] double b)
{
if (b == 0)
throw new ArgumentException("Cannot divide by zero");
return a / b;
}
[McpServerTool, Description("Calculates the power of a number.")]
public static double Power(
[Description("Base number")] double baseNumber,
[Description("Exponent")] double exponent)
{
return Math.Pow(baseNumber, exponent);
}
[McpServerTool, Description("Calculates the square root of a number.")]
public static double SquareRoot([Description("Number to calculate square root of")] double number)
{
if (number < 0)
throw new ArgumentException("Cannot calculate square root of negative number");
return Math.Sqrt(number);
}
}Подключаем тулзу:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
// Configure all logs to go to stderr (stdout is used for the MCP protocol messages).
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
// Add the MCP services: the transport to use (stdio) and the tools to register.
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<RandomNumberTools>()
.WithTools<CalcTools>(); //<--- наш калькулятор
await builder.Build().RunAsync();
Собираем проект. В LM Studio обновляем через значок, либо через "Force Restart":

И наши функции появляются:


Видно как модель последовательно выполнила все необходимые математические операции без особого напряга.
Давайте попробуем поработать с файлами: сохраним текст в файл, прочитаем, и выведем список всех файлов из папки.
FileOperationsTools.csusing System.ComponentModel;
using ModelContextProtocol.Server;
using System.Text;
public class FileOperationsTools
{
[McpServerTool]
[Description("Writes content to a file.")]
public string WriteFile(
[Description("The file name with full path")] string filename,
[Description("The content")] string content
)
{
try
{
//не будем перезаписывать или дозаписывать
//в уже существующий файл
//что бы не испортить его
if (File.Exists(filename))
return "Such a file already exists!";
File.WriteAllText(filename, content);
return "Сontext write successful.";
}
catch (Exception ex)
{
return $"Error write context to file: {ex.Message}";
}
}
[McpServerTool]
[Description("Read file from the specified path.")]
public string ReadFile(
[Description("The file name with full path")] string filename)
{
//определеляем кодировку utf встроенным в StreamReader методом
Encoding encoding = Encoding.Unicode;
using (StreamReader reader = new StreamReader(filename, true))
{
//читаем один байт (BOM)
while (reader.Peek() >= 0)
{
encoding = reader.CurrentEncoding;
break;
}
reader.Close();
}
return File.ReadAllText(filename, encoding);
}
[McpServerTool]
[Description("Lists files in the specified directory.")]
public string ListFiles(
[Description("The path of the directory to list files from")] string path)
{
try
{
if (!Directory.Exists(path))
{
return $"Error: Directory '{path}' does not exist.";
}
var files = Directory.GetFiles(path);
var directories = Directory.GetDirectories(path);
var result = new List<string>();
result.Add("Files:");
foreach (var file in files)
{
var fileInfo = new FileInfo(file);
result.Add($" {fileInfo.Name} ({fileInfo.Length} bytes)");
}
result.Add("\nDirectories:");
foreach (var directory in directories)
{
var dirInfo = new DirectoryInfo(directory);
result.Add($" {dirInfo.Name}/");
}
return string.Join("\n", result);
}
catch (Exception ex)
{
return $"Error listing files: {ex.Message}";
}
}
}
сы


Ладно. Всё это конечно интересно. Но нас больше всего интересует более полезный функционал.
Казалось бы, искать в интернете очень легко: вводишь в поисковик текст, и готово. Но с программной точки зрения не всё так просто. Поисковые системы, в том числе гугл, не дадут просто так взять и загрузить через HttpClient готовую статичную страничку с результатами поиска. Поисковая система выдает html болванку и ссылки на js скрипты, которые собственно и генерируют красивую готовую страничку с результатами. Притом и скрипты эти часто в обфусцированном (т.е. в запутанном, зашифрованном) виде.
Поисковые системы предлагают для этого свой API, с которым можно работать только если ты зарегистрируешься в их системе, и получишь свой персональный токен, без которого любой запрос к API - бесполезен. Зачем всё это? Конечно для заработка на тех, кто хочет воспользоваться поиском. Хочешь искать? Заплати денюжку, токен и заработает...
Не надо путать понятие "токен" от API и "токен" ИИ. Это две разные вещи. Токен от API - это ключ без которого API работать не будет. Сейчас речь только про токен от API.
Конечно есть и бесплатные токены для обычных людей, но присутствуют и ограничения по поиску: 1 бесплатный токен на одного пользователя, 1000 запросов в месяц, и т.д. Условия везде разные.
Можно поступить по другому: воспользоваться компонентом который будет загружать, рендерить, и выдавать готовую страницу как настоящий браузер. Даже не как настоящий, а по настоящему настоящий. Тот же playwright - внутри этого компонента есть движки Chromium, WebKit, Firefox.
Для "домашнего использования" я нашел два варианта: поисковик duckduckgo выдающий статичную страницу (да, вот такой щедрый поисковик) и firecraw выдающий информацию по API с бесплатным токеном.
Как настоящие исследователи-разработчики, попробуем сами разобраться в том как работает поиск, и как его реализовать. Открываем хром или edge, нажимаем F12, переключаемся во вкладку Network, вводим в поисковик "https://html.duckduckgo.com/html", жмем Enter. Открывается форма поиска. Вводим любой текст для поиска, например "погода в москве", жмем Enter, нажимаем на строке "html/", переключаемся во вкладку Headers. Видим что для поиска вызывается метод POST.
Скриншот
Видим что на форме есть еще параметры поиска "All regions", и "Any Time". Выберем любые параметры, очистим лог Ctrl+L либо значком. Нажимаем поиск, и видим что в метод POST передаются параметры: q="погода в москве", kl="ru-ru" (регион), df ="w" (период, в данном случае - неделя).
Скриншот
Ни слова больше! Реализуем! Получаем содержимое страницы, и парсим с помощью HtmlAgilityPack.
WebPageLoader и DuckDuckGoSearch.csКомпонент реализующий Post и Get запросы.
public static class WebPageLoader
{
public static async Task<string> Post(string url, TimeSpan timeout, Dictionary<string, string> postData)
{
using HttpClient client = new() { Timeout = timeout };
// Создаем контент для POST-запроса, кодируя данные формы
using var content = new FormUrlEncodedContent(postData);
// Отправляем POST-запрос
try
{
var response = await client.PostAsync(url, content);
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}
throw new Exception($"Request to {url} failed with status code: {response.StatusCode}");
}
catch(HttpRequestException ex)
{
return ex.Message;
}
}
public static async Task<string> Get(string url, TimeSpan timeout, Dictionary<string,string?>? headers = null)
{
using HttpClient client = new() { Timeout = timeout };
// Отправляем GET-запрос
try
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
if (headers != null)
foreach (var item in headers)
{
request.Headers.Add(item.Key, item.Value);
}
var response = await client.SendAsync(request);
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}
throw new Exception($"Request to {url} failed with status code: {response.StatusCode}");
}
catch (HttpRequestException ex)
{
return ex.Message;
}
}
public class SearchResultItem
{
public string? Title { get; set; }
public string? Link { get; set; }
public string? Content { get; set; }
}
}DuckDuckGoSearch.cs
using HtmlAgilityPack;
using Newtonsoft.Json;
using System.Text.RegularExpressions;
using static WebPageLoader;
public static class DuckDuckGoSearch
{
public static async Task<List<SearchResultItem>> LoadAsync(string query, string? region = null, string? time = null)
{
var result = new List<SearchResultItem>();
var postData = new Dictionary<string, string>() { { "q", query } };
if (!string.IsNullOrEmpty(region)) postData.Add("kl", region);
if (!string.IsNullOrEmpty(time)) postData.Add("df", time);
var page = await WebPageLoader.Post("https://html.duckduckgo.com/html", TimeSpan.FromSeconds(30), postData);
try
{
var doc = new HtmlDocument();
doc.LoadHtml(page);
var resultNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'results_links_deep')]");
if (resultNodes != null)
{
foreach (var resultNode in resultNodes)
{
var title = resultNode.SelectSingleNode(".//a[contains(@class, 'result__a')]")?.InnerText.Trim() ?? string.Empty;
var linkNode = resultNode.SelectSingleNode(".//a[contains(@class, 'result__snippet')]");
var link = linkNode?.GetAttributeValue("href", string.Empty);
var content = linkNode?.InnerText.Trim() ?? string.Empty;
if (!string.IsNullOrEmpty(link))
{
link = link.Replace("//duckduckgo.com/l/?uddg=", "");
link = Regex.Replace(link, "&rut=.*", "");
link = Uri.UnescapeDataString(link);
}
result.Add(new SearchResultItem { Title = title, Link = link, Content = content });
}
}
}
catch (Exception e)
{
}
return result;
}
}Есть еще получение общей информации в виде json через такой запрос: https://api.duckduckgo.com/?q=москва&format=json&no_redirect=1&no_html=1&skip_disambig=1, но для сложных запросов типа "q=погода в москве" - результат будет пустой. Этот поиск не предназначен для полноценных запросов.
Это такой инструмент для извлечения информации из web страниц и получения готовой структурированной информации для ИИ. Тут всё проще: устанавливаем пакет Firecrawl.
FirecrawlSearch.csusing Firecrawl;
using static WebPageLoader;
public static class FirecrawlSearch
{
public static async Task<List<SearchResultItem>> LoadAsync(string query, string apiKey)
{
var result = new List<SearchResultItem>();
// Initialize Firecrawl client with your API key
var client = new FirecrawlApp(apiKey);
// Perform the search - checking available methods in Firecrawl library
var searchResults = await client.Search.SearchAndScrapeAsync(query);
var results = new List<string>();
foreach (var data in searchResults.Data)
{
result.Add(new SearchResultItem { Title = data.Title, Link = data.Url, Content = data.Description });
}
return result;
}
}Для использования этого инструмента необходим токен (apiKey), без которого поиск работать не будет. Регистрируемся и получаем токен https://www.firecrawl.dev/.
Теперь нам нужно этот токен передать MCP серверу. Не хранить же его в коде.
Давайте сделаем так, что бы в настройках mcp.json можно было указать список поисковых движков в WEB_SEARCH_ENGINES и этот токен в WEB_SEARCH_FirecrawApiKey. И да, можно еще передать регион для DuckDuckgo в WEB_SEARCH_duckduckgoRegion:
{
"mcpServers": {
"my-mcp-example": {
"command": "C:\\Users\\user\\Desktop\\mcp-server\\SampleMcpServer\\bin\\Debug\\net8.0\\win-x64\\SampleMcpServer.exe",
"env": {
"WEB_SEARCH_ENGINES": "DuckDuckGo,Firecraw",
"WEB_SEARCH_FirecrawApiKey": "fc-xxxxxxxxxxxxxxxxxxxxxxxxxx",
"WEB_SEARCH_duckduckgoRegion": "ru-ru"
}
}
}
}Ну и еще один поисковик который может выдать информацию в json. Толку для поиска на русском языке от него нет, поскольку он выдает результаты на русском языке слитно без пробелов. Только ради эксперимента. Кстати, какие параметры подставлять - поделился ИИ-режим самого www.baidu.com.
Baidu помогает правильно сформировать строку поиска
using Newtonsoft.Json;
using System.Text;
using static WebPageLoader;
public class BaiduSearch
{
public static async Task<IEnumerable<WebPageLoader.SearchResultItem>> LoadAsync(string query, int top)
{
var result = new List<SearchResultItem>();
query = Uri.EscapeDataString(query);
//rn - ограничение в поиск от 1 до 50
//cr=ru - приоритет на русском языке
//ie=utf-8 - для корректного отображения на кириллице
//pn=1 новостная лента
var cr = "ru";
var headers = new Dictionary<string, string?>() {{ "Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" }};
var page = await WebPageLoader.Get($"https://www.baidu.com/s?wd={query}&tn=json&rn={top}&cr={cr}&ie=utf-8&pn=0", TimeSpan.FromSeconds(30), headers);
var options = new JsonSerializerSettings { Formatting = Formatting.Indented, StringEscapeHandling=StringEscapeHandling.EscapeHtml };
var data = JsonConvert.DeserializeObject<Root>(page, options);
foreach (var resultNode in data.feed.entry)
{
if (!string.IsNullOrEmpty(resultNode.abs))
result.Add(new SearchResultItem { Title = resultNode.title, Link = resultNode.url, Content = resultNode.abs });
}
return result;
}
/*
class Author
{
public string name { get; set; }
public string url { get; set; }
}
class Category
{
public string label { get; set; }
public string value { get; set; }
}
*/
class Entry
{
public string title { get; set; }
public string abs { get; set; }
public string url { get; set; }
public string urlEnc { get; set; }
public string time { get; set; }
/*
public string source { get; set; }
public Category category { get; set; }
public string imgUrl { get; set; }
public string relate { get; set; }
public string same { get; set; }
public string pn { get; set; }
*/
}
class Feed
{
/*
public string requestUrl { get; set; }
public string updated { get; set; }
public string description { get; set; }
public string relateUrl { get; set; }
public Category category { get; set; }
public Author author { get; set; }
public string all { get; set; }
public string resultnum { get; set; }
public string pn { get; set; }
public string rn { get; set; }
*/
public List<Entry> entry { get; set; }
}
class Root
{
public Feed feed { get; set; }
}
}Лишние свойства json закомментировал.
Теперь реализуем тулзу:
InternetSearchTools.csusing ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text.Encodings.Web;
using System.Text.Json;
using static WebPageLoader;
public class InternetSearchTools
{
[McpServerTool]
[Description("Performs a web search.")]
public async Task<string> WebSearch(
[Description("The search query")] string query)
{
//движки
var searchEngines = Environment.GetEnvironmentVariable("WEB_SEARCH_ENGINES");
//токен для Firecraw
var FirecrawApiKey = Environment.GetEnvironmentVariable("WEB_SEARCH_FirecrawApiKey");
//регион для duckduckgo
var duckduckgoRegion = Environment.GetEnvironmentVariable("WEB_SEARCH_duckduckgoRegion");
var result = new List<SearchResultItem>();
try
{
// Десериализация строки в массив строк
string[] engines = searchEngines.Split(",");
// Использование массива
foreach (string engine in engines)
{
if (engine.ToLower().Contains("duckduckgo")) result.AddRange(await DuckDuckGoSearch.LoadAsync(query, duckduckgoRegion));
if (engine.ToLower().Contains("firecraw")) result.AddRange(await FirecrawlSearch.LoadAsync(query, FirecrawApiKey));
if (engine.ToLower().Contains("baidu")) result.AddRange(await BaiduSearch.LoadAsync(query, top:5));
}
}
catch (Exception ex)
{
return ex.Message;
}
var options = new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.Create(new TextEncoderSettings(System.Text.Unicode.UnicodeRanges.All)) };
return System.Text.Json.JsonSerializer.Serialize(result, options);
}
}Необходимо не забыть добавить тулзу в Program.cs, сделать сборку, и перезагрузить MCP сервер в LM Studio.
Результат
Поиск кода на гитхабе тоже требует токен. Для этого заходим под своей учеткой на https://github.com/settings/personal-access-tokens, создаем токен с правами на чтение: access to code, issues, metadata, and pages.
Скриншот
Для поиска репозитория и кода добавим в настройки mcp.json передачу токена в GUTHUB_TOKEN.
{
"mcpServers": {
"my-mcp-example": {
"command": "C:\\Users\\user\\Desktop\\mcp-server\\SampleMcpServer\\bin\\Debug\\net8.0\\win-x64\\SampleMcpServer.exe",
"env": {
"WEB_SEARCH_ENGINES": "DuckDuckGo,Firecraw",
"WEB_SEARCH_FirecrawApiKey": "fc-xxxxxxxxxxxxxxxxxxxxxxxxxx",
"WEB_SEARCH_duckduckgoRegion": "ru-ru",
"GUTHUB_TOKEN": "github_pat_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
}
}
}
}GitHubSearchTool.csusing System.ComponentModel;
using System.Net.Http.Headers;
using System.Text;
using ModelContextProtocol.Server;
using Newtonsoft.Json.Linq;
public class GitHubSearchTool
{
private readonly HttpClient httpClient;
public GitHubSearchTool()
{
var githubToken = Environment.GetEnvironmentVariable("GUTHUB_TOKEN");
httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("MyGitHubSearchApp", "1.0"));
if (!string.IsNullOrEmpty(githubToken))
{
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", githubToken);
}
}
[McpServerTool]
[Description("Searches GitHub repositories using the GitHub API.")]
public async Task<string> SearchRepositories(
[Description("The search query for repositories")] string query,
[Description("The codeLanguage")] string codeLanguage = "",
[Description("The limit")] int limit = 3)
{
try
{
var queryString = Uri.EscapeDataString(query);
if (!string.IsNullOrEmpty(codeLanguage)) queryString = queryString + "+" + Uri.EscapeDataString($"language:{codeLanguage}");
var url = $"https://api.github.com/search/repositories?q={queryString}&per_page={limit}&sort=stars&order=desc";
var response = await httpClient.GetAsync(url);
response.EnsureSuccessStatusCode(); // Throws an exception if not successful
var jsonString = await response.Content.ReadAsStringAsync();
var json = JObject.Parse(jsonString);
var results = new List<string>();
foreach (var item in json["items"])
{
var repoName = item["full_name"]?.ToString() ?? "";
var repoDescription = item["description"]?.ToString() ?? "";
var repoUrl = item["html_url"]?.ToString() ?? "";
results.Add($"Repository: {repoName}\nDescription: {repoDescription}\nURL: {repoUrl}");
}
return results.Count > 0
? string.Join("\n\n", results)
: $"No repositories found for '{query}'";
}
catch (Exception ex)
{
return $"Error searching repositories: {ex.Message}";
}
}
[McpServerTool]
[Description("Searches GitHub code using the GitHub API.")]
public async Task<string> SearchCode(
[Description("The search query for repositories")] string query,
[Description("The repository name")] string repo = "",
[Description("The codeLanguage")] string codeLanguage = "",
[Description("The limit")] int limit = 3
)
{
try
{
var queryString = Uri.EscapeDataString(query);
if (!string.IsNullOrEmpty(repo)) queryString = queryString + "+" + Uri.EscapeDataString($"language:{repo}");
if (!string.IsNullOrEmpty(codeLanguage)) queryString = queryString + "+" + Uri.EscapeDataString($"language:{codeLanguage}");
var url = $"https://api.github.com/search/code?q={queryString}&per_page={limit}&sort=stars&order=desc";
var response = await httpClient.GetAsync(url);
response.EnsureSuccessStatusCode(); // Throws an exception if not successful
var jsonString = await response.Content.ReadAsStringAsync();
var json = JObject.Parse(jsonString);
//находим файлы в репозитории
var items = new List<CodeSearch>();
foreach (var item in json["items"])
{
var repoName = item["repository"]?["full_name"]?.ToString() ?? "";
var fileName = item["name"]?.ToString() ?? "";
var fileUrl = item["url"]?.ToString() ?? "";
items.Add(new CodeSearch() { RepoName = repoName, FileName = fileName, FileUrl = fileUrl });
}
//скачиваем содержимое файла
var results = new List<string>();
foreach (var item in items)
{
var res = await httpClient.GetAsync(item.FileUrl);
res.EnsureSuccessStatusCode(); // Throws an exception if not successful
var jsonString2 = await res.Content.ReadAsStringAsync();
var json2 = JObject.Parse(jsonString2);
var content = json2["content"].ToString();
byte[] data = Convert.FromBase64String(content);
string source = Encoding.UTF8.GetString(data);
results.Add($"Repository: {item.RepoName}\nfileName: {item.FileName}\nSource: {source}");
}
return results.Count > 0
? string.Join("\n\n", results)
: $"No repositories found for '{query}'";
}
catch (Exception ex)
{
return $"Error searching repositories: {ex.Message}";
}
}
class CodeSearch
{
public string RepoName { get; set; }
public string FileName { get; set; }
public string FileUrl { get; set; }
public string Content { get; set; }
}
}


Хм, неплохо. Нашли ссылку на реализацию MCP сервера работающего с MSSQL. Надо будет посмотреть...
Поиск информации в определенной папке подразумевает:
Поиск всех файлов
Извлечение текста из каждого файла
Преобразование текста в вектора (эмбеддинги)
Преобразование введенного пользователем запроса в вектора (эмбеддинги)
Сравнение, насколько "пользовательские" вектора ближе к "файловым"
Выдача текста, который больше всего соответствует пользовательскому запросу
Пункты 3, 4, и 5 как бы подсказывают нам, что для поиска через вектора нужна еще одна модель генерирующая эмбеддинги из текста. Модель может быть загружена в LM Studio, либо на сервере поддерживающим OpenAI API.
Давайте сразу добавим в mcp.json нужные нам настройки (EMBEDD_ENDPOINT, EMBEDD_MODEL, EMBEDD_KEY) для передачи в нашу будущую процедуру поиска текста:
{
"mcpServers": {
"my-mcp-example": {
"command": "C:\\Users\\user\\Desktop\\mcp-server\\SampleMcpServer\\bin\\Debug\\net8.0\\win-x64\\SampleMcpServer.exe",
"env": {
"WEB_SEARCH_ENGINES": "DuckDuckGo,Firecraw",
"WEB_SEARCH_FirecrawApiKey": "fc-xxxxxxxxxxxxxxxxxxxxxxxxxx",
"WEB_SEARCH_duckduckgoRegion": "ru-ru",
"GUTHUB_TOKEN": "github_pat_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
"EMBEDD_ENDPOINT": "http://localhost:1234/v1/",
"EMBEDD_MODEL": "text-embedding-nomic-embed-text-v2-moe",
"EMBEDD_KEY": ""
}
}
}
}Поскольку быструю модель для поиска я скачал в LM Studio, то в качестве EMBEDD_ENDPOINT укажем наш сервер LM Studio: http://localhost:1234/v1/. EMBEDD_KEY будет пустой строкой т.к. модель у нас локальная.
Скачиваем модель для эмбеддинговЗаходим в поиск моделей, вставляем текст: nomic-ai/nomic-embed-text-v2-moe-GGUF.

Заметили что название модели и то что мы указываем в EMBEDD_MODEL различается? Мы скачали модель nomic-ai/nomic-embed-text-v2-moe-GGUF. Но если зайти в список скачанных моделей, переключиться во вкладку "Text Embedding", то увидим text-embedding-nomic-embed-text-v2-moe что и будем указывать в конфигурации.

Так же не забудем настроить LM Studio в качестве сервера локальных моделей.
Включаем сервер моделей в LM StudioПереключаемся в режим разработки, включаем сервер, и проставляем нужные галки в "Server Settings".

У мелкософта и в этот раз всё схвачено: есть экстракторы текста из pdf, docx, xlsx, pptx. Все они реализуют интерфейс IContentDecoder и метод DecodeAsync. Исходный код всех реализаций можно спокойно посмотреть на гитхабе, например: PdfDecoder.cs.
Но для Word при экстракте текста нет никакой связи между заголовком и текстом к которому он принадлежит. Хотелось бы знать, как например LibreOffice определяет начало и конец текста привязанного к определенному заголовку. Не нашел. Пришлось помучаться ...не без помощи ИИ-режима гугла, Сopilot мелкософта, Qoder и прочих.
Пример word файлаЗаголовки считаются таковыми если помечены стилем текста у которого в названии есть слово "заголовок". Без этого например нельзя создать страницу со ссылками на заголовки.
В таблице, если текст в первой строке выделен жирным шрифтом - эта строка считается заголовочной. Зачем? См. ниже результат.

Реализация от Microsoft:
#pragma warning disable KMEXP00
var word1 = new MsWordDecoder();
var content = await word1.DecodeAsync("C:\\examples\\Документ.docx");
foreach (var section in content.Sections)
{
Console.WriteLine(section.Content);
}Результат - одна секция:
Заголовок1
Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1.
Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1.
Заголовок2
Текст 2. Текст 2. Текст 2. Текст 2. Текст 2. Текст 2. Текст 2. Текст 2. Текст 2.
Колонка1
Колонка2
Колонка3
Текст 2
Текст 3
Текст 4
Заголовок3
Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3.
Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Моя реализация:
var word2 = new MyWordExtractor();
var sections = word2.DecodeAsync("C:\\examples\\Документ.docx");
foreach (var section in sections)
{
Console.WriteLine(section.Title + "\n" + section.Content);
}Выдаст три секции:
Заголовок1
Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1.
Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1. Текст 1.
=================
Заголовок2
Текст 2. Текст 2. Текст 2. Текст 2. Текст 2. Текст 2. Текст 2. Текст 2. Текст 2.
[
{
"Колонка1": "Текст 2",
"Колонка2": "Текст 3",
"Колонка3": "Текст 4"
}
]
=================
Заголовок3
Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3.
Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3. Текст 3.А вот собственно реализация:
MyWordExtractor.csusing DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
public class MyWordExtractor
{
public class Section
{
public string Title { get; set; }
public string Content { get; set; }
public int Page { get; set; }
}
public List<Section> DecodeAsync(string filename)
{
var result = new List<Section>();
StringBuilder currentContent = new StringBuilder();
string currentHeading = "No Heading";
using (WordprocessingDocument wordDocument = WordprocessingDocument.Open(filename, false))
{
var mainPart = wordDocument.MainDocumentPart;
if (mainPart == null || mainPart.Document.Body == null)
{
return result;
}
var styles = GetHeadingStyles(mainPart);
foreach (var element in mainPart.Document.Body.Elements())
{
var level = HeadingLevel(element, styles);
if (level > -1)
{
// Store the content under the previous heading.
if (currentContent.Length > 0 || currentHeading != "No Heading")
{
var page = GetPageNumberApproximation(element);
result.Add(new Section() { Title = currentHeading, Content = currentContent.ToString(), Page = page });
}
// Start a new section with the new heading.
//currentHeading = element.InnerText;
if (level > 1)
currentHeading = currentHeading + ". " + element.InnerText;
else
currentHeading = element.InnerText;
currentContent = new StringBuilder();
}
else
{
// Append content to the current section.
if (element is Paragraph paragraph)
{
/*
if (!IsListItem(paragraph))
{
currentContent.AppendLine(paragraph.InnerText);
}
else
*/
{
// A more sophisticated implementation could handle lists properly.
//currentContent.AppendLine(paragraph.InnerText);
currentContent.AppendLine(ExtractParagraphText(paragraph));
}
}
else if (element is Table table)
{
currentContent.AppendLine(ExtractTableText(table));
}
}
}
}
//последний элемент
if (currentContent.Length > 0)
{
var lastpage = result.Max(x => x.Page) + 1;
result.Add(new Section() { Title = currentHeading, Content = currentContent.ToString(), Page = lastpage });
}
return result;
}
public static int GetPageNumberApproximation(OpenXmlElement element)
{
int pageNumber = 1;
// The root is the document body
var root = element.Ancestors<Body>().FirstOrDefault();
if (root == null)
{
return 1;
}
var tmpElement = element;
while (tmpElement != root)
{
var sibling = tmpElement.PreviousSibling();
while (sibling != null)
{
// Count all page break indicators before the element
pageNumber += sibling.Descendants<LastRenderedPageBreak>().Count();
sibling = sibling.PreviousSibling();
}
tmpElement = tmpElement.Parent;
}
return pageNumber;
}
private string? ExtractParagraphText(Paragraph paragraph)
{
var textBuilder = new StringBuilder();
foreach (var run in paragraph.Elements<Run>())
{
bool inComplexFieldCode = false;
// Проверяем на маркеры поля
var fieldChar = run.Elements<FieldChar>().FirstOrDefault();
if (fieldChar != null)
{
if (fieldChar.FieldCharType?.Value == FieldCharValues.Begin)
{
inComplexFieldCode = true;
}
else if (fieldChar.FieldCharType?.Value == FieldCharValues.Separate)
{
inComplexFieldCode = false;
}
else if (fieldChar.FieldCharType?.Value == FieldCharValues.End)
{
inComplexFieldCode = false;
}
continue;
}
// Проверяем на простой FieldCode
var fieldCodeElement = run.Elements<FieldCode>().FirstOrDefault();
if (fieldCodeElement != null)
{
//textBuilder.Append(fieldCodeElement.InnerText.Trim());
continue;
}
// Проверяем на простое поле SimpleField
var simpleField = run.Elements<SimpleField>().FirstOrDefault();
if (simpleField != null)
{
textBuilder.Append(simpleField.InnerText);
continue;
}
// Проверяем на гиперссылку
var hyperlink = run.Elements<Hyperlink>().FirstOrDefault();
if (hyperlink != null)
{
if (!string.IsNullOrEmpty(hyperlink.InnerText))
{
textBuilder.Append(hyperlink.InnerText);
}
continue;
}
if (!inComplexFieldCode)
{
var runText = run.InnerText;
textBuilder.Append(runText);
}
}
//если были только ссылки - добавляем текст из них
if (textBuilder.ToString().Trim().Length == 0)
{
foreach (var hyperlink in paragraph.Descendants<Hyperlink>())
{
foreach (var text in hyperlink.Descendants<Text>())
{
//paragraphText += " " + text.InnerText;
textBuilder.Append(text.InnerText + " ");
}
}
}
return textBuilder.ToString().Trim();
}
private Dictionary<string, int> GetHeadingStyles(MainDocumentPart mainPart)
{
var headingStyles = new Dictionary<string, int>();
var stylesPart = mainPart.StyleDefinitionsPart;
if (stylesPart != null)
{
foreach (var style in stylesPart.Styles.Elements<Style>())
{
var styleParagraphProperties = style.StyleParagraphProperties;
if (styleParagraphProperties != null)
{
var outlineLevel = styleParagraphProperties.OutlineLevel?.Val?.Value;
if (outlineLevel != null)
{
headingStyles[style.StyleId] = (int)outlineLevel + 1;
}
else
{
// Проверяем BasedOn или Link
var basedOn = style.BasedOn?.Val?.Value;
var link = style.LinkedStyle?.Val?.Value;
if (basedOn != null)
{
// Если BasedOn существует, проверяем уровень в базовом стиле
if (headingStyles.ContainsKey(basedOn))
{
headingStyles[style.StyleId] = headingStyles[basedOn];
}
else
{
//headingStyles[style.StyleId] = 12; // По умолчанию
}
}
else if (link != null)
{
// Если Link существует, проверяем уровень в связанном стиле
if (headingStyles.ContainsKey(link))
{
headingStyles[style.StyleId] = headingStyles[link];
}
else
{
//headingStyles[style.StyleId] = 12; // По умолчанию
}
}
else
{
//headingStyles[style.StyleId] = 12; // По умолчанию
}
}
}
}
}
return headingStyles;
}
private int HeadingLevel(OpenXmlElement element, Dictionary<string, int> styles)
{
if (element is Paragraph paragraph)
{
var styleId = paragraph.ParagraphProperties?.ParagraphStyleId?.Val?.Value;
if (styleId != null && styles.ContainsKey(styleId))
{
return styles[styleId];
}
}
return -1;
}
private string? ExtractTableText(Table table)
{
var tableData = new List<Dictionary<string, string>>();
var rows = table.Elements<TableRow>().ToList();
if (rows.Any())
{
var headerCells = rows.First().Elements<TableCell>().ToList();
bool hasHeader = IsHeaderRow(headerCells);
// Skip header row in data if detected
int startRowIndex = hasHeader ? 1 : 0;
for (int i = startRowIndex; i < rows.Count; i++)
{
var rowData = new Dictionary<string, string>();
var cells = rows[i].Elements<TableCell>().ToList();
// Process cells and match with headers if they exist.
for (int j = 0; j < cells.Count; j++)
{
string headerText = hasHeader && j < headerCells.Count ? headerCells[j].InnerText : $"Column_{j + 1}";
rowData[headerText] = cells[j].InnerText;
}
tableData.Add(rowData);
}
}
var options = new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.Create(new TextEncoderSettings(System.Text.Unicode.UnicodeRanges.All)) };
return System.Text.Json.JsonSerializer.Serialize(tableData, options);
}
private bool IsHeaderRow(IEnumerable<TableCell> cells)
{
// Simple heuristic: A row is a header if all its cells have bold text.
foreach (var cell in cells)
{
var boldRun = cell.Descendants<Bold>().FirstOrDefault();
if (boldRun == null)
{
return false;
}
}
return true;
}
}Не идеально, но лучше чем стандартная реализация. Вот здесь например DocumentAtom помимо текста еще и изображения из документа извлекаются.
Ну и собственно реализация тулзы:
RAGTool.cs#pragma warning disable KMEXP00
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.KernelMemory.DataFormats;
using Microsoft.KernelMemory.DataFormats.Office;
using Microsoft.KernelMemory.DataFormats.Pdf;
using Microsoft.KernelMemory.Pipeline;
using Microsoft.SemanticKernel.Connectors.InMemory;
using Microsoft.SemanticKernel.Data;
using ModelContextProtocol.Server;
using System.ClientModel;
using System.ComponentModel;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
/// <summary>
/// Tools for Retrieval Augmented Generation (RAG) search.
/// </summary>
public partial class RAGTool
{
[McpServerTool]
[Description("Performs a RAG search using local documents.")]
public async Task<string> RagSearch(
[Description("The path of the files for search")] string path,
[Description("The search query")] string query,
[Description("The retrieval limit")] int limit = 3,
[Description("The retrieval affinity threshold")] double threshold = 0.2
)
{
var results = new List<RagResult>();
try
{
var embeddEndpoint = Environment.GetEnvironmentVariable("EMBEDD_ENDPOINT"); //"http://localhost:1234/v1/"
var embeddModel = Environment.GetEnvironmentVariable("EMBEDD_MODEL");
var embeddKey = Environment.GetEnvironmentVariable("EMBEDD_KEY");
if (string.IsNullOrEmpty(embeddEndpoint))
return "EMBEDD_ENDPOINT is empty";
if (string.IsNullOrEmpty(embeddModel))
return "EMBEDD_MODEL is empty";
embeddKey = string.IsNullOrEmpty(embeddKey) ? "embeddKey" : embeddKey;
var aiopt = new OpenAI.OpenAIClientOptions() { Endpoint = new Uri(embeddEndpoint) };
var aicred = new ApiKeyCredential(embeddKey); //не имеет значение. можно задать как опцию --api-key при запуске llama-server
var embeddingGenerator = new OpenAI.OpenAIClient(aicred, aiopt)
.GetEmbeddingClient(embeddModel)
.AsIEmbeddingGenerator();
//var vectorStore = new InMemoryVectorStore(new() { EmbeddingGenerator = embeddingGenerator });
var vectorStore = new FaissVectorStore(embeddingGenerator);
var collection = vectorStore.GetCollection<string, ContextSection>("infos");
await collection.EnsureCollectionExistsAsync().ConfigureAwait(false);
var datas = await ImportDataFromFilesAsync(path);
foreach (var data in datas)
{
//эмбеддинг всего текста
data.Embedding = await embeddingGenerator.GenerateVectorAsync(data.Content);
await collection.UpsertAsync(data);
}
// Ensure collection exists
await collection.EnsureCollectionExistsAsync().ConfigureAwait(false);
// Perform the search
var searchResult = await collection.SearchAsync(query, top: limit).Where(x => x.Score >= threshold).OrderByDescending(x => x.Score).ToListAsync();
foreach (var i in searchResult)
{
results.Add(new RagResult() {FileName = i.Record.FileName, Score = i.Score, Content = i.Record.Content });
}
}
catch (Exception ex)
{
results.Add(new RagResult() { Content = $"Error performing RAG search: {ex.Message}" });
}
var options = new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.Create(new TextEncoderSettings(System.Text.Unicode.UnicodeRanges.All)) };
return System.Text.Json.JsonSerializer.Serialize(results, options);
}
/// <summary>
/// Extract data from files.
/// </summary>
async Task<List<ContextSection>> ImportDataFromFilesAsync(string path)
{
string[] files;
//если указали файл - берем его
if (File.Exists(path))
{
files = [path];
}
//если папка - все файлы внутри
else
{
files = Directory.GetFiles(path, searchPattern: "", searchOption: SearchOption.AllDirectories);
}
List<ContextSection> result = new();
var pdfDecoder = new PdfDecoder();
var msWordDecoder = new MsWordDecoder();
//var msWordDecoder = new MyMsWordDecoder();
var myWordExractor = new MyWordExtractor();
var msPowerPointDecoder = new MsPowerPointDecoder();
var msExcelDecoder = new MsExcelDecoder();
FileContent content;
foreach (var file in files)
{
content = new(MimeTypes.PlainText);
string extension = Path.GetExtension(file).ToLower();
switch (extension)
{
case ".pdf":
content = await pdfDecoder.DecodeAsync(file);
break;
case ".docx":
//content = await msWordDecoder.DecodeAsync(file);
var sections = myWordExractor.DecodeAsync(file);
foreach (var section in sections)
{
if (section.Content.Trim().Length > 0)
content.Sections.Add(new Chunk(section.Title + ". " + section.Content, section.Page, Chunk.Meta(sentencesAreComplete: true)));
}
break;
case ".xlsx":
content = await msExcelDecoder.DecodeAsync(file);
break;
case ".pptx":
content = await msPowerPointDecoder.DecodeAsync(file);
break;
default:
//текстовые файлы (поиск по сигнатуре)
if (FileUtils.IsPlainText(file))
{
var text = File.ReadAllText(file);
content.Sections.Add(new Chunk(file + ". " + text, 1, Chunk.Meta(sentencesAreComplete: true)));
}
break;
}
foreach (Chunk section in content.Sections)
{
var fileSection = new ContextSection() { FileName = file, Content = section.Content.Replace("\n", ". ") };
result.Add(fileSection);
}
}
return result;
}
/// <summary>
/// ContextSection
/// </summary>
class ContextSection
{
[JsonIgnore(Condition = JsonIgnoreCondition.Always)]
[VectorStoreKey]
[TextSearchResultName]
public string GUID { get; set; } = Guid.NewGuid().ToString();
[VectorStoreData]
[TextSearchResultValue]
public string? Content { get; init; }
public string? FileName { get; init; }
[JsonIgnore]
[VectorStoreVector(14000)]
public ReadOnlyMemory<float> Embedding { get; set; }
}
public class RagResult
{
public string Content { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public double? Score { get; set; }
}
}
Вкратце: создаем генератор эмбеддингов embeddingGenerator, создаем векторное хранилище в памяти InMemoryVectorStore, создаем коллекцию infos, извлекаем текстовую информацию из файлов в папке в методе ImportDataFromFilesAsync, вставляем текст в коллекцию infos, и с помощью метода collection.SearchAsync находим наиболее близкий к пользовательскому запросу текст из файлов.
Класс ContextSection щедро усыпан атрибутами (VectorStoreKey, VectorStoreData, VectorStoreVector и прочими) для возможности поиска. Без этих атрибутов поиск будет невозможен.
В исходном коде видно, что InMemoryVectorStore был закомментирован, и используется некий FaissVectorStore.
В поисках улучшения качества формирования эмбеддингов, ИИ-режиму гугля был задан вопрос: можно ли вкрячить в MCP-сервер целую векторную базу данных? Можно было добавить Qdrant, но он запускался бы как отдельный сервис внутри нашего сервера. А нам нужно что-то полегче.
Гугл вскользь предложил использовать наибыстрейшую (в качестве поиска) библиотеку FAISS (Facebook AI Similarity Search). Ок. Попробовал сделать аналог InMemoryVectorStore, выложил Gist. Вроде получилось.
Ну а теперь проверим как это работает на самом деле.
Исходный файл. Это не моё. ИИ-режим гугла нашел на просторах интернета.

Спросим: какие виды полиморфизма поддерживает С++, ищи в "C:\...\geom.docx"
Можно посмотреть как создаются эмбеддинги в логах LM Studio:

Результат:

Можно создать метод DocumentationSearch (аналог RagSearch), и пробрасывать через mcp.json в новой переменной список папок с кучей pdf, в которых ИИ модель может искать какую-то документацию.
Вот такой получился, достаточно бодрый (для текущей конфигурации ПК) MCP-сервер. Конечно не надо пихать в один сервер весь функционал какой только придет в голову. Необходимо создавать каждый mcp под определенный функционал.
Теперь не нужно платить кому-то дензнаки за "потраченные" токены. Функционал для своей локальной модели можно наращивать почти бесконечно. Можно так же придумать новую тулзу которая будет запускать внутри себя инференс многократно, для выдачи более точного результата. Что-то типа агентной системы, где каждый агент будет выполнять свою задачу: собирать информацию, вытаскивать нужные данные, перепроверять факты, суммаризировать и т.д. Притом агенты могут быть физически той же самой моделью, которая будет использовать новую тулзу. Просто у каждого агента - свой системный промпт.
Проект и релиз можно забрать здесь: https://github.com/virex-84/SampleMcpServer
А на сегодня всё.