Skip to content

Build an AI Agent with Tool Calling and MCP in TypeScript

Tools are how an AI agent stops talking and starts acting — searching the web, reading files, hitting an API, querying a database. A language model on its own can only produce text; tool calling (a.k.a. function calling) is what lets it choose an action, hand you structured arguments, and use the real result to decide what to do next.

In Reactive Agents there are two ways to give a TypeScript agent tools, and you can mix them freely in one agent:

  1. Define your own tools — wrap any function with the ToolBuilder fluent API (or a raw schema object).
  2. Plug in MCP servers — connect any Model Context Protocol server (filesystem, GitHub, Stripe, a database, your own) and its tools appear in the agent’s registry automatically.

This guide walks through both, end to end. Install first:

Terminal window
bun add reactive-agents
# Node.js 22.5+: npm install reactive-agents

The fastest way to define a tool is ToolBuilder. You give it a name, a description (the model reads this to decide when to call it), typed parameters, and a handler that returns an Effect. Pass the finished tool to .withTools({ tools: [...] }).

import { ReactiveAgents } from "reactive-agents";
import { ToolBuilder } from "@reactive-agents/tools";
import { Effect } from "effect";
const weatherTool = ToolBuilder.create("get_weather")
.description("Get the current weather for a city")
.param("city", "string", "City name, e.g. 'Tokyo'", { required: true })
.riskLevel("low")
.timeout(10_000)
.handler((args) =>
Effect.tryPromise(async () => {
const res = await fetch(
`https://wttr.in/${encodeURIComponent(String(args.city))}?format=j1`,
);
const data = await res.json();
const c = data.current_condition[0];
return `${args.city}: ${c.temp_C}°C, ${c.weatherDesc[0].value}`;
}),
)
.build();
const agent = await ReactiveAgents.create()
.withProvider("anthropic")
.withModel("claude-sonnet-4-6")
.withReasoning() // enables the Think → Act → Observe (ReAct) loop
.withTools({ tools: [weatherTool] })
.build();
const result = await agent.run("What should I wear in Tokyo today?");
console.log(result.output);

What happens under the hood: .withReasoning() turns on the ReAct loop. The model sees get_weather in its tool list, emits a structured tool_use block with { city: "Tokyo" }, the framework validates the arguments against your schema, runs your handler in a sandbox, feeds the real result back as a tool_result, and the model writes its final answer.

The handler returns an Effect<string>. Use Effect.succeed(...) for pure values, Effect.try(...) for synchronous code that can throw, and Effect.tryPromise(...) for async work — errors are caught and surfaced to the agent as an observation instead of crashing the run.

ToolBuilder is sugar over a plain { definition, handler } object. If you are generating tools dynamically or prefer explicit schemas, pass that shape directly:

const agent = await ReactiveAgents.create()
.withProvider("anthropic")
.withReasoning()
.withTools({
tools: [
{
definition: {
name: "get_weather",
description: "Get the current weather for a city",
parameters: [
{ name: "city", type: "string", description: "City name", required: true },
],
riskLevel: "low",
timeoutMs: 10_000,
requiresApproval: false,
source: "function",
},
handler: (args) => Effect.succeed(`Weather for ${args.city}`),
},
],
})
.build();

Both forms produce the same registered tool. You can also register tools on a running agent with await agent.registerTool(definition, handler) and remove them with await agent.unregisterTool("name").

The Model Context Protocol is a standard for exposing tools to AI agents, with thousands of public servers covering filesystems, GitHub, browsers, databases, and SaaS APIs. Use .withMCP() per server — its tools are discovered at build time, prefixed with {serverName}/, and dropped into the same registry as your custom tools.

stdio launches a server as a child process and talks JSON-RPC over stdin/stdout. This is the right transport for npm packages, Docker images, and local scripts. Here is the official filesystem server scoped to the current directory:

// `await using` auto-disposes the agent (and shuts the subprocess down) on scope exit
await using agent = await ReactiveAgents.create()
.withProvider("anthropic")
.withModel("claude-sonnet-4-6")
.withReasoning()
.withMCP({
name: "filesystem",
transport: "stdio",
command: "bunx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "."],
})
.build();
const result = await agent.run(
"List the TypeScript files in this folder and summarize what each does.",
);
console.log(result.output);

streamable-http transport (remote / cloud)

Section titled “streamable-http transport (remote / cloud)”

For modern hosted MCP servers, use streamable-http with an endpoint and optional auth headers. Session handling and cleanup are automatic:

const agent = await ReactiveAgents.create()
.withProvider("anthropic")
.withReasoning()
.withMCP({
name: "stripe",
transport: "streamable-http",
endpoint: "https://mcp.stripe.com",
headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
})
.build();

You can pass an array to .withMCP([...]), or chain .withMCP() multiple times, to connect several servers at once — and combine them with ToolBuilder custom tools in the same agent. The model sees every tool uniformly and picks whichever it needs.

Step 4 — Adaptive tool calling on local and frontier models

Section titled “Step 4 — Adaptive tool calling on local and frontier models”

Not every model speaks the same function-calling dialect. Frontier APIs (Anthropic, OpenAI, Gemini) expose native structured tool_use/tool_calls; many local models only produce tool calls as text. Reactive Agents probes the active model’s dialect and routes to either a native function-calling driver or a text-parsing driver (XML / JSON / pseudo-code) — so the exact same agent code runs against a frontier API or a 4B+ Ollama model with no changes:

const localAgent = await ReactiveAgents.create()
.withProvider("ollama")
.withModel("qwen3:4b")
.withReasoning()
.withTools({ tools: [weatherTool] }) // same tool, same builder
.build();
const result = await localAgent.run("What's the weather in Tokyo?");

Swap .withProvider("ollama") for .withProvider("anthropic") and the tool, the handler, and the loop are identical. This is what makes the framework model-agnostic for tool use.

  • Risk levels and approval. Set .riskLevel("high") and .requiresApproval(true) on destructive tools (file writes, payments, deletes). When approval is required, the agent pauses for a human decision before the handler runs. The built-in file-write tool already requires approval by default.
  • Prevent runaway loops. Parallel tool calls are capped at 3 simultaneous executions and 3 chained steps per phase, and side-effect tools (create_*, delete_*, send_*, …) are forced to run one at a time — so a confused model can’t fan out destructively.
  • Force critical tools. Use .withRequiredTools({ tools: ["get_weather"], adaptive: true, maxRetries: 2 }) to guarantee a tool is called before the agent is allowed to answer.
  • Scope the surface. .withTools({ allowedTools: [...] }) is a hard allowlist (everything else is pruned before the model sees it); .withTools({ focusedTools: [...] }) is soft guidance that highlights tools without blocking the rest. A tight allowedTools list also helps smaller models pick the right tool.
  • Tool timeouts and big results. Every tool runs in a sandbox with a timeout (default 30s, set via .timeout(ms)). Large tool outputs are auto-compressed into a structured preview and stored, so a 31K-character API response won’t blow up the context window.
  • Tools guide — built-in tools, the Conductor’s Suite meta-tools, all four MCP transports, Docker-based servers, and result compression in depth.
  • Quickstart — build and run your first agent in five minutes.
  • Choosing strategies — ReAct vs Plan-Execute vs Reflexion for tool-heavy work.