Building an MCP Server
Key Points
- Build a .NET app that exposes tools/resources/prompts via MCP. Consumed by Claude, ChatGPT, your agents, etc.
- Annotation-based: decorate methods; SDK auto-registers.
- Transport: stdio for local; HTTP for remote.
- Senior practices: validate inputs; auth; rate limit; logging; sandbox.
Hello server
using ModelContextProtocol.Server;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
[McpServerToolType]
public class TimeTools
{
[McpServerTool, Description("Get current UTC time as ISO 8601")]
public string GetUtcTime() => DateTime.UtcNow.ToString("O");
[McpServerTool, Description("Convert UTC time to a target timezone")]
public string ConvertTime(
[Description("ISO 8601 UTC time")] string utc,
[Description("Timezone like 'America/Los_Angeles'")] string timeZone)
{
var dt = DateTime.Parse(utc, null, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
return TimeZoneInfo.ConvertTimeFromUtc(dt, tz).ToString("O");
}
}
Run: dotnet run. Configure Claude Desktop / Cursor / your agent to connect.
Resources
[McpServerResourceType]
public class FileResources(string rootPath)
{
[McpServerResource(UriTemplate = "file://{path}")]
public async Task<ResourceContents> ReadFile(string path, CancellationToken ct)
{
var fullPath = Path.GetFullPath(Path.Combine(rootPath, path));
if (!fullPath.StartsWith(rootPath)) throw new UnauthorizedAccessException();
var content = await File.ReadAllTextAsync(fullPath, ct);
return new ResourceContents { Text = content, MimeType = "text/plain" };
}
}
Path traversal guard critical.
Prompts
[McpServerPromptType]
public class CodeReviewPrompts
{
[McpServerPrompt, Description("Code review prompt template")]
public IList<ChatMessage> Review(
[Description("Code language")] string language,
[Description("Code to review")] string code)
{
return [
new(ChatRole.System, $"You are a senior {language} reviewer."),
new(ChatRole.User, $"Review:\n```{language}\n{code}\n```")
];
}
}
DI in tools
Tools can depend on services:
public class GitHubTools(IGitHubService gh)
{
[McpServerTool, Description("List PRs")]
public async Task<List<PullRequest>> ListPrs(string repo, CancellationToken ct)
=> await gh.ListPrsAsync(repo, ct);
}
builder.Services.AddSingleton<IGitHubService, GitHubService>();
builder.Services.AddMcpServer().WithToolsFromAssembly();
DI scope per request.
HTTP/SSE transport
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
var app = builder.Build();
app.MapMcpEndpoints(); // /mcp/sse, /mcp/message
app.Run();
For remote/multi-user servers.
Authentication
Standard ASP.NET Core auth applies.
Error handling
[McpServerTool]
public async Task<User> GetUser(int id)
{
var u = await _service.GetAsync(id);
if (u is null) throw new McpException($"User {id} not found", McpErrorCode.InvalidRequest);
return u;
}
McpException returns proper JSON-RPC error codes.
Rate limiting
builder.Services.AddRateLimiter(o =>
{
o.AddFixedWindowLimiter("mcp", c => { c.PermitLimit = 100; c.Window = TimeSpan.FromMinutes(1); });
});
app.UseRateLimiter();
app.MapMcpEndpoints().RequireRateLimiting("mcp");
Logging
OTel emits spans per tool call. Track usage.
Testing
[Fact]
public async Task GetTime_returns_iso8601()
{
var server = new TestMcpServer().AddToolsFromType<TimeTools>();
var client = server.CreateClient();
var result = await client.CallToolAsync("GetUtcTime", new());
Assert.Single(result.Content);
}
Common server use cases
- Internal data API as MCP: customers' AI agents can query.
- Coding tools: read code, run linter, run tests.
- Workflow controllers: pause / resume / approve.
- Domain APIs: any business function exposed to agents.
Security checklist
- ✅ Input validation per tool.
- ✅ Path / URL allowlists.
- ✅ Auth on HTTP transport.
- ✅ Rate limiting.
- ✅ No secrets in tool descriptions.
- ✅ Sandbox file ops.
- ✅ Audit log every tool call.
Senior considerations
- Tool descriptions are LLM-facing prompts — clear, specific, no ambiguity.
- Idempotent ops when possible.
- Return structured data (JSON) — easier for LLM to use.
- Test with real LLMs — descriptions that read well to humans may confuse LLMs.