Subchapter 143.13
references/transport-stdio.mdMarkdown5 KBView on GitHub
STDIO is the right choice when the server runs as a child process of the client (Claude Desktop, VS Code, MCP Inspector, a custom CLI). The client launches your executable; you read JSON-RPC frames from stdin and write them to stdout.
dnx-runnable NuGet package.Stateless flag to worry about.If the user wants a remote/multi-tenant server, use HTTP Streamable instead.
dotnet new console -n MyStdioServer -f net10.0
cd MyStdioServer
dotnet add package ModelContextProtocol --version 2.2.0
dotnet add package Microsoft.Extensions.Hosting --version 10.0.11// Program.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
using System.ComponentModel;
var builder = Host.CreateApplicationBuilder(args);
// CRITICAL: stdout is the JSON-RPC channel. Send all logs to stderr.
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
[McpServerToolType]
public static class EchoTool
{
[McpServerTool, Description("Echoes the message back to the client.")]
public static string Echo(string message) => $"hello {message}";
}The single most common bug in STDIO servers is something writing to stdout that isn’t a JSON-RPC frame. The client will then drop the connection with a parse error.
Things that silently break STDIO:
Console.WriteLine(...) anywhere in your code.Trace.WriteLine(...) if a default trace listener is attached.Defensive checklist:
Console.Write* from tools or startup code. Use ILogger injected into the tool class.ILogger or suppress them at startup.The SDK sends serverInfo (name + version) during negotiation (the 2026-07-28 server/discover exchange, or the legacy initialize response for down-level clients — the SDK handles both automatically). By default it derives them from your assembly. To override:
builder.Services
.AddMcpServer(options =>
{
options.ServerInfo = new()
{
Name = "my-stdio-server",
Version = "1.0.0",
Title = "My STDIO MCP Server" // optional human-readable name
};
})
.WithStdioServerTransport()
.WithToolsFromAssembly();Clients (e.g. Claude Desktop config) typically launch your server with arguments and environment variables. Read them like any other .NET app:
string apiKey = Environment.GetEnvironmentVariable("MY_API_KEY")
?? throw new InvalidOperationException("MY_API_KEY not set");
string configPath = args.ElementAtOrDefault(0)
?? Path.Combine(Environment.CurrentDirectory, "config.json");Document the expected vars/args in the README so users know what to put in their client config.
In claude_desktop_config.json:
{
"mcpServers": {
"my-server": {
"command": "dotnet",
"args": ["run", "--project", "C:/path/to/MyStdioServer"],
"env": {
"MY_API_KEY": "..."
}
}
}
}For a published self-contained executable, replace command/args with the executable path. For a NuGet-distributed server using dnx:
"command": "dnx",
"args": ["MyMcpServer", "--version", "1.2.3"]In .vscode/mcp.json:
{
"servers": {
"my-server": {
"type": "stdio",
"command": "dotnet",
"args": ["run", "--project", "${workspaceFolder}/src/MyMcpServer"]
}
}
}The cleanest workflow is MCP Inspector (opens in a new tab):
npx @modelcontextprotocol/inspector dotnet run --project ./MyStdioServerInspector launches your server, opens a UI, and lets you call tools / list resources / fire elicitations interactively. See testing.md for more.
builder.Build().RunAsync() already handles SIGINT/SIGTERM. If you have background work to flush, use IHostApplicationLifetime:
var host = builder.Build();
var lifetime = host.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
// flush, close handles, etc. — keep it fast (<5s) so the client doesn't hang.
});
await host.RunAsync();