Skip to content

httpTool

JavaScript · package toolnexus · SPEC §7 · js/src/http.ts

function httpTool(opts: {
name: string
description: string
method: string
url: string // may contain {placeholders} filled from args
headers?: Record<string, string> // values expand ${ENV_VAR} at call time
query?: string[] // arg names sent as querystring
body?: "json" | "form" | "raw"
inputSchema?: JSONSchema
timeout?: number // ms, default 30000
resultMode?: "text" | "json" | "status+text"
}): Tool

Declares a REST endpoint as a Tool, with no client code at all. The declaration decides where each argument goes — into the path, the querystring, or the body — and the returned tool does the fetch, the timeout, the cancellation and the error mapping.

  • An internal service already has the capability and you just want the model to reach it.
  • A third-party REST API you would otherwise have to wrap in an MCP server.
  • Anywhere the auth is a header and the header value belongs in the environment, not in code.

For a whole remote toolset rather than one endpoint, a remote MCP server (loadMcp) or an A2A agent is the better fit — those advertise their own tools instead of you declaring each one.

All three examples run against a throwaway node:http server on an ephemeral port, so they are fully hermetic — no public URL is ever contacted.

With method: "GET", every remaining argument becomes a querystring parameter — you do not need query at all.

import assert from "node:assert"
import http from "node:http"
import { httpTool } from "toolnexus"
// --- a local stand-in for the real API ---
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ path: req.url, method: req.method }))
})
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()))
const port = (server.address() as { port: number }).port
const search = httpTool({
name: "search_docs",
description: "Search the documentation",
method: "GET",
url: `http://127.0.0.1:${port}/search`,
inputSchema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] },
})
// It is an ordinary Tool, tagged with the http source.
assert.equal(search.name, "search_docs")
assert.equal(search.source, "http")
const res = await search.execute({ q: "adapters" })
assert.equal(res.isError, false)
assert.equal(JSON.parse(res.output).path, "/search?q=adapters")
// The HTTP status always comes back as metadata.
assert.equal(res.metadata?.status, 200)
server.close()
console.log("ok:", JSON.parse(res.output).path)

2. Path placeholders, a JSON body, and an ${ENV} auth header

Section titled “2. Path placeholders, a JSON body, and an ${ENV} auth header”

A {name} in the URL is filled from the matching argument and consumed — it is not repeated in the query or the body. On a non-GET request, whatever arguments are left become the body.

import assert from "node:assert"
import http from "node:http"
import { httpTool } from "toolnexus"
const seen: { url?: string; body: string; auth?: string; ct?: string }[] = []
const server = http.createServer((req, res) => {
let body = ""
req.on("data", (c) => (body += c))
req.on("end", () => {
seen.push({
url: req.url,
body,
auth: req.headers.authorization,
ct: req.headers["content-type"],
})
res.writeHead(201, { "Content-Type": "application/json" })
res.end(JSON.stringify({ created: true }))
})
})
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()))
const port = (server.address() as { port: number }).port
// Secrets are read from the environment at CALL time and never logged.
process.env.DOCS_API_TOKEN = "YOUR_KEY_HERE"
const comment = httpTool({
name: "add_comment",
description: "Comment on an issue",
method: "POST",
url: `http://127.0.0.1:${port}/issues/{issue}/comments`,
headers: { Authorization: "Bearer ${DOCS_API_TOKEN}" },
// Explicitly routed to the querystring instead of the body.
query: ["notify"],
inputSchema: {
type: "object",
properties: {
issue: { type: "string" },
notify: { type: "boolean" },
text: { type: "string" },
},
required: ["issue", "text"],
},
})
const res = await comment.execute({ issue: "42", notify: true, text: "looks good" })
assert.equal(res.isError, false)
assert.equal(res.metadata?.status, 201)
const call = seen[0]
// `issue` went into the PATH, `notify` into the QUERY, `text` into the BODY.
assert.equal(call.url, "/issues/42/comments?notify=true")
assert.deepEqual(JSON.parse(call.body), { text: "looks good" })
assert.equal(call.ct, "application/json")
// ${DOCS_API_TOKEN} expanded from process.env.
assert.equal(call.auth, "Bearer YOUR_KEY_HERE")
server.close()
console.log("ok:", call.url)

