Skip to content

McpServe.Build

C# · package Toolnexus · SPEC §7C · McpServe.cs

public static McpServer BuildMcpServer(
ITransport transport, IReadOnlyList<ITool> tools, MCPServeConfig? cfg = null, OnCall? onCall = null)

The inbound mirror of A2AServer.Start — but where A2A advertises skills and fulfils a Task through the whole client loop, this advertises the toolkit’s unified tools (every source: mcp · skill · native · http · builtin · a2a) and dispatches each tools/call straight to ITool.ExecuteAsync. There is no client, no Task, no TaskStore — the calling MCP client is the LLM host. Built on the official ModelContextProtocol SDK’s own server types (ITransport, McpServer); Toolkit.ServeAsync mounts the streamable-HTTP profile (POST /mcp) on top of it when opts.Mcp (or a top-level mcpServer config block) is present.

Turn your toolkit into a universal MCP gateway: aggregate N MCP servers, your skills, and your own native/HTTP tools behind one Toolkit, then re-expose the union as a single MCP server any MCP client (Claude Desktop, an IDE, another agent) can call. BuildMcpServer is the low-level constructor — call it directly when you have your own ITransport (e.g. an in-process pipe for tests, or your own hosting shell); most apps go through Toolkit.ServeAsync(addr, new Toolkit.ServeOptions { Mcp = ... }), which wires this to the shared streamable-HTTP endpoint.

1. The smallest useful call — an in-process transport pair

Section titled “1. The smallest useful call — an in-process transport pair”
using System.IO.Pipelines;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Toolnexus;
var echo = NativeTool.Of("echo", "echo back text",
new Dictionary<string, object?>
{
["type"] = "object",
["properties"] = new Dictionary<string, object?> { ["text"] = new Dictionary<string, object?> { ["type"] = "string" } },
["required"] = new List<object?> { "text" },
},
(IDictionary<string, object?> a) => a.TryGetValue("text", out var v) ? v?.ToString() ?? "" : "");
var clientToServer = new Pipe();
var serverToClient = new Pipe();
var serverTransport = new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream());
var server = McpServe.BuildMcpServer(serverTransport, new List<ITool> { echo }, new MCPServeConfig { Name = "gateway" });
var cts = new CancellationTokenSource();
var run = server.RunAsync(cts.Token);
var clientTransport = new StreamClientTransport(clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream());
var client = await McpClient.CreateAsync(clientTransport);
if (client.ServerInfo.Name != "gateway") throw new Exception(client.ServerInfo.Name);
var tools = await client.ListToolsAsync();
if (!tools.Any(t => t.Name == "echo")) throw new Exception("expected echo in tools/list");
await client.DisposeAsync();
cts.Cancel();
await server.DisposeAsync();
try { await run; } catch { }
Console.WriteLine($"ok: {client.ServerInfo.Name} advertises {string.Join(",", tools.Select(t => t.Name))}");

2. The realistic case — tools/call dispatches straight to ExecuteAsync

Section titled “2. The realistic case — tools/call dispatches straight to ExecuteAsync”
using System.IO.Pipelines;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Toolnexus;
var echo = NativeTool.Of("echo", "echo back text",
new Dictionary<string, object?>
{
["type"] = "object",
["properties"] = new Dictionary<string, object?> { ["text"] = new Dictionary<string, object?> { ["type"] = "string" } },
["required"] = new List<object?> { "text" },
},
(IDictionary<string, object?> a) => a.TryGetValue("text", out var v) ? v?.ToString() ?? "" : "");
var boom = NativeTool.Of("boom", "always throws", null,
(IDictionary<string, object?> _) => throw new Exception("kaboom"));
var calls = new List<OnCallEvent>();
var clientToServer = new Pipe();
var serverToClient = new Pipe();
var serverTransport = new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream());
var server = McpServe.BuildMcpServer(serverTransport, new List<ITool> { echo, boom }, null,
ev => { calls.Add(ev); return Task.CompletedTask; });
var cts = new CancellationTokenSource();
var run = server.RunAsync(cts.Token);
var client = await McpClient.CreateAsync(
new StreamClientTransport(clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream()));
var ok = await client.CallToolAsync("echo", new Dictionary<string, object?> { ["text"] = "hi" });
if (ok.IsError == true) throw new Exception("expected success");
if (((TextContentBlock)ok.Content[0]).Text != "hi") throw new Exception("wrong echo");
// A throwing tool becomes an isError result — never crashes the server.
var bad = await client.CallToolAsync("boom", new Dictionary<string, object?>());
if (bad.IsError != true) throw new Exception("expected isError");
if (!((TextContentBlock)bad.Content[0]).Text.Contains("kaboom")) throw new Exception("expected kaboom in output");
if (calls.Count != 2) throw new Exception($"expected 2 onCall events, got {calls.Count}");
await client.DisposeAsync();
cts.Cancel();
await server.DisposeAsync();
try { await run; } catch { }
Console.WriteLine($"ok: echo succeeded, boom isError=true, onCall saw {calls.Count} calls");

3. Full surface — the real streamable-HTTP endpoint via Toolkit.ServeAsync

Section titled “3. Full surface — the real streamable-HTTP endpoint via Toolkit.ServeAsync”
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using Toolnexus;
var echo = NativeTool.Of("echo", "echo back text",
new Dictionary<string, object?>
{
["type"] = "object",
["properties"] = new Dictionary<string, object?> { ["text"] = new Dictionary<string, object?> { ["type"] = "string" } },
["required"] = new List<object?> { "text" },
},
(IDictionary<string, object?> a) => a.TryGetValue("text", out var v) ? v?.ToString() ?? "" : "");
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options
{
Builtins = false,
ExtraTools = new List<ITool> { echo },
});
// mcp.Tools narrows the advertised surface — even though the toolkit only has one tool here,
// this is the filter a real gateway would use to expose a subset of a much larger toolkit.
var srv = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions
{
Mcp = new MCPServeConfig { Name = "http-gateway", Tools = new[] { "echo" } },
});
var client = await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(srv.Url + "/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
}));
if (client.ServerInfo.Name != "http-gateway") throw new Exception(client.ServerInfo.Name);
var res = await client.CallToolAsync("echo", new Dictionary<string, object?> { ["text"] = "over http" });
var text = ((TextContentBlock)res.Content[0]).Text;
if (text != "over http") throw new Exception(text);
await client.DisposeAsync();
await srv.StopAsync();
Console.WriteLine($"ok: {client.ServerInfo.Name} -> {text}");
Parameter Type What it is
transport ITransport The SDK transport to serve over — an in-process stream pair for tests, or the streamable-HTTP transport Toolkit.ServeAsync builds per request.
tools IReadOnlyList<ITool> The tools to expose. Use McpServe.ExposedMcpTools(tk.Tools(), cfg) to apply cfg.Tools filtering first.
cfg MCPServeConfig? Name/Version for initialize; Tools narrows tools/list (unknown names ignored, never an error).
onCall OnCall? Fires per inbound tools/call with {name, source, ms, isError}. Host callback errors are isolated.
  • A2AServer.Start — Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent.
  • A2AServer.BuildAgentCard — Construct the Agent Card that advertises your name, skills and endpoint.
  • A2AServer.FileTaskStore — Persist inbound A2A tasks so a suspended request survives a restart.