Defining MCP Tools in C#: Attributes, JSON Schema, and Error Handling
When you define MCP tools in C#, you're not just exposing a method. You're creating a contract that a model will inspect, reason about, and eventually call with JSON arguments. The official MCP C# SDK makes that feel familiar with attributes, dependency injection, cancellation tokens, and normal .NET return types, but the details matter. A vague tool name, a missing parameter description, or the wrong error shape can be the difference between a useful agent workflow and a confusing retry loop.
This is a middle-of-funnel implementation reference. I’m assuming you already understand why Model Context Protocol matters and now want to decide how to shape tool definitions in a .NET server. We’ll stay focused on tools specifically: attributes, generated JSON Schema, return shapes, DI, cancellation, errors, list-changed notifications, and naming practices that help a model call the right thing.
As of writing, the latest stable NuGet version for ModelContextProtocol, ModelContextProtocol.Core, and ModelContextProtocol.AspNetCore is 1.4.0. NuGet also lists a 2.0.0-preview.1, but the examples here stick to stable SDK APIs.
How MCP Tools in C# Become Model-Callable Contracts
MCP tools are model-controlled capabilities. The MCP tools specification describes a tool with a unique name, optional title, human-readable description, an inputSchema, optional outputSchema, and optional annotations. The C# SDK maps a .NET method into that protocol shape. That mapping is convenient, but it also means your method signature and descriptions become part of the model-facing interface.
The tradeoff is worth calling out. Attribute-based tool definition is fast and idiomatic for many .NET teams. It keeps the tool close to the code that implements it. It also encourages you to think carefully about names, parameter boundaries, and result shape because those details are discoverable by the host. If you’ve worked with function calling through Function Tools with AIFunctionFactory in Microsoft Agent Framework, this will feel similar: metadata quality is not decorative. It changes model behavior.
The main official MCP C# SDK types you’ll use are in ModelContextProtocol.Server and ModelContextProtocol.Protocol. The attribute path starts with [McpServerToolType] on a class and [McpServerTool] on methods. The runtime discovers those methods when you register tools from an assembly or explicitly register a tool type.
Here is the smallest useful shape for defining MCP tools in C# without drifting into project setup:
using ModelContextProtocol.Server;
using System.ComponentModel;
[McpServerToolType]
public static class IncidentTools
{
[McpServerTool(
Name = "search_incidents",
Title = "Search Incidents",
ReadOnly = true,
Destructive = false,
Idempotent = true,
OpenWorld = false)]
[Description("Searches known incident summaries for a service name and returns concise matches.")]
public static string SearchIncidents(
[Description("The service name to search for, such as billing-api or checkout.")]
string serviceName,
[Description("The maximum number of matching incidents to return. Use 1 through 10.")]
int maxResults = 5)
{
return $"Showing up to {maxResults} incidents for {serviceName}.";
}
}
There are a few important details in that example. [McpServerToolType] marks the class as a container. [McpServerTool] marks the method as callable and can set metadata like Name, Title, ReadOnly, Destructive, Idempotent, and OpenWorld. [Description] on the method becomes the tool description. [Description] on parameters becomes schema guidance for each argument.
The SDK source derives a name when you do not provide one. It removes an Async suffix for async methods, replaces non-alphanumeric runs with underscores, trims underscores, and applies lower snake case by default. That is helpful, but I prefer explicit names for production MCP tools in C# because the name is part of the model-facing contract.
MCP Tools in C# and Generated JSON Schema
The generated input schema is where your .NET method becomes a JSON contract. The SDK uses JSON Schema 2020-12 for tool parameters. Normal parameters are read from the tool call arguments dictionary and deserialized from JSON. Special parameters are excluded from the schema because the server binds them from the MCP request context or DI container.
For basic types, the mapping is predictable. string maps to string, int and long map to integer, float and double map to number, bool maps to boolean, and complex types become JSON objects with properties. Default parameter values can show up in the schema, and parameter names become the argument names the model uses.
That means parameter naming is not just an internal C# concern. query, maxResults, and includeArchived are much easier for a model to use correctly than q, n, and flag. If you want a bounded count, say the range in the description and still validate it in code.
The SDK documentation is explicit that schema annotations are not runtime validation. Descriptions help shape the generated JSON Schema, but your MCP tool method is still responsible for validating string lengths, ranges, required business rules, and unsafe operations. If your tool mutates state or reaches into production systems, pair that validation with approval patterns like tool approval and human-in-the-loop workflows.
For MCP tools in C#, think about the schema as the model’s user interface. The model does not see your implementation details. It sees a name, a description, and fields. Make those fields boring, explicit, and hard to misinterpret.
Injecting Services and CancellationToken into MCP Tools in C#
One of the best reasons to define MCP tools in C# is that you can reuse normal .NET application structure. The SDK can bind services from dependency injection into tool method parameters, and those DI parameters are not included in the generated JSON input schema. CancellationToken is also specially handled and excluded from the schema. The token is triggered when the client cancels the in-flight request, allowing your tool to stop work promptly.
That combination lets you keep model-provided arguments separate from trusted application services. The model supplies query and maxResults; your application supplies the search service, logger, database abstraction, authorization policy, or other dependencies. If you want a deeper reminder of how containers resolve services, how dependency injection containers use reflection internally in C# is a useful companion topic.
using ModelContextProtocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
public sealed record RunbookMatch(string Title, string Summary);
public interface IRunbookSearchService
{
Task<IReadOnlyList<RunbookMatch>> SearchAsync(
string query,
int maxResults,
CancellationToken cancellationToken);
}
[McpServerToolType]
public static class RunbookTools
{
[McpServerTool(Name = "search_runbooks", Title = "Search Runbooks", ReadOnly = true)]
[Description("Finds operational runbooks that match a troubleshooting query.")]
public static async Task<string> SearchRunbooksAsync(
IRunbookSearchService runbooks,
[Description("The troubleshooting topic, error code, or service symptom to search for.")]
string query,
[Description("The maximum number of runbooks to return. Use 1 through 5.")]
int maxResults = 3,
CancellationToken cancellationToken = default)
{
if (maxResults is < 1 or > 5)
{
throw new McpException("maxResults must be between 1 and 5.");
}
var matches = await runbooks.SearchAsync(query, maxResults, cancellationToken);
return string.Join(
"
",
matches.Select(match => $"- {match.Title}: {match.Summary}"));
}
}
This is a practical boundary for MCP tool definition .NET teams can maintain. The model-facing schema contains query and maxResults. It does not contain IRunbookSearchService or CancellationToken. The tool implementation still validates maxResults because a schema hint does not replace business validation.
The same pattern applies to logging and observability dependencies. You can inject an ILogger<T> or service that records tool usage, while keeping stdout and protocol concerns separate. If you are designing tool operations that call APIs or databases, the production habits from logging in .NET still matter.
Return Types for MCP Tools in C#
The simplest return type is string. The SDK wraps it as a TextContentBlock in the tool result. That is often enough for short summaries, IDs, status messages, and markdown snippets. But MCP tools in C# can return richer content when the result needs more structure.
The SDK tools documentation shows that tool return values can map to several result shapes: null becomes an empty content list, string becomes text content, a single ContentBlock is returned as one content item, IEnumerable<ContentBlock> becomes multiple content blocks, AIContent from Microsoft.Extensions.AI maps to an MCP content block, IEnumerable<AIContent> maps item by item, CallToolResult is returned directly, and other object types are serialized to JSON text. The concept docs also call out DataContent: image MIME types map to ImageContentBlock, audio MIME types map to AudioContentBlock, and other MIME types map to an embedded resource block with binary contents.
Here is a mixed content example that returns text and an image block. It is intentionally small, but it shows the return type rather than only another string:
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
[McpServerToolType]
public static class DiagramTools
{
[McpServerTool(Name = "describe_deployment_diagram", Title = "Describe Deployment Diagram")]
[Description("Returns a short deployment summary and a small PNG diagram.")]
public static IEnumerable<ContentBlock> DescribeDeploymentDiagram()
{
byte[] onePixelPng = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=");
return
[
new TextContentBlock { Text = "The deployment has one API node behind a gateway." },
ImageContentBlock.FromBytes(onePixelPng, "image/png")
];
}
}
Use richer content when it changes the user experience. If the model only needs a string summary to continue reasoning, a string is easier to inspect and test. If the tool genuinely produces an image, audio clip, or embedded binary payload, return the appropriate content block. The point is not to show off the protocol surface; the point is to return the smallest useful shape that preserves the information.
The SDK also supports structured content and output schema through [McpServerTool(UseStructuredContent = true)] and OutputSchemaType. OutputSchemaType only takes effect when UseStructuredContent is true. That can help when the result is machine-readable and downstream tooling needs a predictable JSON shape.
Error Handling for MCP Tools in C#
Error handling is where many MCP tool definitions get muddy. The protocol distinguishes tool execution errors from protocol errors. Tool execution errors belong in a normal tool result with isError: true. Protocol errors are JSON-RPC errors for issues like malformed protocol-level requests, unknown tools, or invalid protocol-level arguments.
The C# SDK gives you a few choices. Throwing McpException from a tool method produces a tool error result and includes the exception message. Throwing most other exception types also produces a tool error result, but the caller receives a generic message to avoid leaking internal details. Throwing McpProtocolException produces a JSON-RPC protocol error instead. Returning CallToolResult directly gives you full control over success and error responses, including IsError.
For MCP tools in C#, I treat recoverable business problems as tool errors. Missing domain data, an invalid range, a closed ticket, or a rate-limited dependency can usually be returned in a way the model can understand and retry. I reserve McpProtocolException for protocol-level problems where the request itself is invalid at the MCP layer.
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
[McpServerToolType]
public static class WorkItemTools
{
[McpServerTool(Name = "get_work_item_summary", Title = "Get Work Item Summary", ReadOnly = true)]
[Description("Gets a short summary for a work item ID.")]
public static CallToolResult GetWorkItemSummary(
[Description("The numeric work item ID to summarize.")]
int workItemId)
{
if (workItemId <= 0)
{
return new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = "workItemId must be greater than zero." }]
};
}
if (workItemId == 404)
{
throw new McpException("No work item exists for ID 404.");
}
return new CallToolResult
{
Content = [new TextContentBlock { Text = $"Work item {workItemId}: ready for triage." }]
};
}
}
This example shows two valid tool-execution error styles. Returning CallToolResult is explicit and testable. Throwing McpException is convenient when you want the SDK to shape the error result. Both are different from throwing McpProtocolException, which should be rare in ordinary application validation.
The decision comes down to control. If you need specific content blocks, structuredContent, or consistent error text, return CallToolResult. If you just need to stop the tool and surface a safe message, throw McpException. If an unexpected dependency throws, let the SDK avoid leaking internals and record the details through your logs.
Tool List-Changed Notifications
Most MCP tools in C# are static from the model’s perspective: list tools, call tools, done. Some servers can add, remove, or modify tools at runtime. The MCP protocol supports a notifications/tools/list_changed notification when the server declares the tools capability with listChanged.
The C# SDK exposes this as NotificationMethods.ToolListChangedNotification and ToolListChangedNotificationParams. The concept docs note an important constraint: unsolicited notifications require stdio or stateful sessions. Stateless HTTP servers cannot send unsolicited tool-list notifications because there is no session channel to push through.
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
[McpServerToolType]
public static class DynamicToolRegistryTools
{
[McpServerTool(Name = "refresh_tool_registry", Title = "Refresh Tool Registry")]
[Description("Refreshes the dynamic tool registry and notifies the client when the list changes.")]
public static async Task<string> RefreshToolRegistryAsync(
McpServer server,
CancellationToken cancellationToken)
{
await server.SendNotificationAsync(
NotificationMethods.ToolListChangedNotification,
new ToolListChangedNotificationParams(),
cancellationToken: cancellationToken);
return "Tool registry refreshed.";
}
}
Use this sparingly. Dynamic tool lists can be valuable for plugin systems or permission-scoped capabilities, but they also make behavior harder to reason about.
Naming and Description Best Practices for MCP Tools in C#
Names and descriptions are operational design, not copywriting garnish. A model is selecting from available tools based on the metadata you provide. If two tools sound similar, it may choose the wrong one. If a parameter is ambiguous, it may pass the wrong value.
Good names are action-oriented and specific: search_runbooks, get_work_item_summary, create_draft_release_notes. Avoid internal abbreviations unless your users and model context will consistently know them. Keep the tool name stable once clients depend on it.
Descriptions should answer three questions. What does the tool do? When should it be used? What should the caller avoid? For example, "Searches operational runbooks for troubleshooting guidance. Use this when the user asks how to diagnose a service issue. Do not use it to modify incidents." That kind of description gives the model a selection boundary.
Parameter descriptions should include formats, limits, and units. Say "ISO 8601 date, such as 2026-07-05" instead of "date". Say "maximum number of results, 1 through 10" instead of "count". Then enforce those limits in code.
Annotations also matter. Mark read-only tools as ReadOnly = true. Mark destructive tools honestly. Set Idempotent when repeating the same call has no additional effect. Use OpenWorld = false when the tool interacts with a closed, well-defined domain. These hints do not replace authorization, but they help hosts and humans reason about risk. If you're integrating these tools into broader agent workflows, MCP tool integration in Microsoft Agent Framework in C# gives you a wider view of how tool metadata flows into agent behavior.
A Practical Checklist for Defining MCP Tools in C#
Before I call an MCP tool definition ready, I want it to pass a short review. The method should have one clear responsibility. The tool name should be stable, explicit, and model-readable. The method description should explain when to use the tool. Every model-supplied parameter should have a descriptive name and [Description] text.
I also check the return shape. If the result is plain text, return string. If the result needs multiple blocks, return IEnumerable<ContentBlock>. If you need precise error or structured response control, return CallToolResult. Avoid returning massive blobs when a short summary plus an identifier would be more useful.
Finally, review failure behavior. Recoverable domain failures should be visible as tool errors so the model can self-correct. Sensitive implementation failures should not leak. Cancellation should flow into async dependencies.
This is the implementation discipline that makes MCP tools in C# feel like a stable API surface instead of a random method collection. It is also why package pinning and API verification matter. If you're publishing reusable server packages, the same versioning thinking from NuGet versioning and Semantic Versioning in .NET applies to your tool contracts.
FAQ
What attributes define MCP tools in C#?
Use [McpServerToolType] on the containing class and [McpServerTool] on each callable method. Use [Description] on the method and parameters to populate model-facing descriptions in the generated tool metadata and input schema.
How does JSON Schema get generated for MCP tools in C#?
The SDK generates JSON Schema 2020-12 from the method signature. Normal parameters become schema properties. Special parameters such as CancellationToken, McpServer, progress handlers, and DI-resolved services are excluded from the schema because the server supplies them.
Should an MCP tool return string or CallToolResult?
Return string for simple text results. Return CallToolResult when you need direct control over Content, StructuredContent, or IsError. For richer output, return ContentBlock, IEnumerable<ContentBlock>, AIContent, or IEnumerable<AIContent>.
When should MCP tools in C# throw McpException?
Throw McpException for safe, recoverable tool-execution errors where the message can be shown to the model. For example, a missing work item or out-of-range business value can be an McpException. Unexpected internal exceptions should usually be logged and allowed to produce generic tool errors.
When should I use McpProtocolException instead?
Use McpProtocolException for protocol-level errors, not ordinary business validation. The SDK treats it as a JSON-RPC protocol error. For most application-level validation failures, a tool error result with IsError = true is easier for the model to understand and recover from.
Can MCP tools in C# use dependency injection?
Yes. Parameters that can be resolved from the server’s IServiceProvider are bound from DI and excluded from the generated input schema. This lets you expose model-provided arguments while keeping trusted services, repositories, loggers, and policies inside your application boundary.
Do tool list-changed notifications work everywhere?
No. Tool list-changed notifications require a connection shape that can send unsolicited notifications, such as stdio or stateful sessions. Stateless HTTP servers cannot push unsolicited tool-list updates, so dynamic tools need careful transport and session design.
Conclusion
Defining MCP tools in C# is mostly about contract design. The SDK gives you familiar building blocks: [McpServerToolType], [McpServerTool], [Description], DI, CancellationToken, content blocks, CallToolResult, McpException, and McpProtocolException. The hard part is deciding what your model-facing contract should look like.
Keep tools focused. Name them clearly. Describe parameters like the model has never seen your code because it has not. Validate inputs explicitly. Return the smallest useful result shape. Use tool errors for recoverable execution problems and protocol errors for actual protocol problems.
That combination gives you MCP tools in C# that are discoverable, safer to invoke, and easier to evolve as the SDK and protocol continue to move.