3. The full surface — result modes, HTTP errors, timeouts and cancellation

Section titled “3. The full surface — result modes, HTTP errors, timeouts and cancellation”

A non-2xx response is a tool error, not a thrown exception: the model reads the status and body and can react. Timeouts and ctx.signal both abort the request and are reported the same way.

import assert from "node:assert"
import http from "node:http"
import { httpTool } from "toolnexus"
const server = http.createServer((req, res) => {
if (req.url === "/boom") {
res.writeHead(422, { "Content-Type": "text/plain" })
res.end("invalid payload")
return
}
if (req.url === "/slow") {
setTimeout(() => {
res.writeHead(200)
res.end("late")
}, 2000).unref()
return
}
res.writeHead(200, { "Content-Type": "application/json" })
res.end('{"ok":true}')
})
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()))
const port = (server.address() as { port: number }).port
const base = `http://127.0.0.1:${port}`
// resultMode: "status+text" prefixes the body with the status code.
const status = httpTool({
name: "with_status",
description: "Fetch and report the status",
method: "GET",
url: `${base}/ok`,
resultMode: "status+text",
})
assert.equal((await status.execute({})).output, '200\n{"ok":true}')
// A non-2xx is isError: true, with the status preserved in metadata.
const boom = httpTool({ name: "boom", description: "Always fails", method: "GET", url: `${base}/boom` })
const failed = await boom.execute({})
assert.equal(failed.isError, true)
assert.equal(failed.output, "HTTP 422: invalid payload")
assert.equal(failed.metadata?.status, 422)
// timeout (ms) bounds the request; ctx.timeout overrides the declared one.
const slow = httpTool({
name: "slow",
description: "Never answers in time",
method: "GET",
url: `${base}/slow`,
timeout: 60_000,
})
const timedOut = await slow.execute({}, { timeout: 100 })
assert.equal(timedOut.isError, true)
assert.ok(timedOut.output.length > 0) // the abort reason, e.g. "This operation was aborted"
// The loop's cancellation signal aborts an in-flight request too.
const ac = new AbortController()
const inflight = slow.execute({}, { signal: ac.signal })
ac.abort()
const cancelled = await inflight
assert.equal(cancelled.isError, true)
server.close()
console.log("ok:", failed.output, "| timeout + cancel both reported as tool errors")
Option Type Default What it does
name string The tool name the model calls.
description string What the model reads to decide whether to call it.
method string Any HTTP verb; upper-cased for you.
url string Target. {arg} placeholders are URL-encoded from args and consumed.
headers Record<string, string> {} Values expand ${ENV_VAR} from process.env at call time. Unset ⇒ empty string.
query string[] [] Arg names forced into the querystring. Ignored for GET, where all leftover args go there anyway.
body "json" | "form" | "raw" "json" Encoding for leftover args on a non-GET/HEAD request. raw sends the body arg as a plain string.
inputSchema JSONSchema empty object schema What the model sees. Not enforced at runtime.
timeout number 30000 Milliseconds. ctx.timeout wins when the loop supplies one.
resultMode "text" | "json" | "status+text" "text" text = raw body; json = parse then re-stringify (invalid JSON falls back to the text); status+text = `${status}\n${body}`.

Where an argument goes, in order:

  1. It matches a {placeholder} in url → substituted there and removed.
  2. It is listed in query, or the method is GET → querystring.
  3. Otherwise → the request body, encoded per body.

Every outcome carries metadata.status except a transport failure (timeout, abort, connection refused), which has no status to report.

  • defineTool — when the call needs real logic
  • Tool — what this builds
  • loadMcp — a whole remote toolset instead of one endpoint
  • createToolkit — pass these in via extraTools