Give a Claude Agent SDK app web access

A hands-on walkthrough: wire Tabstack's ready-built in-process MCP server into a query() call, understand the mcp__tabstack__ namespacing, and give Claude web access with no separate server to run.

By Steve McDougall··8 min read
Give a Claude Agent SDK app web access

Every other integration in this series hands your framework an array or an object of tools. This one is different, because the Claude Agent SDK expects tools to arrive as an MCP server.

That sounds like more work, and I assumed it would be. It is actually less, because the package ships a server already built. You import it, name it in your query() options, and Claude has web access. There is no separate process to run and no MCP config file to maintain.

What you need

  • A Node project (the package ships dual ESM and CJS builds)
  • A Tabstack API key from console.tabstack.ai
  • An Anthropic API key
npm install @tabstack/claude-agent @anthropic-ai/claude-agent-sdk zod
export TABSTACK_API_KEY="your-tabstack-key"
export ANTHROPIC_API_KEY="your-anthropic-key"

One version constraint to note. The Claude Agent SDK depends on Zod 4, so this package's zod peer dependency is ^4.0.0. Unlike the eve and LlamaIndex adapters, Zod 3 is not an option here. If your app is still on Zod 3, that upgrade is a prerequisite rather than a nice-to-have.

Step 1: wire in the server

import { query } from "@anthropic-ai/claude-agent-sdk";
import { tabstackServer, tabstackAllowedTools, tabstackMcpServerName } from "@tabstack/claude-agent";
 
for await (const message of query({
  prompt: "What are Vercel's current pricing plans, with sources?",
  options: {
    mcpServers: { [tabstackMcpServerName]: tabstackServer },
    allowedTools: tabstackAllowedTools,
  },
})) {
  if (message.type === "result" && message.subtype === "success") {
    console.log(message.result);
  }
}

Three imports, and Claude can read the web.

Note the for await. query() returns a lazy async generator rather than a promise, so nothing runs until you iterate it. Writing await query({ ... }) typechecks fine and does absolutely nothing, which is a quiet half hour to lose.

tabstackServer is a ready-built in-process MCP server holding all five tools. In-process is the important word. It runs inside your Node process, so there is no subprocess to spawn, no port to bind, and no server lifecycle to manage. If your only previous experience of MCP was editing a JSON config and restarting something, this is a pleasant surprise.

tabstackAllowedTools pre-approves every Tabstack tool. The Claude Agent SDK gates tool use by permission, so without this the tools exist but Claude cannot call them. It is the line people forget, and the symptom is an agent that talks about wanting to look something up and then does not.

tabstackMcpServerName is the string "tabstack". Use the export rather than typing the literal, because the name becomes part of how Claude addresses each tool.

The server resolves TABSTACK_API_KEY lazily on first tool call, so importing the package never requires a key to be set.

Step 2: understand what Claude actually sees

Claude does not see research_question. It sees mcp__tabstack__research_question.

MCP tools are namespaced by their server, so every tool arrives fully qualified as mcp__tabstack__<tool_name>. This matters in two places. When you read a transcript and want to know which tool ran, that prefix is what you are scanning for. And when you write instructions referring to a tool by name, the unqualified name is what Claude maps onto, but the qualified name is what appears in permission prompts and logs.

Underneath the prefix, the names are the same everywhere:

Tool nameWhat it does
extract_structured_dataPull specific fields from a URL into a JSON shape you define
extract_page_contentFetch a page as clean markdown
research_questionSynthesised answer with cited sources across multiple pages
generate_structured_dataFetch a page, then AI-transform it into derived or reshaped JSON
automate_browser_taskRun a multi-step, natural language browser task

Those names are shared with the LangChain, Vercel AI SDK, eve, Mastra, and Python packages, so a prompt written for one carries over.

The distinction worth internalising is extract versus generate. Extract pulls out what is already written on the page. Generate fetches the page and produces something derived from it, like a category or a summary nobody wrote. If the field exists in the HTML, extract it. If you are asking for judgement, generate it.

Step 3: the inputs the model fills in

