An MCP server is a small program that exposes tools, data and prompt templates to AI applications through the Model Context Protocol (MCP), an open standard. Once it's connected, an assistant such as Claude, GitHub Copilot or ChatGPT can call your server's functions, such as "search orders" or "create a GitHub issue", without a custom integration for each assistant. You write the server once, and any MCP-compatible client can use it.
A server runs either locally over stdio, where the client starts it as a child process, or remotely over Streamable HTTP, where the client calls a URL. Both carry JSON-RPC 2.0 messages.
As of October 2026, the current specification is version 2026-07-28, the largest revision so far. It makes MCP stateless by removing the initialize handshake and protocol-level sessions. The official C#, TypeScript and Python SDKs have all shipped 2.x releases for it, and every example below uses them.
Why MCP exists
MCP standardizes how AI applications connect to external tools and data. Anthropic introduced it in November 2024, but it's an open protocol, developed in the open at modelcontextprotocol.io and supported by most major AI clients, including GitHub Copilot in VS Code, ChatGPT and Cursor. Official SDKs exist for C#, TypeScript, Python and other languages.
The specification credits the Language Server Protocol as inspiration, and the parallel is exact. LSP lets any editor talk to any language server; MCP lets any AI client talk to any tool server. Without it, every integration is N x M work: ten AI apps and twenty systems means two hundred custom connectors. With it, each system ships one server and each app ships one client.
The spec defines three roles. The host is the AI application the user works in, such as Claude Desktop, Claude Code, VS Code or ChatGPT. The client is the connector inside the host, holding one connection to one server. The server is your program. A typical exchange looks like this:
User asks a question in the host (Claude, VS Code, ChatGPT)
↓
Host sends the question plus the list of available MCP tools to the model
↓
Model decides to call a tool, e.g. get_order_status(orderId: 1042)
↓
MCP client sends a JSON-RPC "tools/call" request to your server
↓
Your server runs the code (query DB, call API) and returns the result
↓
Model uses the result to write the final answer
Notice what the model can't do. It never connects to your database. It can only ask the host to call a tool you exposed, with arguments that match your schema, which is why the design of those tools is where your security starts.
What a server can offer
A server answers JSON-RPC requests: the client lists what the server offers, then calls it. There are three kinds of feature, and the difference between them is who decides when they're used.
Feature | Who controls it | Typical use |
|---|
Tools | The model | Run a query, create a ticket, call an API |
Resources | The application or user | Attach a file, schema or record as context |
Prompts | The user | Slash-command workflows and templates |
Tools are functions the model can execute. Each has a name, a description and a JSON Schema for its input. The client discovers them with tools/list and runs one with tools/call:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "get_order_status",
"arguments": { "orderId": 1042 }
}
}
Because the model picks when to call a tool, the host should ask the user before running it.
Resources are read-only data identified by a URI, such as file:///logs/app.log or orders://1042, and the host or user decides what to attach to the conversation. Use them for context, not actions. Prompts are reusable message templates with arguments, such as "Review this pull request" or "Summarize incident {id}", and hosts usually surface them as slash commands.
Clients can offer features to servers too. The main one in the current spec is elicitation, which lets a server ask the user for missing information. Roots, Sampling and Logging are now deprecated; they still work during the deprecation window, but new servers shouldn't depend on them.
Choosing a transport
stdio | Streamable HTTP |
|---|
The client starts your server as a local process | The client sends HTTP POST requests to a URL such as https://api.example.com/mcp |
Messages go over stdin and stdout | Responses are plain JSON or a server-sent event stream |
Runs with the user's own permissions | Runs on your infrastructure; secured with OAuth |
Best for local files, CLIs and dev tools | Best for shared, team or SaaS servers |
Not supported by cloud clients such as ChatGPT or claude.ai connectors | Works with desktop, IDE and web clients |
My rule of thumb: stdio for a personal tool that needs your machine, Streamable HTTP as soon as a second person or a cloud client needs it. The older HTTP+SSE transport, with its two endpoints, has been deprecated since the 2025-03-26 revision, so don't start anything new on it.
One rule trips up almost everyone writing a stdio server: never write logs to stdout. Stdout carries the protocol messages, so a stray Console.WriteLine or print() corrupts the stream. Log to stderr.
What changed in the 2026-07-28 spec
MCP uses date-based versions rather than semantic versioning. Before 2026-07-28 came 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05. According to the official changelog, the move from 2025-11-25 brought these changes:
- The
initialize / notifications/initialized handshake is gone. Every request carries its protocol version and client capabilities in _meta. - Protocol sessions are gone too, along with the
Mcp-Session-Id header. If your server needs state across calls, mint your own handle and pass it as an ordinary tool argument. - Servers must implement a new
server/discover method to advertise their versions, capabilities and identity. subscriptions/listen replaces the HTTP GET stream and resource subscribe calls for change notifications.- Instead of server-initiated requests, a server returns an
input_required result and the client retries with the answers (multi round-trip requests). - Long-running tasks moved into the official tasks extension, alongside extensions such as MCP Apps (inline UI).
- Streamable HTTP POSTs must carry
Mcp-Method and Mcp-Name headers, which makes routing and gateway filtering easier.
The point of all this is scale. A stateless server sits behind a load balancer like any other HTTP service. The SDKs hide most of the detail, and the Python SDK documents that its 2.x line still serves 2025-era clients from the same app.
Building an MCP server in C#
The official ModelContextProtocol C# SDK is maintained with Microsoft, and its latest NuGet release is 2.3.0. It comes in three packages. ModelContextProtocol provides hosting and dependency injection, and it's what you use for stdio servers. ModelContextProtocol.AspNetCore adds Streamable HTTP servers on ASP.NET Core. ModelContextProtocol.Core holds the low-level client and server APIs with minimal dependencies.
A stdio server
dotnet new console -n OrdersMcp
cd OrdersMcp
dotnet add package ModelContextProtocol
dotnet add package Microsoft.Extensions.Hosting
The whole server, including one tool and one prompt, fits in 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);
// stdout is the protocol channel, so send every log line to stderr
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly()
.WithPromptsFromAssembly();
await builder.Build().RunAsync();
[McpServerToolType]
public static class OrderTools
{
private static readonly Dictionary<int, string> Orders = new()
{
[1042] = "Shipped",
[1043] = "Processing"
};
[McpServerTool, Description("Gets the shipping status of an order by its numeric ID. Read-only.")]
public static string GetOrderStatus(
[Description("The order ID, for example 1042")] int orderId)
=> Orders.TryGetValue(orderId, out var status)
? $"Order {orderId}: {status}"
: $"Order {orderId} was not found.";
}
[McpServerPromptType]
public static class OrderPrompts
{
[McpServerPrompt, Description("Drafts a polite status update for a customer.")]
public static string CustomerUpdate(int orderId)
=> $"Look up order {orderId} and write a short, polite status update for the customer.";
}
The SDK reads the [Description] attributes and parameter types to build each tool's name, description and JSON Schema. Spend real time on those descriptions. They're what the model reads when it decides which tool to call, and a vague one means your tool never gets used.
The same tools over HTTP
To share the server with a team, host it on ASP.NET Core. The tool class doesn't change:
dotnet new web -n OrdersMcpHttp
dotnet add package ModelContextProtocol.AspNetCore
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Server;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
// Stateless is the default and fits the 2026-07-28 spec
options.SessionMode = HttpServerSessionMode.Stateless;
})
.WithToolsFromAssembly();
var app = builder.Build();
app.MapMcp(); // maps the MCP endpoint
app.Run("http://localhost:3001");
The SDK's getting-started guide also recommends limiting AllowedHosts to loopback or known host names rather than "*", to guard against DNS rebinding. Enable CORS only if a browser genuinely needs it. And think hard about which methods you expose at all; three methods a .NET 10 MCP server should never give AI is a good check before you deploy.
The same server in TypeScript and Python
The TypeScript SDK v2 implements the 2026-07-28 spec and splits into @modelcontextprotocol/server and @modelcontextprotocol/client. The v1 package, @modelcontextprotocol/sdk, still gets fixes for at least six months after the v2 release.
mkdir orders-mcp-ts && cd orders-mcp-ts
npm init -y
npm install @modelcontextprotocol/server zod
import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
const server = new McpServer({ name: 'orders-server', version: '1.0.0' });
server.registerTool(
'get_order_status',
{
description: 'Gets the shipping status of an order by its numeric ID. Read-only.',
inputSchema: z.object({ orderId: z.number().int() })
},
async ({ orderId }) => ({
content: [{ type: 'text', text: `Order ${orderId}: Shipped` }]
})
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('orders-server running on stdio'); // stderr, never stdout
}
main();
Set "type": "module" in package.json, compile with tsc, and point your client at node build/index.js.
The Python SDK 2.x needs Python 3.11 or later. In v2 the high-level class formerly called FastMCP is MCPServer, imported from mcp.server, so any tutorial importing mcp.server.fastmcp was written for v1.
uv init orders-mcp
cd orders-mcp
uv add "mcp[cli]"
from mcp.server import MCPServer
mcp = MCPServer("Orders")
ORDERS = {1042: "Shipped", 1043: "Processing"}
@mcp.tool()
def get_order_status(order_id: int) -> str:
"""Get the shipping status of an order by its numeric ID. Read-only."""
status = ORDERS.get(order_id)
return f"Order {order_id}: {status}" if status else f"Order {order_id} was not found."
@mcp.resource("orders://{order_id}")
def order_resource(order_id: str) -> str:
"""Order details as a resource the user can attach."""
return f"Order {order_id}: {ORDERS.get(int(order_id), 'unknown')}"
if __name__ == "__main__":
mcp.run(transport="stdio")
Type hints and the docstring become the tool schema and description. To serve it over Streamable HTTP instead, run uv run mcp run server.py --transport streamable-http, which listens at http://localhost:8000/mcp.
Whatever language you use, test with the MCP Inspector before involving an AI client. npx @modelcontextprotocol/inspector lets you list and call any server's tools by hand, and with the Python SDK, uv run mcp dev server.py opens it directly.
Connecting it to Claude, VS Code and ChatGPT
Claude Code
Use claude mcp add. Everything after -- is the command that starts a stdio server:
# Local stdio server
claude mcp add orders -- dotnet run --project ./OrdersMcp
# Remote Streamable HTTP server
claude mcp add --transport http github https://api.githubcopilot.com/mcp/
# Share with your team via .mcp.json in the repo
claude mcp add --scope project --transport http learn https://learn.microsoft.com/api/mcp
claude mcp list
The --scope flag accepts local (the default), project or user. Inside a session, /mcp shows server status and completes OAuth logins. If you haven't used the tool before, start with Claude Code for .NET developers.
Claude Desktop and claude.ai
Remote servers are added as custom connectors under Settings, Connectors, Add custom connector, using the server URL. Claude connects to them from Anthropic's cloud, not from your machine, so the server must be publicly reachable over HTTPS. On Team and Enterprise plans, owners add connectors in admin settings first. Local stdio servers go in Claude Desktop's own config file instead.
VS Code and GitHub Copilot
Run MCP: Add Server from the Command Palette, or create .vscode/mcp.json. Watch the key name: VS Code uses a top-level servers key, not mcpServers.
{
"servers": {
"orders": {
"type": "stdio",
"command": "dotnet",
"args": ["run", "--project", "./OrdersMcp"]
},
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/"
}
}
}
VS Code also reads a portable Copilot file, ~/.copilot/mcp-config.json, which does use mcpServers. The VS Code MCP configuration reference explains which file each type of chat session reads. The tools picker in chat lets you switch individual tools off.
ChatGPT
ChatGPT connects only to remote MCP servers over HTTPS; it can't launch a local stdio process. You add one as an app in ChatGPT's developer mode on the web. OpenAI's help center and developer docs describe different eligible plans and menu paths, so check your own account's settings. Expect OAuth for anything beyond a public, read-only server, and expect ChatGPT to ask before it runs write actions.
Servers worth installing first
Server | What it does | How to connect |
|---|
GitHub MCP Server | Repos, issues, pull requests, Actions | Remote: https://api.githubcopilot.com/mcp/; supports --toolsets and --read-only when self-hosted |
Playwright MCP | Browser automation via accessibility snapshots | npx @playwright/mcp@latest
|
Microsoft Learn MCP Server | Searches and fetches official Microsoft docs and code samples | Remote: https://learn.microsoft.com/api/mcp, no auth, free |
Azure MCP Server | Azure resource operations | See the safety note below |
Reference servers | Filesystem, Git, Fetch, Memory, Time, Sequential Thinking, Everything | Maintained in the modelcontextprotocol/servers repo |
The Azure server can change real cloud resources, so read up on using Azure MCP Server tools safely before you connect it. For anything else, the official MCP Registry is linked from the modelcontextprotocol/servers repo. Treat every community server like any third-party dependency: read the code or trust the publisher before you run it.
Securing an MCP server
The specification puts it bluntly: tools represent arbitrary code execution. An MCP server hands a probabilistic model a set of real actions, so design for the day it gets tricked.
Authentication
A stdio server takes its credentials from environment variables and runs with the user's permissions, so keep the scope of those keys small. An HTTP server uses OAuth 2.1: it acts as an OAuth resource server, publishes protected resource metadata, and validates every access token, including its audience. Ask for narrow scopes, and step up only when a privileged tool is first used rather than requesting everything up front. The 2026-07-28 revision also deprecates Dynamic Client Registration in favor of Client ID Metadata Documents.
Two mistakes are worth calling out. First, never pass tokens through: accepting a token issued for another API and forwarding it bypasses audience checks and auditing, and the spec's security guidance forbids it. Second, don't treat a state handle as authentication. Now that MCP is stateless, servers pass their own handles, such as a cart ID or workflow ID, as tool arguments. Generate them randomly, bind them to the authenticated user, and reject them from anyone else.
Prompt injection and tool poisoning
Anything a tool returns, whether an email body, a web page or an issue comment, can contain instructions aimed at the model. A malicious server can also hide instructions in its own tool descriptions. Treat tool output as untrusted data, and never let one tool's output silently trigger a destructive one. Keep a human approval step for writes, payments, deletes and outbound messages. Install servers only from publishers you trust, and pin versions. Most importantly, avoid combining private data, untrusted content and an outbound channel (such as email or an HTTP fetch tool) in the same session, because that combination is what turns an injection into a leak.
Least privilege
Expose narrow tools get_order_status(orderId) not run_sql(query)
↓
Use read-only credentials a DB login that can only SELECT the views you need
↓
Separate read and write write tools behind confirmation, or in another server
↓
Log every call tool name, arguments, caller, result size
Validate inputs on the server even though a schema exists, because the model can send any value. Apply rate limits and result-size limits as well, so one bad call can't dump a whole table into a conversation.
When it doesn't work
The client shows the server as failed
Run the start command yourself in a terminal. Missing runtimes, wrong paths and relative paths resolved from the wrong directory are the usual causes.
"Invalid JSON" or a dead stdio connection
Something is writing to stdout. Move all logging to stderr.
The model never calls your tool
Rewrite the description to say when the tool should be used. Vague names such as process lose to clear ones such as search_invoices_by_customer. If the server exposes dozens of tools, that's probably the real problem: they crowd the model's context and hurt tool selection. Split the server, or use toolset options to load only what a task needs.
It works locally but not in ChatGPT or claude.ai
Those clients connect from the cloud, so localhost is unreachable to them. Deploy the server, or use a secure tunnel while testing.
Join the conversation! Your thoughts help the community grow.