Files
awesome-copilot/skills/dotnet-mcp-builder/references/tool-primitive.md
T
Adrien Clerbois aa01464ccb Update dotnet-mcp-builder skill to ModelContextProtocol 2.x (#2487)
* Update dotnet-mcp-builder skill to ModelContextProtocol 2.x

Align the skill with the C# SDK 2.0.0 release and the MCP 2026-07-28
spec: stable line is now 2.x, HttpServerTransportOptions.Stateless
defaults to true, roots/sampling/MCP-channel logging are [Obsolete]
(MCP9005) with the multi-round-trip input_required pattern as the
replacement, discovery-first negotiation (server/discover) supersedes
the initialize handshake, Mcp-Method/Mcp-Name routable headers, raw
structuredContent for non-object results, required Tool.inputSchema,
and the new ModelContextProtocol.Extensions.Tasks and
ModelContextProtocol.Extensions.Apps packages (typed MCP Apps support
replacing the hand-rolled _meta/ui:// pattern on 1.x).

* Address Copilot review: Apps extension accuracy, header scope, capability ownership

- packages.md: the Apps package replaces the manual _meta wiring, not the
  ui:// resource; note the experimental MCPEXP003 diagnostic; label the
  1.x -> 2.0 list as highlights and add the OAuth/SSE runtime changes
  with a pointer to the full release notes.
- transport-http.md: Mcp-Method is on every POST, Mcp-Name only on named
  invocations (tools/call, prompts/get, resources/read) - do not require
  it globally at gateways.
- mcp-apps.md: current MIME type is text/html;profile=mcp-app (skybridge
  is a legacy draft value); document [McpAppUi] + WithMcpApps().
- server-features.md: roots/sampling are client capabilities, only
  logging sits on ServerCapabilities.

* Correct stateful HTTP guidance: 2026-07-28 has no HTTP sessions

Per the official SDK v2 elicitation docs, a server with Stateless=false
refuses the 2026-07-28 revision so dual-path clients fall back to an
initialize-capable revision; ElicitAsync cannot be used on 2026-07-28
Streamable HTTP at all. Reframe stateful HTTP as down-level
compatibility mode and document the multi-round-trip pattern
(InputRequiredException / InputRequest.ForElicitation, retry with
InputResponses -> ElicitResult) as the current-protocol way to ask
mid-tool, across SKILL.md, transport-http.md, and elicitation.md.
2026-07-31 10:31:14 +10:00

8.4 KiB

Tools

Tools are functions the LLM can call. In the C# SDK they're plain methods on a class marked [McpServerToolType], with each method marked [McpServerTool]. The SDK generates the JSON Schema from the method signature and [Description] attributes.

Anatomy of a tool

using System.ComponentModel;
using ModelContextProtocol.Server;

[McpServerToolType]
public class WeatherTools
{
    // Static or instance — both work. Instance methods get DI for the containing class.
    [McpServerTool, Description("Returns the current weather for a city.")]
    public static string GetWeather(
        [Description("City name, e.g. 'Brussels'")] string city,
        [Description("Units: 'celsius' or 'fahrenheit'")] string units = "celsius")
    {
        return $"{city}: 18°{units[0]}";
    }
}

Register it (one of):

.WithToolsFromAssembly()        // discovers all [McpServerToolType] in the calling assembly
.WithTools<WeatherTools>()      // explicit, single class

The tool name shown to the LLM is GetWeather (PascalCase converted to snake_case is not automatic — what you see is what you get unless you set Name explicitly).

Attribute options

[McpServerTool(
    Name = "get_weather",                 // override the tool name
    Title = "Get current weather",        // human-readable display name
    Destructive = false,                  // hint: tool modifies state irreversibly
    Idempotent = true,                    // hint: same args ⇒ same result
    OpenWorld = true,                     // hint: interacts with external systems
    ReadOnly = true                       // hint: doesn't mutate any state
)]
[Description("Returns the current weather for a city.")]
public static string GetWeather(...) { ... }

The behaviour hints (Destructive, Idempotent, OpenWorld, ReadOnly) are advisory — clients use them to decide things like auto-approval. They don't change runtime behaviour.

Async, cancellation, DI

[McpServerTool, Description("Fetches the latest commits for a repo.")]
public async Task<IEnumerable<Commit>> GetCommits(
    string owner,
    string repo,
    IGitHubClient github,                         // injected from DI
    CancellationToken cancellationToken)          // injected by the SDK
{
    return await github.GetCommitsAsync(owner, repo, cancellationToken);
}

The SDK recognises and special-cases these parameter types — they don't appear in the tool schema:

  • IMcpServer / McpServer — the current server (used for ElicitAsync, SampleAsync, RequestRootsAsync, sending notifications).
  • CancellationToken — propagated from the JSON-RPC request.
  • RequestContext<CallToolRequestParams> — full request context if you need it.
  • IServiceProvider — request-scoped service provider.
  • Anything resolvable from DI that the SDK can recognise as not a primitive payload.

Everything else is treated as a JSON-RPC argument and goes into the schema.

Return types

The SDK serialises whatever you return into the appropriate content blocks. Practical guidance:

Return type What the LLM sees
string Single text content block.
int, bool, double, etc. Stringified into a text content block.
Any DTO (record/class) Serialized to JSON in a text content block, plus structured content for clients that support it.
IEnumerable<T> of DTOs JSON array.
ContentBlock / ImageContentBlock / AudioContentBlock / EmbeddedResourceBlock That single block, untouched.
IEnumerable<ContentBlock> Multiple blocks in order.
CallToolResult Full control — set Content, StructuredContent, IsError.

Returning structured data the LLM can act on

public record Forecast(string City, double TempC, string Conditions);

[McpServerTool, Description("Returns a 3-day forecast.")]
public static Forecast[] GetForecast(string city) =>
    new[]
    {
        new Forecast(city, 18.0, "sunny"),
        new Forecast(city, 16.5, "cloudy"),
        new Forecast(city, 14.2, "rain"),
    };

The SDK emits the array as both a JSON text block (for older clients) and structuredContent (for newer ones), and infers an output schema from Forecast.

v2 behavior change: non-object results are emitted as raw structuredContent values — returning 72 produces "structuredContent": 72, where 1.x wrapped it as { "result": 72 }. Clients reading structured output should follow the advertised output schema. If you hand-write Tool definitions (rather than using attributes), note that inputSchema is required on deserialization in 2.x — an empty {} is sufficient.

Returning images / audio

[McpServerTool, Description("Generates a chart and returns it as a PNG.")]
public static ImageContentBlock RenderChart(string title)
{
    byte[] png = Renderer.Render(title);
    return ImageContentBlock.FromBytes(png, "image/png");
}

[McpServerTool, Description("Synthesises speech.")]
public static AudioContentBlock Speak(string text)
{
    byte[] wav = Tts.Synthesize(text);
    return AudioContentBlock.FromBytes(wav, "audio/wav");
}

Mixing content blocks

[McpServerTool, Description("Returns the chart and a caption.")]
public static IEnumerable<ContentBlock> RenderAnnotatedChart(string title)
{
    byte[] png = Renderer.Render(title);
    return new ContentBlock[]
    {
        new TextContentBlock { Text = $"Chart for: {title}" },
        ImageContentBlock.FromBytes(png, "image/png"),
        new TextContentBlock { Text = "Generated at " + DateTime.UtcNow.ToString("u") }
    };
}

Returning an embedded resource

Useful when the tool result is a document the user might want to reuse:

[McpServerTool, Description("Looks up a contract.")]
public static EmbeddedResourceBlock GetContract(string id)
{
    return new EmbeddedResourceBlock
    {
        Resource = new TextResourceContents
        {
            Uri = $"contracts://{id}",
            MimeType = "text/markdown",
            Text = LoadContract(id)
        }
    };
}

Errors

There are two flavours of error a tool can produce:

Tool-level errors (the LLM can read and recover from these)

Throw any exception — the SDK catches it and returns a CallToolResult with IsError = true and the exception message in a text block:

[McpServerTool, Description("Divides a by b.")]
public static double Divide(double a, double b)
{
    if (b == 0)
        throw new ArgumentException("Cannot divide by zero.");
    return a / b;
}

You can also build the result explicitly:

[McpServerTool, Description("…")]
public static CallToolResult Foo(...)
{
    return new CallToolResult
    {
        IsError = true,
        Content = [new TextContentBlock { Text = "Detailed error explanation for the LLM." }]
    };
}

Protocol-level errors (the call is rejected before the LLM sees a result)

Use McpException (or McpProtocolException with an explicit error code) for things like bad arguments:

[McpServerTool, Description("…")]
public static string Process(string input)
{
    if (string.IsNullOrWhiteSpace(input))
        throw new McpProtocolException("Missing required input", McpErrorCode.InvalidParams);
    return $"Processed: {input}";
}

Heuristic: if the LLM should try again with different arguments, throw a regular exception so it gets a tool error. If the call is malformed in a way the LLM can't fix, throw McpProtocolException.

Notifying clients of tool list changes

If your tools come and go at runtime (e.g. plugin loaded, user logged in), notify the client:

await server.SendNotificationAsync(
    NotificationMethods.ToolListChangedNotification,
    new ToolListChangedNotificationParams(),
    cancellationToken);

Requires a stateful transport (STDIO or stateful HTTP).

Common pitfalls

  • Forgetting [McpServerToolType] on the class. The method-level [McpServerTool] alone won't be discovered by WithToolsFromAssembly.
  • Vague descriptions. [Description("Gets data")] makes the LLM guess. Spend a sentence describing what the tool does, when to call it, and what it returns.
  • Big payloads. Tools that return megabytes of JSON eat the model's context. Trim or paginate. For binary blobs, return an EmbeddedResourceBlock so the host can decide how to render it.
  • Hiding errors. Returning "failed" as a string looks like success to the SDK. Throw the exception or set IsError = true.