HttpTool.Of
C# · package Toolnexus · SPEC §7 · HttpTool.cs
public static HttpTool Of(HttpTool.Options opts)Declares an HTTP endpoint as an ITool with Source set to "http".
The model’s arguments become URL placeholders, query parameters or a request body according to the
options, the response body becomes the tool output, and a non-2xx status becomes a tool error.
No HttpClient of your own, no request-building code, no per-endpoint wrapper class — the
endpoint becomes a tool by description.
When to use it
Section titled “When to use it”When the capability you want to expose is already a REST call: an internal microservice, a public
JSON API, a webhook, an admin endpoint. Declaring it beats writing a NativeTool around
HttpClient, because argument routing and error mapping are handled for you.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. A GET endpoint, arguments as query parameters
Section titled “1. A GET endpoint, arguments as query parameters”For GET (and HEAD), every leftover argument goes to the query string — you do not need to
list them in Query.
using System.Net;using System.Net.Sockets;using System.Text;using Toolnexus;
static int FreePort(){ var probe = new TcpListener(IPAddress.Loopback, 0); probe.Start(); var p = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); return p;}
// A throwaway loopback server so this example is hermetic — no public network.var port = FreePort();var server = new HttpListener();server.Prefixes.Add($"http://127.0.0.1:{port}/");server.Start();
var serving = Task.Run(async () =>{ var c = await server.GetContextAsync(); var body = Encoding.UTF8.GetBytes($$"""{"seen":"{{c.Request.Url!.PathAndQuery}}"}"""); c.Response.StatusCode = 200; c.Response.ContentType = "application/json"; c.Response.ContentLength64 = body.Length; await c.Response.OutputStream.WriteAsync(body); c.Response.Close();});
try{ var search = HttpTool.Of(new HttpTool.Options { Name = "search_docs", Description = "Search the documentation index", Method = "GET", Url = $"http://127.0.0.1:{port}/search", InputSchema = new Dictionary<string, object?> { ["type"] = "object", ["properties"] = new Dictionary<string, object?> { ["q"] = new Dictionary<string, object?> { ["type"] = "string" } }, ["required"] = new[] { "q" }, }, });
if (search.Source != "http") throw new Exception(search.Source);
var res = await search.ExecuteAsync(new Dictionary<string, object?> { ["q"] = "http tool" }); if (res.IsError) throw new Exception(res.Output); // Values are URL-encoded on the way out. if (!res.Output.Contains("/search?q=http%20tool")) throw new Exception(res.Output);
// Metadata always carries the HTTP status. if (Convert.ToInt32(res.Metadata?["status"]) != 200) throw new Exception("status metadata");
await serving; Console.WriteLine($"ok: {res.Output}");}finally{ server.Stop();}2. A POST with a URL placeholder, a JSON body and an ${ENV} header
Section titled “2. A POST with a URL placeholder, a JSON body and an ${ENV} header”{placeholder} segments in the URL are filled from the arguments and consumed — they do not
also appear in the body. Whatever is left becomes the JSON body.
using System.Net;using System.Net.Sockets;using System.Text;using Toolnexus;
static int FreePort(){ var probe = new TcpListener(IPAddress.Loopback, 0); probe.Start(); var p = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); return p;}
var port = FreePort();var server = new HttpListener();server.Prefixes.Add($"http://127.0.0.1:{port}/");server.Start();
string seenPath = "", seenAuth = "", seenBody = "", seenType = "";var serving = Task.Run(async () =>{ var c = await server.GetContextAsync(); seenPath = c.Request.Url!.PathAndQuery; seenAuth = c.Request.Headers["Authorization"] ?? ""; seenType = c.Request.ContentType ?? ""; using (var reader = new StreamReader(c.Request.InputStream, Encoding.UTF8)) seenBody = await reader.ReadToEndAsync();
var body = Encoding.UTF8.GetBytes("""{"ok":true}"""); c.Response.StatusCode = 201; c.Response.ContentLength64 = body.Length; await c.Response.OutputStream.WriteAsync(body); c.Response.Close();});
// The token is read from the environment at CALL time and never appears in the tool// definition. YOUR_KEY_HERE is an obvious placeholder, not a credential.Environment.SetEnvironmentVariable("DOCS_DEMO_TOKEN", "YOUR_KEY_HERE");
try{ var comment = HttpTool.Of(new HttpTool.Options { Name = "add_comment", Description = "Add a comment to an issue", Method = "POST", Url = $"http://127.0.0.1:{port}/issues/{{issue}}/comments", Headers = new Dictionary<string, string> { ["Authorization"] = "Bearer ${DOCS_DEMO_TOKEN}" }, Body = "json", InputSchema = new Dictionary<string, object?> { ["type"] = "object", ["properties"] = new Dictionary<string, object?> { ["issue"] = new Dictionary<string, object?> { ["type"] = "string" }, ["text"] = new Dictionary<string, object?> { ["type"] = "string" }, }, ["required"] = new[] { "issue", "text" }, }, });
var res = await comment.ExecuteAsync(new Dictionary<string, object?> { ["issue"] = "42", ["text"] = "looks good", });
if (res.IsError) throw new Exception(res.Output); if (res.Output != """{"ok":true}""") throw new Exception(res.Output); if (Convert.ToInt32(res.Metadata?["status"]) != 201) throw new Exception("status");
await serving;
// `issue` was consumed by the URL; only `text` survived into the body. if (seenPath != "/issues/42/comments") throw new Exception(seenPath); if (seenBody != """{"text":"looks good"}""") throw new Exception(seenBody); if (seenType != "application/json") throw new Exception(seenType); // ${DOCS_DEMO_TOKEN} was expanded from the environment. if (seenAuth != "Bearer YOUR_KEY_HERE") throw new Exception("header expansion");
Console.WriteLine($"ok: {seenPath} <- {seenBody}");}finally{ server.Stop();}3. Failures, explicit Query on a POST, and ResultMode
Section titled “3. Failures, explicit Query on a POST, and ResultMode”A non-2xx response is a tool error whose output is HTTP <status>: <body> — the model reads
it and can correct itself. Network failures and timeouts land the same way.
using System.Net;using System.Net.Sockets;using System.Text;using Toolnexus;
static int FreePort(){ var probe = new TcpListener(IPAddress.Loopback, 0); probe.Start(); var p = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); return p;}
var port = FreePort();var server = new HttpListener();server.Prefixes.Add($"http://127.0.0.1:{port}/");server.Start();
var seenPaths = new List<string>();var seenBodies = new List<string>();var serving = Task.Run(async () =>{ for (var i = 0; i < 2; i++) { var c = await server.GetContextAsync(); seenPaths.Add(c.Request.Url!.PathAndQuery); using (var reader = new StreamReader(c.Request.InputStream, Encoding.UTF8)) seenBodies.Add(await reader.ReadToEndAsync());
var notFound = c.Request.Url!.AbsolutePath.StartsWith("/missing"); var body = Encoding.UTF8.GetBytes(notFound ? """{"error":"no such record"}""" : """{"id":9,"state":"queued"}"""); c.Response.StatusCode = notFound ? 404 : 200; c.Response.ContentLength64 = body.Length; await c.Response.OutputStream.WriteAsync(body); c.Response.Close(); }});
try{ // Query names are routed to the querystring even on a POST; the rest becomes the body. var enqueue = HttpTool.Of(new HttpTool.Options { Name = "enqueue_job", Description = "Enqueue a background job", Method = "POST", Url = $"http://127.0.0.1:{port}/jobs", Query = new List<string> { "priority" }, Body = "json", ResultMode = "status+text", Timeout = 5_000, });
var ok = await enqueue.ExecuteAsync(new Dictionary<string, object?> { ["priority"] = "high", ["kind"] = "reindex", }); if (ok.IsError) throw new Exception(ok.Output); // ResultMode "status+text" prefixes the status code and a newline. if (ok.Output != "200\n{\"id\":9,\"state\":\"queued\"}") throw new Exception(ok.Output);
var missing = HttpTool.Of(new HttpTool.Options { Name = "get_record", Description = "Fetch a record", Method = "GET", Url = $"http://127.0.0.1:{port}/missing", });
var bad = await missing.ExecuteAsync(new Dictionary<string, object?>()); if (!bad.IsError) throw new Exception("expected IsError"); if (bad.Output != """HTTP 404: {"error":"no such record"}""") throw new Exception(bad.Output); // The status is on the metadata of the error result too. if (Convert.ToInt32(bad.Metadata?["status"]) != 404) throw new Exception("status metadata");
await serving; if (seenPaths[0] != "/jobs?priority=high") throw new Exception(seenPaths[0]); if (seenBodies[0] != """{"kind":"reindex"}""") throw new Exception(seenBodies[0]);
Console.WriteLine($"ok: {seenPaths[0]} | error: {bad.Output}");}finally{ server.Stop();}Options
Section titled “Options”| Field | Type | Default | What it does |
|---|---|---|---|
Name |
string |
"" |
Tool name the model calls. Must match [a-zA-Z0-9_-]. |
Description |
string |
"" |
What the model reads to decide whether to call it. |
Method |
string |
"GET" |
Upper-cased internally, so "post" works. |
Url |
string |
"" |
May contain {placeholder} segments filled from the arguments. |
Headers |
IDictionary<string, string>? |
null |
Values expand ${ENV_VAR} at call time; never logged. |
Query |
List<string>? |
null |
Argument names routed to the querystring. Ignored for GET — everything goes there anyway. |
Body |
string? |
"json" |
"json", "form", or "raw" (sends the body argument verbatim). |
InputSchema |
IDictionary<string, object?>? |
empty object | The JSON Schema the model sees. |
Timeout |
long? |
30000 |
Milliseconds. Overridden by ToolContext.TimeoutMs. |
ResultMode |
string? |
text | "json" re-serializes the parsed body; "status+text" prefixes "<status>\n". |
Argument routing, in order: URL placeholders first, then query names (or everything, for GET), and
whatever remains becomes the body. GET and HEAD never send a body.
See also
Section titled “See also”NativeTool.Of— when the call needs logic around itMcpSource.LoadAsync— when the remote side already speaks MCPToolkit.CreateAsync— register the tool alongside every other sourceITool·ToolResult·ToolContext