Writing MCP Tools in C# for Claude Desktop/Code
A reference documentation for building MCP (Model Context Protocol) tool servers in C# 7.3, .NET Framework 4.8, as a Console Application.
The content is divided into two sections:
- External Protocol Logic – How Claude Desktop communicates with the MCP server
- Internal Engineering Logic – A proposed code structure for organizing the server internals
Section 1: External Protocol Logic
This section explains the communication protocol between Claude Desktop (or Claude Code) and your MCP server. This is the part you must implement correctly. It is not negotiable. The protocol must be followed exactly.
1.1 Transport Mechanism: stdio
Claude Desktop launches your MCP server as a child process. Communication happens over stdin and stdout using line-delimited JSON.
Claude Desktop Your MCP Server (.exe)
| |
| ---- launches process (stdio) ---- |
| |
| --- JSON line via stdout ----------> | (stdin: Console.ReadLine)
| <-- JSON line via stdin ----------- | (stdout: Console.WriteLine)
| |
| --- JSON line via stdout ----------> |
| <-- JSON line via stdin ----------- |
| |
| ---- EOF (closes stdin) ----------> | (server exits)
| |
Critical rules:
- Each message is one line of JSON followed by a newline
- Use
Console.ReadLine()to read requests - Use
Console.WriteLine()to write responses - Always call
Console.Out.Flush()after writing - Never write anything else to stdout (debug output goes to stderr via
Console.Error) - Set
Console.InputEncodingandConsole.OutputEncodingtoEncoding.UTF8
1.2 Protocol: JSON-RPC 2.0
Every message follows the JSON-RPC 2.0 specification.
Request format (sent by Claude Desktop to your server via stdin):
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { ... }
}
Success response format (sent by your server to Claude Desktop via stdout):
{
"jsonrpc": "2.0",
"id": 1,
"result": { ... }
}
Error response format:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32602,
"message": "Invalid params",
"data": "Optional additional detail"
}
}
Standard JSON-RPC error codes:
| Code | Name | Meaning |
|---|---|---|
| -32700 | Parse Error | Invalid JSON received |
| -32600 | Invalid Request | JSON is valid but not a proper request |
| -32601 | Method Not Found | The requested method does not exist |
| -32602 | Invalid Params | Invalid method parameters |
| -32603 | Internal Error | Server-side error |
1.3 Connection Lifecycle
The connection follows a strict sequence. The client (Claude Desktop) always initiates. The server always responds.
Step 1: Initialize
Client sends initialize with its capabilities. Server responds with its own capabilities.
Client sends:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "claude-ai", "version": "0.1.0" }
}
}
Server responds:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "my-mcp-server",
"version": "1.0.0"
}
}
}
The "tools": {} inside capabilities tells the client that this server supports tools. The empty object is intentional. It just signals capability support.
Step 2: Initialized Notification
Client sends a notification confirming initialization is complete.
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
This is a notification (no id field). Do not send a response. Just acknowledge internally and continue.
Step 3: Tool Listing
Client requests the list of available tools.
Client sends:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}
Server responds with tool definitions:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "read_file",
"description": "Read the complete raw content of a file.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Full path to the file"
}
},
"required": ["path"]
}
},
{
"name": "write_file",
"description": "Write content to a file.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Full path to the file"
},
"content": {
"type": "string",
"description": "Content to write"
}
},
"required": ["path", "content"]
}
}
]
}
}
Each tool definition has:
name– Unique identifier (use kebab_case or snake_case)description– Human-readable explanation of what the tool does. This is critically important. Claude reads this description to decide when and how to use the tool. Write it clearly. Include parameter explanations in the description if the parameter names alone are not self-explanatory.inputSchema– A JSON Schema object describing the parameters. Thetypeat the top level must always be"object". Individual properties can bestring,integer,boolean,array, orobject. Therequiredarray lists parameter names that must be provided. If no parameters are required, omit therequiredfield entirely or set it tonull.
Supported property types in inputSchema:
| Type | JSON Schema type | Example |
|---|---|---|
| Text | "string" | File paths, content, names |
| Whole numbers | "integer" | Line numbers, counts |
| True/false | "boolean" | Flags, toggles |
| List of values | "array" | File lists, batch operations |
| Nested object | "object" | Complex parameters |
| Restricted values | "string" + "enum" | Mode selectors, format choices |
Optional properties can include "default" to indicate the default value when omitted.
Step 4: Tool Execution
Client calls a specific tool with arguments.
Client sends:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "read_file",
"arguments": {
"path": "C:\\projects\\myapp\\Program.cs"
}
}
}
Server responds (success):
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "using System;\n\nnamespace MyApp\n{\n class Program\n {\n ...\n }\n}"
}
]
}
}
Server responds (error in tool execution):
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "Error: File not found: C:\\projects\\myapp\\Program.cs"
}
],
"isError": true
}
}
Important distinction: A tool execution error is NOT a JSON-RPC error. The JSON-RPC layer succeeded (the request was valid, the tool was found, the parameters were parsed). The error happened inside the tool’s logic. So it comes back as a result with isError: true, not as an error response.
Use JSON-RPC error only for protocol-level failures (bad JSON, unknown method, missing tool name).
Content types in tool results:
The content array can contain multiple items. Each item has a type field.
| Type | Fields | Use case |
|---|---|---|
"text" | text | Text content, file contents, messages |
"image" | data, mimeType | Base64-encoded image data |
Example of returning an image:
{
"content": [
{
"type": "text",
"text": "Screenshot captured successfully"
},
{
"type": "image",
"data": "/9j/4AAQSkZJRg...",
"mimeType": "image/png"
}
]
}
Step 5: Ping
Client may send ping requests at any time to check if the server is alive.
{ "jsonrpc": "2.0", "id": 99, "method": "ping" }
Respond with an empty result:
{ "jsonrpc": "2.0", "id": 99, "result": {} }
Step 6: Notifications
The client may send notifications that do not require a response. They have no id field.
Known notifications:
notifications/initialized– Client confirms initialization completenotifications/cancelled– Client cancelled a pending request
Do not send a response to notifications. Just handle them internally or ignore them.
Step 7: Shutdown
When Claude Desktop closes the connection, it stops sending data. Console.ReadLine() returns null (EOF). The server should exit gracefully.
1.4 Claude Desktop and Claude Code Configuration
Users register your MCP server in a configuration JSON file. The location depends on which client is being used.
Claude Desktop (standard installer):
%APPDATA%\Claude\claude_desktop_config.json
Claude Desktop (MSIX / Microsoft Store installer):
%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json
Note: The MSIX version runs inside a virtualized filesystem. The “Edit Config” button in Claude Desktop may open the wrong file location. If MCP servers are not loading, verify you are editing the file at the MSIX path above, not the standard %APPDATA% path.
Claude Code (CLI):
C:\Users\{username}\.claude.json
Example configuration (Claude Desktop):
{
"mcpServers": {
"your_mcp_name": {
"command": "C:\\mcp\\folder\\for_claude_desktop\\MyMcpServer.exe",
"args": []
}
}
}
Example configuration (Claude Code .claude.json):
The .claude.json file contains many other settings. Add the mcpServers block at the top level alongside the existing keys:
{
"mcpServers": {
"your_mcp_name": {
"command": "C:\\mcp\\folder\\for_claude_code\\MyMcpServer.exe",
"args": []
}
}
}
The command field is the full path to your compiled executable. The args array can pass command-line arguments.
The key name ("your_mcp_name" in this example) becomes the prefix that Claude uses to identify tools from this server. If you have a tool named read_file, Claude sees it as your_mcp_name:read_file.
1.5 Complete Lifecycle Summary
1. Claude Desktop launches your .exe
2. Client sends: "initialize" -> Server responds with capabilities
3. Client sends: "notifications/initialized" -> No response (notification)
4. Client sends: "tools/list" -> Server responds with tool definitions
5. Client sends: "tools/call" -> Server executes tool, responds with result
6. (steps 4-5 repeat as needed)
7. Client sends: "ping" -> Server responds with {}
8. Client closes stdin (EOF) -> Server exits
1.6 Protocol Versioning and Compatibility
The MCP specification has gone through several versions:
| Version | Date | Status |
|---|---|---|
| 2024-11-05 | November 2024 | Legacy (initial stable release) |
| 2025-06-18 | June 2025 | Stable |
| 2025-11-25 | November 2025 | Latest Stable |
The protocol version is exchanged during the initialize handshake. Your server declares which version it implements via the protocolVersion field.
What changed between versions:
The 2025-06-18 version added structured tool output, OAuth authorization, elicitation support, and removed JSON-RPC batching.
The 2025-11-25 version added experimental Tasks support (async long-running operations), tool annotations (hints like readOnlyHint, destructiveHint), icons metadata for tools, title field on tool definitions, outputSchema for structured results, and sampling tool calling support.
What this means for your stdio tool server:
For local stdio-based tool servers (the kind this document describes), the core tool protocol has remained stable across all three versions. The tools/list and tools/call message formats, the inputSchema JSON Schema structure, the content array response format with text and image types, and the isError flag have not changed.
The newer features (Tasks, OAuth, Streamable HTTP transport, tool annotations, outputSchema) are additive and optional. A server that implements the basic tool pattern described in this document works correctly with all three specification versions. Claude Desktop and Claude Code will negotiate the protocol version during initialization and use only the features the server declares support for.
Practical recommendation:
Use protocolVersion: "2025-11-25" in your initialize response. This is the latest stable version. Do not use the newer optional fields (title, outputSchema, toolAnnotations) unless you have verified that your target client supports them. As of early 2026, some Claude Code versions silently drop tools that include unrecognized fields like outputSchema or toolAnnotations. If in doubt, keep tool definitions minimal: name, description, and inputSchema only.
Official documentation links:
- MCP Specification (latest): https://modelcontextprotocol.io/specification/2025-11-25
- MCP Tools specification: https://modelcontextprotocol.io/specification/2025-11-25/server/tools
- MCP Transports (stdio): https://modelcontextprotocol.io/specification/2025-11-25/basic/transports
- MCP GitHub repository: https://github.com/modelcontextprotocol/modelcontextprotocol
- Claude Desktop MCP quickstart: https://modelcontextprotocol.io/quickstart/user
- MCP Blog (announcements): https://blog.modelcontextprotocol.io
Section 2: Internal Engineering Logic
This section proposes an internal code structure for organizing your MCP server. There are many valid ways to structure the code. This is not the only way, and not necessarily the best for every situation. It is one approach that has been tested in production with 80+ tools and works well at that scale.
The reasons behind each design choice are explained below.
2.1 Project Structure Overview
MyMcpServer/
├── Program.cs <- Entry point
├── McpServer.cs <- Protocol handler (stdin/stdout loop)
├── mcp-config.json <- Runtime configuration
│
├── Core/ <- Framework infrastructure
│ ├── ITool.cs <- Tool interface + parameter builder
│ ├── ToolDiscovery.cs <- Reflection-based tool registration
│ ├── ToolResult.cs <- Standardized result factory
│ ├── JsonRpcModels.cs <- JSON-RPC request/response models
│ ├── McpModels.cs <- MCP-specific models
│ └── McpConfig.cs <- Configuration loader
│
├── Services/ <- Shared business logic
│ ├── FileOperations.cs <- File read/write/edit operations
│ ├── PathValidator.cs <- Security: path access control
│ └── (other services...)
│
└── Tools/ <- Individual tool implementations
├── File/
│ ├── ReadFile.cs
│ ├── WriteFile.cs
│ └── ReadFileLines.cs
├── Directory/
│ ├── ListDirectory.cs
│ └── CreateDirectory.cs
└── (more tool categories...)
Why this structure:
Core/contains the framework. You write this once and rarely change it. It handles the protocol, tool discovery, result formatting, and configuration.Services/contains shared logic that multiple tools use. For example,FileOperationshandles encoding detection, binary file checking, line numbering, and other low-level file work. Tools call into services rather than duplicating this logic.Tools/contains individual tool classes. Each tool is a single file, a single class. To add a new tool, you create one file. No registration code, no configuration changes. The framework discovers it automatically via reflection.
2.2 Entry Point: Program.cs
using System;
using System.Text;
namespace MyMcpServer
{
class Program
{
static void Main(string[] args)
{
try
{
Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;
McpConfig.Load();
var server = new McpServer();
server.Start();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Fatal error: {ex.Message}");
Console.Error.WriteLine(ex.StackTrace);
Environment.Exit(1);
}
}
}
}
Why this design:
- UTF-8 encoding is set before anything else. Without this, file paths and content with non-ASCII characters will corrupt.
- Configuration loads before the server starts. If configuration is invalid, the process exits immediately with a clear error.
- The entire Main method is wrapped in try/catch. Fatal errors go to stderr (which Claude Desktop captures for diagnostics) and the process exits with code 1.
2.3 Protocol Handler: McpServer.cs
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
namespace MyMcpServer
{
public class McpServer
{
private Dictionary<string, ITool> _tools;
private bool _initialized = false;
public McpServer()
{
_tools = ToolDiscovery.DiscoverTools();
}
public void Start()
{
while (true)
{
try
{
string line = Console.ReadLine();
if (line == null)
break; // EOF, exit gracefully
if (string.IsNullOrWhiteSpace(line))
continue;
var request = JsonConvert.DeserializeObject<JsonRpcRequest>(line);
HandleRequest(request);
}
catch (JsonException ex)
{
SendError(null, -32700, "Parse error", ex.Message);
}
catch (Exception ex)
{
SendError(null, -32603, "Internal error", ex.ToString());
}
}
}
private void HandleRequest(JsonRpcRequest request)
{
if (request == null || string.IsNullOrEmpty(request.Method))
{
SendError(request?.Id, -32600, "Invalid request", null);
return;
}
switch (request.Method)
{
case "initialize":
HandleInitialize(request);
break;
case "notifications/initialized":
case "notifications/cancelled":
// Notifications require no response
break;
case "tools/list":
HandleToolsList(request);
break;
case "tools/call":
HandleToolCall(request);
break;
case "ping":
SendResponse(request.Id, new { });
break;
default:
SendError(request.Id, -32601,
$"Method not found: {request.Method}", null);
break;
}
}
private void HandleInitialize(JsonRpcRequest request)
{
_initialized = true;
SendResponse(request.Id, new
{
protocolVersion = "2025-11-25",
capabilities = new { tools = new { } },
serverInfo = new { name = "my-mcp-server", version = "1.0.0" }
});
}
private void HandleToolsList(JsonRpcRequest request)
{
var definitions = ToolDiscovery.GenerateToolDefinitions(_tools);
SendResponse(request.Id, new { tools = definitions });
}
private void HandleToolCall(JsonRpcRequest request)
{
// Parse the tool name from params
string toolName = request.Params?.Value<string>("name");
if (string.IsNullOrEmpty(toolName))
{
SendError(request.Id, -32602, "Tool name is required", null);
return;
}
if (!_tools.TryGetValue(toolName, out ITool tool))
{
SendError(request.Id, -32602, $"Unknown tool: {toolName}", null);
return;
}
// Extract arguments as JObject to preserve types
JObject args = request.Params["arguments"] as JObject ?? new JObject();
// Execute tool with error safety
ToolResult result;
try
{
result = tool.Execute(args);
}
catch (Exception ex)
{
result = ToolResult.Error(ex.Message);
}
// Convert to MCP response format
SendResponse(request.Id, new
{
content = result.ContentList.Select(c => new
{
type = c.Type,
text = c.Type == "text" ? c.Text : null,
data = c.Type == "image" ? c.Data : null,
mimeType = c.Type == "image" ? c.MimeType : null
}).ToList(),
isError = result.IsError ? (bool?)true : null
});
}
private void SendResponse(object id, object result)
{
var response = new { jsonrpc = "2.0", id, result };
Console.WriteLine(JsonConvert.SerializeObject(response));
Console.Out.Flush();
}
private void SendError(object id, int code, string message, object data)
{
var response = new
{
jsonrpc = "2.0",
id,
error = new { code, message, data }
};
Console.WriteLine(JsonConvert.SerializeObject(response));
Console.Out.Flush();
}
}
}
Why this design:
- The
while (true)loop withConsole.ReadLine()is the simplest and most reliable way to handle the stdio transport. When the client closes stdin,ReadLinereturns null and the loop exits. - The
HandleRequestmethod is a simple switch on the method name. Each method has its own handler. This keeps the routing logic flat and readable. JObject args = request.Params["arguments"] as JObjectis important. If you deserialize arguments intoDictionary<string, object>, Newtonsoft.Json converts integers tolong, which then fails when tools try to read them asint?. Keeping them asJObjectpreserves the original types.- Tool execution errors are caught and converted to
ToolResult.Error(). This prevents a single tool from crashing the entire server. Console.Out.Flush()must be called after every write. Without it, the output may be buffered and the client will never receive the response.
2.4 JSON-RPC Models: JsonRpcModels.cs
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace MyMcpServer
{
public class JsonRpcRequest
{
[JsonProperty("jsonrpc")]
public string JsonRpc { get; set; } = "2.0";
[JsonProperty("id")]
public object Id { get; set; }
[JsonProperty("method")]
public string Method { get; set; }
[JsonProperty("params")]
public JObject Params { get; set; }
}
}
Why Params is JObject:
Using JObject instead of a strongly-typed class allows you to handle all MCP methods with one request model. The initialize method sends different params than tools/call. With JObject, you parse what you need in each handler without creating separate request classes for every method.
Why Id is object:
The JSON-RPC spec allows id to be a string, number, or null. Using object handles all cases. The same value is echoed back in the response.
2.5 Tool Interface: ITool.cs
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
namespace MyMcpServer
{
public interface ITool
{
string Name { get; }
string Description { get; }
ToolParamList Params { get; }
ToolResult Execute(JObject args);
}
public class ToolParamList
{
private readonly List<ToolParam> _params = new List<ToolParam>();
public List<ToolParam> Parameters => _params;
public ToolParamList String(string name, string description,
bool required = false, string defaultValue = null)
{
_params.Add(new ToolParam
{
Name = name,
Type = "string",
Description = description,
Required = required,
DefaultValue = defaultValue
});
return this;
}
public ToolParamList StringEnum(string name, string description,
string[] enumValues, bool required = false, string defaultValue = null)
{
_params.Add(new ToolParam
{
Name = name,
Type = "string",
Description = description,
Required = required,
DefaultValue = defaultValue,
EnumValues = enumValues
});
return this;
}
public ToolParamList Int(string name, string description,
bool required = false, int? defaultValue = null)
{
_params.Add(new ToolParam
{
Name = name,
Type = "integer",
Description = description,
Required = required,
DefaultValue = defaultValue?.ToString()
});
return this;
}
public ToolParamList Bool(string name, string description,
bool required = false, bool? defaultValue = null)
{
_params.Add(new ToolParam
{
Name = name,
Type = "boolean",
Description = description,
Required = required,
DefaultValue = defaultValue?.ToString().ToLower()
});
return this;
}
public ToolParamList Array(string name, string description,
bool required = false)
{
_params.Add(new ToolParam
{
Name = name,
Type = "array",
Description = description,
Required = required
});
return this;
}
}
public class ToolParam
{
public string Name { get; set; }
public string Type { get; set; }
public string Description { get; set; }
public bool Required { get; set; }
public string DefaultValue { get; set; }
public string[] EnumValues { get; set; }
}
}
Why a fluent builder:
The ToolParamList uses a fluent API (method chaining) so that parameter definitions read cleanly inside each tool class:
public ToolParamList Params => new ToolParamList()
.String("path", "Full path to the file", required: true)
.Bool("recursive", "Include subdirectories", defaultValue: false)
.StringEnum("format", "Output format", new[] { "text", "json" });
This is more readable than building dictionaries or JSON Schema objects manually. The ToolDiscovery class converts these definitions into proper JSON Schema when the client requests tools/list.
Why Execute takes JObject:
Each tool receives arguments as a JObject. This gives tools direct access to read parameters with type safety:
string path = args.Value<string>("path");
int? startLine = args.Value<int?>("start_line");
bool recursive = args.Value<bool?>("recursive") ?? false;
Using JObject.Value<T>() returns null for missing keys (when using nullable types) and preserves the original JSON types.
2.6 Tool Result: ToolResult.cs
using System.Collections.Generic;
namespace MyMcpServer
{
public class ToolResult
{
public bool IsError { get; set; }
public List<ToolContent> ContentList { get; set; }
private ToolResult() { }
public static ToolResult Success(string content)
{
return new ToolResult
{
IsError = false,
ContentList = new List<ToolContent>
{
new ToolContent { Type = "text", Text = content }
}
};
}
public static ToolResult Error(string message)
{
return new ToolResult
{
IsError = true,
ContentList = new List<ToolContent>
{
new ToolContent { Type = "text", Text = $"Error: {message}" }
}
};
}
public static ToolResult Error(string code, string message)
{
return new ToolResult
{
IsError = true,
ContentList = new List<ToolContent>
{
new ToolContent { Type = "text", Text = $"Error [{code}]: {message}" }
}
};
}
public static ToolResult SuccessJson(object obj)
{
string json = Newtonsoft.Json.JsonConvert.SerializeObject(obj,
Newtonsoft.Json.Formatting.Indented);
return new ToolResult
{
IsError = false,
ContentList = new List<ToolContent>
{
new ToolContent { Type = "text", Text = json }
}
};
}
public static ToolResult Image(string base64Data, string mimeType = "image/png")
{
return new ToolResult
{
IsError = false,
ContentList = new List<ToolContent>
{
new ToolContent
{
Type = "image",
Data = base64Data,
MimeType = mimeType
}
}
};
}
}
public class ToolContent
{
public string Type { get; set; }
public string Text { get; set; }
public string Data { get; set; }
public string MimeType { get; set; }
}
}
Why static factory methods:
The constructor is private. Tools create results through ToolResult.Success(), ToolResult.Error(), and other factory methods. This ensures that the content list is always properly formatted. A tool author cannot accidentally create a malformed result.
2.7 Tool Discovery: ToolDiscovery.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace MyMcpServer
{
public static class ToolDiscovery
{
public static Dictionary<string, ITool> DiscoverTools()
{
var tools = new Dictionary<string, ITool>();
var assembly = Assembly.GetExecutingAssembly();
var toolTypes = assembly.GetTypes()
.Where(t => typeof(ITool).IsAssignableFrom(t)
&& !t.IsInterface
&& !t.IsAbstract);
foreach (var type in toolTypes)
{
try
{
var tool = (ITool)Activator.CreateInstance(type);
if (!string.IsNullOrEmpty(tool.Name))
{
tools[tool.Name] = tool;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Failed to load tool {type.Name}: {ex.Message}");
}
}
return tools;
}
public static List<object> GenerateToolDefinitions(Dictionary<string, ITool> tools)
{
var definitions = new List<object>();
foreach (var tool in tools.Values)
{
var properties = new Dictionary<string, object>();
var required = new List<string>();
foreach (var param in tool.Params.Parameters)
{
var prop = new Dictionary<string, object>
{
["type"] = param.Type,
["description"] = param.Description
};
if (param.EnumValues != null && param.EnumValues.Length > 0)
prop["enum"] = param.EnumValues;
if (!string.IsNullOrEmpty(param.DefaultValue))
{
switch (param.Type)
{
case "integer":
if (int.TryParse(param.DefaultValue, out int intVal))
prop["default"] = intVal;
break;
case "boolean":
if (bool.TryParse(param.DefaultValue, out bool boolVal))
prop["default"] = boolVal;
break;
default:
prop["default"] = param.DefaultValue;
break;
}
}
properties[param.Name] = prop;
if (param.Required)
required.Add(param.Name);
}
var definition = new Dictionary<string, object>
{
["name"] = tool.Name,
["description"] = tool.Description,
["inputSchema"] = new Dictionary<string, object>
{
["type"] = "object",
["properties"] = properties,
["required"] = required.Count > 0 ? (object)required : null
}
};
definitions.Add(definition);
}
return definitions;
}
}
}
Why reflection-based discovery:
With this pattern, adding a new tool requires zero registration code. You create a class that implements ITool, put it anywhere in the Tools/ folder, and it is automatically discovered at startup. The assembly is scanned for all concrete classes implementing ITool, instantiated, and registered by name.
This eliminates a common source of bugs: forgetting to register a new tool, or having a mismatch between the registration and the actual class.
If a single tool fails to instantiate (for example, due to a missing dependency), the error is logged to stderr and the remaining tools still load. One broken tool does not crash the entire server.
2.8 Writing a Tool: Complete Example
Here is a complete tool implementation. This is all you need to add a new tool.
Tools/File/ReadFile.cs:
using Newtonsoft.Json.Linq;
namespace MyMcpServer.Tools.File
{
public class ReadFile : ITool
{
public string Name => "read_file";
public string Description => "Read the complete raw content of a file.";
public ToolParamList Params => new ToolParamList()
.String("path", "Full path to the file", required: true);
public ToolResult Execute(JObject args)
{
string path = args.Value<string>("path");
if (string.IsNullOrEmpty(path))
return ToolResult.Error("INVALID_PARAMS", "Missing 'path' parameter");
if (!System.IO.File.Exists(path))
return ToolResult.Error($"File not found: {path}");
string content = System.IO.File.ReadAllText(path, System.Text.Encoding.UTF8);
return ToolResult.Success(content);
}
}
}
Tools/File/WriteFile.cs:
using Newtonsoft.Json.Linq;
namespace MyMcpServer.Tools.File
{
public class WriteFile : ITool
{
public string Name => "write_file";
public string Description => "Write content to a file, creating it if needed.";
public ToolParamList Params => new ToolParamList()
.String("path", "Full path to the file", required: true)
.String("content", "Content to write to the file", required: true);
public ToolResult Execute(JObject args)
{
string path = args.Value<string>("path");
string content = args.Value<string>("content");
if (string.IsNullOrEmpty(path))
return ToolResult.Error("INVALID_PARAMS", "Missing 'path' parameter");
if (content == null)
return ToolResult.Error("INVALID_PARAMS", "Missing 'content' parameter");
// Ensure directory exists
string dir = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !System.IO.Directory.Exists(dir))
System.IO.Directory.CreateDirectory(dir);
System.IO.File.WriteAllText(path, content, new System.Text.UTF8Encoding(true));
var fi = new System.IO.FileInfo(path);
return ToolResult.Success(
$"File written: {System.IO.Path.GetFileName(path)}\n" +
$"Size: {fi.Length:N0} bytes");
}
}
}
Tools/Directory/ListDirectory.cs:
using Newtonsoft.Json.Linq;
using System;
using System.IO;
using System.Text;
namespace MyMcpServer.Tools.Directory
{
public class ListDirectory : ITool
{
public string Name => "list_directory";
public string Description => "List files and folders in a directory.";
public ToolParamList Params => new ToolParamList()
.String("path", "Full path to the directory", required: true)
.Bool("recursive", "Include subdirectories", defaultValue: false)
.String("pattern", "Filter pattern (e.g. \"*.cs\")");
public ToolResult Execute(JObject args)
{
string path = args.Value<string>("path");
bool recursive = args.Value<bool?>("recursive") ?? false;
string pattern = args.Value<string>("pattern") ?? "*";
if (string.IsNullOrEmpty(path))
return ToolResult.Error("INVALID_PARAMS", "Missing 'path' parameter");
if (!System.IO.Directory.Exists(path))
return ToolResult.Error($"Directory not found: {path}");
var sb = new StringBuilder();
var searchOption = recursive
? SearchOption.AllDirectories
: SearchOption.TopDirectoryOnly;
// List directories first
foreach (string dir in System.IO.Directory.GetDirectories(path, "*", searchOption))
{
sb.AppendLine($"[DIR] {GetRelativePath(path, dir)}");
}
// List files
foreach (string file in System.IO.Directory.GetFiles(path, pattern, searchOption))
{
sb.AppendLine($"[FILE] {GetRelativePath(path, file)}");
}
if (sb.Length == 0)
return ToolResult.Success("Directory is empty.");
return ToolResult.Success(sb.ToString());
}
private string GetRelativePath(string basePath, string fullPath)
{
if (!basePath.EndsWith(Path.DirectorySeparatorChar.ToString()))
basePath += Path.DirectorySeparatorChar;
if (fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
return fullPath.Substring(basePath.Length);
return fullPath;
}
}
}
Pattern summary for writing tools:
- Create a class that implements
ITool - Set
Name(unique identifier),Description(what it does), andParams(parameter definitions) - Implement
Execute(JObject args)to perform the work - Read parameters with
args.Value<string>("name")orargs.Value<int?>("name") - Return
ToolResult.Success(text)for success orToolResult.Error(message)for failure - Never throw exceptions from
Executeif you can returnToolResult.Errorinstead. The framework catches exceptions as a safety net, but explicit error returns give better messages.
2.9 Configuration: McpConfig.cs and mcp-config.json
mcp-config.json (placed next to the .exe):
{
"allowed_directories": [
"C:\\Projects",
"D:\\Work"
],
"gc_memory_threshold_mb": 150,
"debug_logging": false
}
McpConfig.cs:
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace MyMcpServer
{
public static class McpConfig
{
public static List<string> AllowedDirectories { get; private set; }
public static int GcMemoryThresholdMb { get; private set; }
public static bool DebugLogging { get; private set; }
public static void Load()
{
string configPath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "mcp-config.json");
if (!File.Exists(configPath))
throw new FileNotFoundException($"Config not found: {configPath}");
string json = File.ReadAllText(configPath);
JObject config = JObject.Parse(json);
AllowedDirectories = config["allowed_directories"]?
.ToObject<List<string>>() ?? new List<string>();
AllowedDirectories = AllowedDirectories
.Select(d => Path.GetFullPath(d.Replace('/', '\\')))
.ToList();
GcMemoryThresholdMb = config["gc_memory_threshold_mb"]?.Value<int>() ?? 150;
DebugLogging = config["debug_logging"]?.Value<bool>() ?? false;
}
}
}
Why external configuration:
The config file sits next to the executable, not hardcoded in source. This allows users to customize behavior (allowed directories, database connections, memory thresholds) without recompiling. The AppDomain.CurrentDomain.BaseDirectory ensures the config is found regardless of the working directory that Claude Desktop uses when launching the process.
2.10 Security: Path Validation
If your tools access the filesystem, consider restricting which directories are accessible.
using System;
using System.Collections.Generic;
using System.IO;
namespace MyMcpServer
{
public static class PathValidator
{
public static void Validate(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path cannot be empty");
// If no restrictions configured, allow all
if (McpConfig.AllowedDirectories == null
|| McpConfig.AllowedDirectories.Count == 0)
return;
string normalized = Path.GetFullPath(path)
.TrimEnd(Path.DirectorySeparatorChar);
foreach (string allowed in McpConfig.AllowedDirectories)
{
string normalizedAllowed = allowed
.TrimEnd(Path.DirectorySeparatorChar);
if (normalized.StartsWith(normalizedAllowed,
StringComparison.OrdinalIgnoreCase))
{
// Verify it is actually inside the directory,
// not just a prefix match
if (normalized.Length == normalizedAllowed.Length ||
normalized[normalizedAllowed.Length] == Path.DirectorySeparatorChar)
{
return;
}
}
}
throw new UnauthorizedAccessException(
$"Access denied: '{path}' is not within allowed directories");
}
}
}
Call PathValidator.Validate(path) at the start of any tool that reads or writes files. This prevents path traversal attacks and restricts the AI to directories the user has explicitly permitted.
2.11 Memory Management
MCP servers are long-running processes. If your tools process large files or datasets, memory can accumulate. A simple GC trigger based on memory threshold helps:
private void PerformGcIfNeeded()
{
long memoryMb = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64
/ (1024 * 1024);
if (memoryMb > McpConfig.GcMemoryThresholdMb)
{
GC.Collect(2, GCCollectionMode.Forced, true);
GC.WaitForPendingFinalizers();
GC.Collect(2, GCCollectionMode.Forced, true);
}
}
Call this after each tools/call execution. The threshold is configurable (default 150 MB is reasonable for most workloads).
2.12 NuGet Dependencies
For a .NET Framework 4.8 console app, you need one external package:
- Newtonsoft.Json – JSON serialization/deserialization
Install via NuGet Package Manager or packages.config:
<packages>
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
</packages>
System.Text.Json is also usable on .NET Framework 4.8 (via NuGet), but Newtonsoft.Json has broader compatibility and the JObject type is particularly useful for the dynamic parameter handling this pattern requires.
2.13 Building and Deployment
Build as a standard .NET Framework 4.8 Console Application. The output is a single .exe plus its dependencies in the same folder.
bin/Debug/net48/
├── MyMcpServer.exe <- Your server
├── MyMcpServer.exe.config
├── Newtonsoft.Json.dll <- JSON library
└── mcp-config.json <- Configuration (copy to output)
Make sure mcp-config.json is set to “Copy to Output Directory: Copy always” in the project properties.
Register in Claude Desktop by pointing the command to the full path of the .exe.
2.14 Debug Logging
Since stdout is reserved for protocol messages, debug logging must go elsewhere. Options:
- stderr –
Console.Error.WriteLine()– Claude Desktop captures this - Log file – Write to a file next to the executable
// Simple file-based debug logging
if (McpConfig.DebugLogging)
{
string logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "debug.log");
File.AppendAllText(logPath,
$"[{DateTime.Now:HH:mm:ss.fff}] {message}\n");
}
Enable debug logging during development. Disable it in production to avoid disk I/O on every request.
Quick Reference: Adding a New Tool
- Create a new
.csfile in theTools/folder (any subfolder) - Implement the
IToolinterface - Set
Name,Description, andParams - Implement
Execute(JObject args) - Return
ToolResult.Success()orToolResult.Error() - Build the project
- Restart Claude Desktop (it re-launches MCP servers on restart)
No registration needed. No configuration changes. The reflection-based discovery finds it automatically.
Quick Reference: Complete Message Examples
Initialize:
-> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"claude-ai","version":"0.1.0"}}}
<- {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"my-mcp-server","version":"1.0.0"}}}
Initialized notification:
-> {"jsonrpc":"2.0","method":"notifications/initialized"}
(no response)
List tools:
-> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
<- {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"read_file","description":"Read a file.","inputSchema":{"type":"object","properties":{"path":{"type":"string","description":"Full path"}},"required":["path"]}}]}}
Call tool (success):
-> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"C:\\test.txt"}}}
<- {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Hello World"}]}}
Call tool (error):
-> {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"C:\\missing.txt"}}}
<- {"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"Error: File not found: C:\\missing.txt"}],"isError":true}}
Ping:
-> {"jsonrpc":"2.0","id":99,"method":"ping"}
<- {"jsonrpc":"2.0","id":99,"result":{}}
What Custom MCP Tools Can Do
MCP tools give Claude (or any MCP client) the ability to interact with the outside world through your code. Since a tool is simply a C# method that receives parameters and returns a result, the possibilities are limited only by what C# can do on the host machine.
Examples:
1. Local Filesystem Operations
Tools can read, write, edit, search, and organize files on the user’s machine. This includes reading source code with line numbers for precise editing, writing new files, performing find-and-replace across entire project directories, managing backups, and working with binary files like images and archives. For developers, this turns Claude into a hands-on coding assistant that can directly modify project files rather than just suggesting changes.
2. Database Access and Management
Tools can connect to databases (MySQL, SQLite, SQL Server, or any database with a .NET driver), execute queries, inspect schemas, run migrations, and return structured results. This allows Claude to explore database structures, write and test queries, insert or update records, and perform data analysis without the user needing to copy-paste between a database client and the chat window.
3. Build Systems and Developer Tooling
Tools can invoke MSBuild to compile .NET projects, run NuGet package restore, manage IIS Express for local web development, scaffold new project files, and execute any command-line build tool. This enables Claude to participate in the full development lifecycle: create a project, add files, install packages, build, diagnose compilation errors, and fix them in a single conversation.
4. HTTP and API Integration
Tools can make HTTP requests to external APIs, web services, or local development servers. This includes fetching data from REST APIs, posting form data, downloading files, checking endpoint health, and interacting with third-party services. Any web API that accepts HTTP requests can be wrapped as an MCP tool, giving Claude the ability to interact with services like GitHub, Jira, cloud platforms, or internal company APIs.
5. Document and Data Processing
Tools can read and manipulate structured document formats like Excel spreadsheets (.xlsx), Word documents (.docx), CSV files, JSON data, and XML. This includes extracting tables from spreadsheets, searching document contents, converting between formats, and generating reports. For business users, this means Claude can analyze spreadsheets, extract information from documents, and produce formatted output without requiring manual data preparation.
6. System Administration and Environment Management
Tools can inspect running processes, manage Windows services, read environment variables, manipulate the Windows registry, monitor disk space, and perform other system-level operations. For IT professionals and DevOps workflows, this means Claude can help diagnose system issues, automate routine maintenance tasks, and interact with the local environment in ways that would otherwise require opening multiple management consoles.
7. IoT Device Access and Sensor Integration
Tools can communicate with IoT devices and embedded hardware over the protocols they speak natively — serial (COM ports), MQTT, HTTP REST, Modbus TCP, or raw TCP/UDP sockets. This includes reading sensor values (temperature, humidity, motion, current draw), sending control commands (relay toggles, motor speed, LED state), polling device registers, subscribing to MQTT topics for streaming telemetry, and parsing binary frames from proprietary sensors. For hardware engineers and home-automation builders, this means Claude can inspect a live sensor feed, help diagnose an anomalous reading, write a calibration routine, and push updated config to the device — all inside a single conversation, without leaving the chat window to open PuTTY or a broker dashboard.
Each of these areas can be implemented as a set of focused, single-purpose tool classes following the ITool pattern described in this document. The reflection-based discovery system means you can build a library of dozens or hundreds of tools organized by category, and they all become available to Claude automatically.