You will read these in transcripts:

  • extract_structured_data: url, json_schema_json, a JSON encoded JSON Schema string
  • extract_page_content: url
  • research_question: query
  • generate_structured_data: url, instructions, json_schema_json
  • automate_browser_task: task, plus optional url, guardrails, data, country, max_iterations, max_validation_attempts

Each tool's input schema is the core Zod schema's raw shape, which means the Claude Agent SDK validates Claude's arguments before the handler runs. Malformed tool calls fail immediately with a clear message rather than travelling to the API and coming back rejected.

There are optional inputs too, and each is accepted by a different subset of the tools:

  • effort, as "min", "standard", or "max", on the three fetch-based tools
  • nocache to bypass the cache, on the three fetch-based tools and research_question
  • country, an ISO 3166-1 alpha-2 code, for geotargeted fetches, on the three fetch-based tools and automate_browser_task
  • mode, as "fast" or "balanced", on research_question only

The first three are forwarded only when Claude supplies them. mode is the exception: it defaults to "balanced" and is always sent.

If a page comes back thin or empty, it is nearly always a client-rendered app and effort: "max" is the fix. Full rendering happens server side, so there is no Playwright in your project and no browser binary in your image. Put it in your system prompt and let Claude apply it:

If a page returns little or no content, retry once with effort set to max. Always cite the sources you used.

Step 4: custom keys, and assembling it yourself

For a key per tenant, a custom base URL, or reuse of an SDK client you already have:

import { query } from "@anthropic-ai/claude-agent-sdk";
import {
  createTabstackClaudeAgentServer,
  tabstackAllowedTools,
  tabstackMcpServerName,
} from "@tabstack/claude-agent";
 
const server = createTabstackClaudeAgentServer({ apiKey: process.env.MY_KEY });
 
for await (const message of query({
  prompt: "...",
  options: {
    mcpServers: { [tabstackMcpServerName]: server },
    allowedTools: tabstackAllowedTools,
  },
})) {
  if (message.type === "result" && message.subtype === "success") {
    console.log(message.result);
  }
}

If you would rather assemble the server yourself, perhaps because you want Tabstack's tools alongside your own in a single server, createTabstackClaudeAgentTools(config) returns the raw tool array to pass into your own createSdkMcpServer({ name, version, tools }). That is also the route to take when you want a subset. Build the array, keep the tools you need, and hand the rest of your own tools in beside them.

Step 5: failures

This adapter handles errors differently from the others, and the difference is deliberate.

When a tool call fails, the handler normalises it to a TabstackToolError with a clean message, plus an HTTP status for API errors, then returns it as an MCP error result with isError: true. What Claude sees is the message; the status stays on the error object for your own logging rather than travelling into the transcript. Either way Claude reads a useful message rather than a raw exception, which means it can retry with different arguments or explain the problem to the user. A dead link does not end the run.

That is the right default for an agent loop. You are not catching these in your own code in the common case, because the model is the one that needs to react.

One behaviour to know before shipping: automate_browser_task runs non-interactively. It never pauses for human-in-the-loop form input, so it cannot block a run waiting on someone. It returns the final answer plus the data it extracted and the pages it visited. If you genuinely need a human in the middle of a browser session, that is the interactive mode flow in the API rather than this tool.

Where to take it next

Trim allowedTools. tabstackAllowedTools approves all five, which is the fastest way to get started and, I would argue, rarely what you want in production. Narrowing it to the two or three your agent actually needs reduces both cost and the chance of Claude choosing a tool you did not intend.

Combine it with your own tools. Because this is an in-process MCP server, adding Tabstack does not preclude anything else. Your own createSdkMcpServer output and tabstackServer sit side by side under different names in mcpServers.

The package is on npm as @tabstack/claude-agent, and the full API reference is at docs.tabstack.ai.

Ready to build? Sign up for 10,000 free credits, no card required, and hand your Claude agent its first live page. Full API in the docs.

Start automating the web in minutes.

The model, the browser, and the orchestration all run on Tabstack. You just make the call.