Files
awesome-copilot/skills/dotnet-mcp-builder/references/testing.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

6.1 KiB

Testing and local debugging

Three workflows: interactive testing with MCP Inspector, in-process integration tests, and CI-friendly unit tests.

MCP Inspector (interactive)

MCP Inspector is the go-to tool for trying out a server by hand. It launches your server, connects via STDIO or HTTP, and gives you a UI to list/call tools, view resources, fire elicitations, see logs, and inspect raw JSON-RPC frames.

STDIO

npx @modelcontextprotocol/inspector dotnet run --project ./MyMcpServer

Pass env vars or args after --:

npx @modelcontextprotocol/inspector \
  dotnet run --project ./MyMcpServer -- \
  --some-flag value

HTTP

Start the server normally (dotnet run), then in Inspector pick "Streamable HTTP" and enter the URL (e.g. http://localhost:3001).

Use it for

  • Verifying tool descriptions are clear (Inspector renders them like the LLM would consume them).
  • Walking through elicitation flows without a real LLM.
  • Capturing the exact JSON-RPC payloads when filing bug reports.

The cleanest test setup uses InMemoryTransport (or the lower-level StreamServerTransport / StreamClientTransport) to wire a real server and a real client together in the same process. No subprocesses, no network.

using System.IO.Pipelines;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Xunit;

public class WeatherToolsTests
{
    [Fact]
    public async Task GetWeather_returns_text()
    {
        var clientToServer = new Pipe();
        var serverToClient = new Pipe();

        await using var server = McpServer.Create(
            new StreamServerTransport(
                clientToServer.Reader.AsStream(),
                serverToClient.Writer.AsStream()),
            new McpServerOptions
            {
                ToolCollection =
                [
                    McpServerTool.Create(
                        (string city) => $"{city}: 18°C",
                        new() { Name = "GetWeather" })
                ]
            });

        var serverTask = server.RunAsync();

        await using var client = await McpClient.CreateAsync(
            new StreamClientTransport(
                clientToServer.Writer.AsStream(),
                serverToClient.Reader.AsStream()));

        var tools = await client.ListToolsAsync();
        var tool = tools.Single(t => t.Name == "GetWeather");

        var result = await tool.CallAsync(new Dictionary<string, object?>
        {
            ["city"] = "Brussels"
        });

        Assert.False(result.IsError);
        var text = result.Content.OfType<TextContentBlock>().Single().Text;
        Assert.Equal("Brussels: 18°C", text);
    }
}

This style lets you assert on the exposed behaviour (what a real client sees), not internal details.

Testing tools that use sampling/elicitation/roots

(Sampling and roots are deprecated on 2.x — expect MCP9005 warnings in test projects that exercise them; suppress in the test csproj if you're deliberately covering legacy paths.)

Inject the MCP server, but supply mock client capabilities. With the in-memory pattern above, register handlers on the client:

await using var client = await McpClient.CreateAsync(clientTransport, new McpClientOptions
{
    Capabilities = new()
    {
        Sampling = new()
        {
            SamplingHandler = (req, progress, ct) =>
                Task.FromResult(new CreateMessageResult
                {
                    Content = [new TextContentBlock { Text = "MOCK SUMMARY" }]
                })
        },
        Elicitation = new()
        {
            ElicitationHandler = (req, ct) =>
                Task.FromResult(new ElicitResult
                {
                    Action = "accept",
                    Content = JsonSerializer.SerializeToNode(new { confirm = true })
                                .AsObject().ToDictionary(kv => kv.Key, kv => JsonDocument.Parse(kv.Value!.ToJsonString()).RootElement)
                })
        }
    }
});

Now your tool's server.SampleAsync / server.ElicitAsync calls hit deterministic mocks.

Unit tests at the DI layer

For pure logic with no MCP-specific behaviour, just test the class:

[Fact]
public void Echo_prepends_hello()
{
    Assert.Equal("hello world", EchoTool.Echo("world"));
}

The [McpServerTool] attribute doesn't affect runtime behaviour outside MCP wiring — your methods are just methods.

Running it from Claude Desktop / VS Code during development

For end-to-end "feels-like-the-real-thing" testing:

  1. Run dotnet publish -c Release (or just dotnet build and use dotnet run).
  2. Point Claude Desktop / VS Code at the binary or dotnet run --project .... See transport-stdio.md for the config snippets.
  3. Restart the host.
  4. Trigger the tool from chat.

When iterating, set up dotnet watch run --project ... so the server restarts on edit; the host typically reconnects on the next tool call.

CI

A typical CI pipeline:

- run: dotnet restore
- run: dotnet build --no-restore
- run: dotnet test --no-build --logger "trx;LogFileName=test-results.trx"

Nothing MCP-specific. The in-memory transport tests run anywhere dotnet test runs — no Node, no Docker.

Common diagnostic tricks

  • "Tool isn't showing up": call client.ListToolsAsync() in a quick test and dump the names. If your tool isn't there, the registration is wrong.
  • "LLM keeps misusing the tool": open Inspector and look at the schema/description as the LLM sees it. Most "the model is dumb" issues are actually missing [Description].
  • "Sampling/elicitation throws 'method not supported'": the client doesn't advertise the capability. Either you're testing against a host that doesn't support it (Inspector supports both), or your in-memory client is missing the handler.
  • "HTTP returns 404 for /": check app.MapMcp() is called and you're hitting the right path. MapMcp("/mcp") means the URL is http://host/mcp, not http://host/.