Build an AI Agent with Tool Calling and MCP in TypeScript
Last updated 21 days ago · 3974546
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:
- Define your own tools — wrap any function with the
tool()helper (or a raw schema object for full control). - 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:
bun add reactive-agents# Node.js 22.5+: npm install reactive-agentsStep 1 — An agent with one custom tool
Section titled “Step 1 — An agent with one custom tool”The fastest way to define a tool is the tool() helper — no Effect or Schema knowledge required. Give it a name, a description (the model reads this to decide when to call it), and a plain async/sync handler that receives the validated args record and returns a value directly:
import { ReactiveAgents } from "reactive-agents";import { tool } from "@reactive-agents/tools";
const weatherTool = tool("get_weather", "Get the current weather for a city", { params: { city: { type: "string", required: true, description: "City name, e.g. 'Tokyo'" }, }, riskLevel: "low", timeoutMs: 10_000, handler: async (args) => { const city = String(args.city); const res = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=j1`); const data = (await res.json()) as { current_condition: Array<{ temp_C: string; weatherDesc: Array<{ value: string }> }>; }; const c = data.current_condition[0]; return `${city}: ${c.temp_C}°C, ${c.weatherDesc[0].value}`; },});
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. tool() wraps whatever your handler returns or throws into the Effect the runtime expects internally — you never see that layer.
For simpler tools, skip params/riskLevel/timeoutMs entirely: tool("greet", "Greet a user", async (args) => \Hello, ${args.name}!`)`.
Step 2 — The raw-schema tool form
Section titled “Step 2 — The raw-schema tool form”tool() is sugar over a plain ToolDefinition schema object plus an Effect-returning handler. If you are generating tools dynamically, need fine-grained control over the ToolDefinition shape, or want to write the handler as an Effect directly (for structured error channels, resource-safe cleanup, etc.), write the { definition, handler } shape yourself:
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").
Step 3 — Connect an MCP server
Section titled “Step 3 — Connect an MCP server”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 transport (local subprocess)
Section titled “stdio transport (local subprocess)”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 exitawait 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 tool() custom tools in the same agent. The model sees every tool uniformly and picks whichever it needs.
Skip the config — resolve a server by name
Section titled “Skip the config — resolve a server by name”For servers published to Docker Hub’s mcp/* catalog, skip the command/args entirely and pass the catalog name as a string:
const agent = await ReactiveAgents.create() .withProvider("anthropic") .withReasoning() .withMCP("brave-search", { env: { BRAVE_API_KEY: process.env.BRAVE_API_KEY! } }) .build();The first use of an unapproved image throws MCPApprovalRequiredError rather than silently running it — approve it once with approveMcpImage(), or pass requireApproval: false for environments you’ve already vetted. Full option reference (env, volumes, registry, requireApproval) and the approval flow are in the Tools guide’s Docker Hub MCP catalog section.
streamable-http with OAuth 2.1
Section titled “streamable-http with OAuth 2.1”headers above works for a pre-obtained token you manage yourself. If the server speaks OAuth 2.1 and
you want RA to handle discovery, token exchange, refresh, and storage, use auth instead. For an
unattended agent (no human present), use client_credentials:
const agent = await ReactiveAgents.create() .withProvider("anthropic") .withReasoning() .withMCP({ name: "billing", transport: "streamable-http", endpoint: "https://mcp.example.com/mcp", auth: { type: "client_credentials", clientId: process.env.MCP_CLIENT_ID!, clientSecret: process.env.MCP_CLIENT_SECRET!, }, }) .build();If the agent needs to act on behalf of a specific human, use authorization_code and run rax mcp login <name> once, ahead of time, to complete the interactive browser login — the agent itself never opens a
browser (interactive defaults to false for exactly this reason). Tokens land in
~/.reactive-agents/mcp-auth/ (permission-locked to your user) unless you pass your own tokenStore
(e.g. createMemoryTokenStore() for tests). Full grant-selection guidance, the token-store contract, and
the rax mcp login|logout|status command reference are in the Tools guide’s OAuth
section.
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 handler .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-infile-writetool 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 tightallowedToolslist 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.
Where to go next
Section titled “Where to go next”- 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.