C# ile MCP sunucusu: Veritabanınızı yapay zekâya güvenle açmak
Model Context Protocol nedir, C# ile stdio sunucusu nasıl yazılır, salt okunur rol ile yetki nasıl sınırlandırılır? Serinin ikinci yazısı.
Şirket içi yapay zekâ asistanı serisi
- PostgreSQL ve pgvector ile RAG
- C# ile MCP sunucusu (bu yazı)
- Eval ile yapay zekâyı test etmek
İlk yazıda dokümanlarımızı arayabilen bir asistan kurduk. Ama yalnızca kendi uygulamamızın içinde çalışıyor. Aynı aramayı başka bir yapay zekâ aracında da kullanmak istersek her biri için ayrı entegrasyon mu yazacağız?
MCP (Model Context Protocol) tam bu iş için var. Araçları yapay zekâ uygulamalarına sunmanın ortak yolu gibi düşünebilirsiniz: Sunucuyu bir kez yazarsınız, MCP destekleyen istemciler onu kullanabilir.
MCP sunucusu ne sunar?
Bu yazıda en çok kullanılan parçaya, yani araçlara (tools) odaklanıyoruz. Araç, modelin çağırabildiği bir fonksiyondur. Model adını, açıklamasını ve parametrelerini görür; gerektiğinde çağırmaya kendisi karar verir.
Protokol, sunucuyla istemci arasında iki ana yol sunar:
- stdio: İstemci sunucuyu alt süreç olarak başlatır. Yerel kullanım için en basit yol.
- HTTP: Sunucu ağda çalışır. Birden fazla kullanıcı varsa bu gerekir, ama kimlik doğrulama da sizin sorumluluğunuza girer.
Biz stdio ile başlıyoruz.
Proje
dotnet new console -n Asistan.Mcp
dotnet add Asistan.Mcp reference Asistan.Core
dotnet add Asistan.Mcp package ModelContextProtocol
dotnet add Asistan.Mcp package Microsoft.Extensions.Hosting
Örnek, resmî C# SDK'sının 2.3.0 sürümüyle derlendi.
using Asistan.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
// stdout MCP protokolüne ayrılmıştır; loglar stderr'e gitmeli.
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddSingleton(_ => Kurulum.VeriKaynagi(Kurulum.Baglanti));
builder.Services.AddSingleton(_ => Kurulum.Model());
builder.Services.AddSingleton<DokumanDeposu>();
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
Yorum satırındaki kural en sık yapılan hatadır: stdio kullanırken stdout'a yazılan her şey protokolün parçası sayılır. Araya giren tek bir Console.WriteLine bağlantıyı bozabilir.
Araçlar
Kodun neredeyse tamamı ilk yazıdan geliyor. Burada yalnızca modele açıyoruz:
using System.ComponentModel;
using System.Text;
using Asistan.Core;
using ModelContextProtocol.Server;
using Npgsql;
namespace Asistan.Mcp;
[McpServerToolType]
public sealed class DokumanAraclari(ModelIstemcisi model, DokumanDeposu depo, NpgsqlDataSource db)
{
[McpServerTool(Name = "dokuman_ara", ReadOnly = true)]
[Description("Şirket içi dokümanlarda arama yapar. Politika, süreç ve kural soruları için kullan.")]
public async Task<string> DokumanAra(
[Description("Aranacak soru veya konu")] string soru,
[Description("Dönecek parça sayısı (1-10)")] int adet = 5,
CancellationToken ct = default)
{
adet = Math.Clamp(adet, 1, 10);
var vektor = (await model.EmbedAsync([soru], ct))[0];
var sonuc = await depo.AraAsync(soru, vektor, adet, ct);
if (sonuc.Count == 0) return "Eşleşen doküman bulunamadı.";
var yazi = new StringBuilder();
foreach (var b in sonuc) yazi.AppendLine($"[{b.Kaynak}]\n{b.Icerik}\n");
return yazi.ToString();
}
[McpServerTool(Name = "dokuman_listesi", ReadOnly = true)]
[Description("Arama yapılabilen dokümanları ve parça sayılarını listeler.")]
public async Task<string> DokumanListesi(CancellationToken ct = default)
{
await using var con = await db.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(
"SELECT kaynak, count(*) FROM dokuman_parca GROUP BY kaynak ORDER BY kaynak", con);
var yazi = new StringBuilder();
await using var okuyucu = await cmd.ExecuteReaderAsync(ct);
while (await okuyucu.ReadAsync(ct))
yazi.AppendLine($"{okuyucu.GetString(0)} ({okuyucu.GetInt64(1)} parça)");
return yazi.ToString();
}
}
Description metinleri süs değildir. Model aracı ne zaman kullanacağına büyük ölçüde bunlara bakarak karar verir. "Arama yapar" yerine ne zaman kullanılacağını da yazın.
ReadOnly = true işareti istemciye aracın veri değiştirmediğini söyler. Ama bu yalnızca bir bildirimdir, kimseyi durdurmaz. Asıl koruma veritabanında olmalı.
Asıl güvenlik: salt okunur rol
Modelin ne yapacağını kodla garanti edemezsiniz, ama neyi yapamayacağını garanti edebilirsiniz. MCP sunucusu için ayrı bir rol açın:
CREATE ROLE asistan_okuyucu LOGIN PASSWORD 'guclu-bir-sifre';
GRANT USAGE ON SCHEMA public TO asistan_okuyucu;
GRANT SELECT ON dokuman_parca TO asistan_okuyucu;
ALTER ROLE asistan_okuyucu SET default_transaction_read_only = on;
Test ortamında bu yetkilerle denedik: SELECT çalıştı; DELETE ve INSERT "permission denied", DROP TABLE ise "must be owner of table" hatasıyla reddedildi. Bir doküman modeli kandırsa bile rolün yetkisini aşamaz.
Bir MCP sunucusu yazarken şu sınırlar da işe yarar:
- Serbest SQL aracı açmayın.
sql_calistiryerine dar ve amacı belli araçlar yazın. - Girdileri sınırlayın. Yukarıdaki
Math.Clampküçük ama gerekli bir önlem; modeladet = 100000da gönderebilir. - Araç çıktısına güvenmeyin. Arama sonucu doküman metnidir ve içinde talimat gibi görünen ifadeler olabilir. İstemci tarafında da veri olarak ele alınmalıdır.
- Sırları koda yazmayın. Bağlantı bilgisi ortam değişkeninden gelmeli.
Sınamak
Bir modele bağlamadan önce küçük bir istemciyle sınamak hata ayıklamayı çok kolaylaştırır:
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "asistan",
Command = "dotnet",
Arguments = ["run", "--no-build", "--project", args[0]],
});
await using var istemci = await McpClient.CreateAsync(transport);
foreach (var arac in await istemci.ListToolsAsync())
Console.WriteLine($"{arac.Name}: {arac.Description}");
var sonuc = await istemci.CallToolAsync("dokuman_ara",
new Dictionary<string, object?> { ["soru"] = "İade süresi kaç gün?", ["adet"] = 2 });
foreach (var blok in sonuc.Content.OfType<TextContentBlock>())
Console.WriteLine(blok.Text);
var liste = await istemci.CallToolAsync("dokuman_listesi");
foreach (var blok in liste.Content.OfType<TextContentBlock>())
Console.WriteLine(blok.Text);
Test ortamında sunucu iki aracı listeledi, dokuman_ara iade dokümanını ilk sırada getirdi, dokuman_listesi de üç dokümanı parça sayılarıyla gösterdi.
Bir istemciye bağlamak
MCP destekleyen istemcilerin çoğu benzer bir JSON yapılandırması kullanır. Örneğin Claude Code, proje kökündeki .mcp.json dosyasını okur:
{
"mcpServers": {
"asistan": {
"command": "dotnet",
"args": ["run", "--project", "C:/projeler/Asistan.Mcp"],
"env": {
"ASISTAN_DB": "Host=localhost;Database=asistan;Username=asistan_okuyucu;Password=guclu-bir-sifre",
"MODEL_API": "http://localhost:11434/v1/"
}
}
}
}
Şifre içeren bu dosyayı Git'e eklemeyin. Gerçek kullanımda değerleri ortam değişkeninden okutmak daha doğru olur.
Bu yazıdaki kodu .NET 10, pgvector 0.8 ve PostgreSQL 18 ile derleyip çalıştırdık. Veritabanı olarak pgvector eklentili PGlite, model yerine ise OpenAI uyumlu sahte bir test sunucusu kullandık. Yani kodun akışı sınandı; gerçek bir modelin Türkçe cevap kalitesi sınanmadı.
Sıradaki yazı: Peki asistanın doğru çalıştığını nereden bileceğiz? Eval ile yapay zekâyı test ediyoruz.
Kaynaklar: Model Context Protocol · MCP C# SDK · Claude Code MCP belgeleri · Kapak fotoğrafı: Inisheer, CC BY-SA 3.0, Wikimedia Commons
Yorumlar (0)