NativePort
← How-to

How to Give Buzz Agents Web Search and Page Reading with MCP

Add web search and page reading to a Buzz agent via a stdio MCP server proxying NativePort, with a wrapper that fixes buzz-acp's NATIVEPORT_API_KEY forwarding gap.

This tutorial extends a working native buzz-agent (the runtime, its OpenAI-compatible model connection to NativePort, and Buzz’s own relay/identity setup) with two tools it doesn’t have out of the box: web search and page reading. It assumes you’ve already been through Connect Buzz’s built-in AI agent to an OpenAI-compatible API and have buzz-agent talking to NativePort; that setup isn’t repeated here. What’s new is a small stdio MCP server, nativeport-search-tools, exposing web_search and read_url through the same NativePort key, plus a wrapper pattern that’s not optional, because of how buzz-agent spawns MCP tool processes.

What you’ll need

  • A working buzz-agent + NativePort connection, per the prerequisite tutorial above.
  • Node.js >= 22 (the server uses ESM and the current @modelcontextprotocol/sdk).
  • The same NATIVEPORT_API_KEY used for the model connection: one key, both surfaces.

Install the MCP server

Put the server somewhere stable outside your working directory. buzz-agent will launch it by absolute path, not from wherever you happen to run a build:

mkdir -p "$HOME/.local/share/nativeport-search-tools"
cd "$HOME/.local/share/nativeport-search-tools"

package.json:

{
  "name": "nativeport-search-tools",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "description": "Stdio MCP server exposing NativePort-proxied web search (Serper) and page reading (Jina Reader) tools.",
  "main": "server.mjs",
  "scripts": {
    "start": "node server.mjs"
  },
  "engines": {
    "node": ">=22"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.30.0"
  }
}

server.mjs:

#!/usr/bin/env node
// Stdio MCP server exposing two NativePort-proxied tools:
//   - web_search: Serper search        (POST /serper/search)
//   - read_url:   Jina Reader scraping (GET  /jina/reader/<url>)
//
// stdout is reserved for MCP JSON-RPC frames; all diagnostics go to stderr.

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";

const NATIVEPORT_BASE_URL = "https://api.nativeport.ai";
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_SEARCH_RESULTS = 8;
const MAX_OUTPUT_CHARS = 12_000;

const apiKey = process.env.NATIVEPORT_API_KEY;
if (!apiKey) {
  console.error("NATIVEPORT_API_KEY is required (set it in the environment) — refusing to start.");
  process.exit(1);
}

/** Fetch with a hard timeout; never throws on timeout, returns a labeled AbortError instead. */
async function fetchWithTimeout(url, init, timeoutMs) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...init, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

function truncate(text, max = MAX_OUTPUT_CHARS) {
  if (text.length <= max) return text;
  return `${text.slice(0, max)}\n\n[truncated — ${text.length - max} more characters omitted]`;
}

async function webSearch({ query, num }) {
  if (typeof query !== "string" || query.trim().length === 0) {
    throw new Error("query must be a non-empty string");
  }
  const count = Number.isInteger(num) ? Math.min(Math.max(num, 1), MAX_SEARCH_RESULTS) : 5;

  let res;
  try {
    res = await fetchWithTimeout(
      `${NATIVEPORT_BASE_URL}/serper/search`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ q: query, num: count }),
      },
      REQUEST_TIMEOUT_MS,
    );
  } catch (err) {
    const reason = err?.name === "AbortError" ? "timed out" : "network error";
    throw new Error(`web_search: request to NativePort ${reason}`);
  }

  if (!res.ok) {
    throw new Error(`web_search: NativePort/Serper returned HTTP ${res.status}`);
  }

  const data = await res.json();
  const lines = [];

  if (data.answerBox) {
    const box = data.answerBox;
    const summary = box.answer ?? box.snippet;
    if (summary) lines.push(`Answer: ${summary}${box.link ? ` (${box.link})` : ""}`);
  }

  const organic = Array.isArray(data.organic) ? data.organic.slice(0, count) : [];
  if (organic.length === 0 && lines.length === 0) {
    return "No results found.";
  }
  for (const [i, r] of organic.entries()) {
    lines.push(`${i + 1}. ${r.title ?? "(untitled)"}\n   ${r.link ?? ""}\n   ${r.snippet ?? ""}`);
  }

  return truncate(lines.join("\n\n"));
}

async function readUrl({ url }) {
  if (typeof url !== "string") throw new Error("url must be a string");
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    throw new Error("url must be a valid absolute URL");
  }
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
    throw new Error("url must use http or https");
  }

  let res;
  try {
    res = await fetchWithTimeout(
      `${NATIVEPORT_BASE_URL}/jina/reader/${parsed.href}`,
      { headers: { Authorization: `Bearer ${apiKey}` } },
      REQUEST_TIMEOUT_MS,
    );
  } catch (err) {
    const reason = err?.name === "AbortError" ? "timed out" : "network error";
    throw new Error(`read_url: request to NativePort ${reason}`);
  }

  if (!res.ok) {
    throw new Error(`read_url: NativePort/Jina returned HTTP ${res.status}`);
  }

  const text = await res.text();
  return truncate(text);
}

const TOOLS = [
  {
    name: "web_search",
    description: "Search the web via NativePort's Serper proxy and return concise, cited results.",
    inputSchema: {
      type: "object",
      properties: {
        query: { type: "string", description: "The search query." },
        num: {
          type: "integer",
          minimum: 1,
          maximum: MAX_SEARCH_RESULTS,
          description: `Number of results to return (default 5, max ${MAX_SEARCH_RESULTS}).`,
        },
      },
      required: ["query"],
      additionalProperties: false,
    },
  },
  {
    name: "read_url",
    description: "Fetch a web page via NativePort's Jina Reader proxy and return its content as markdown/text.",
    inputSchema: {
      type: "object",
      properties: {
        url: { type: "string", description: "Absolute http(s) URL to read." },
      },
      required: ["url"],
      additionalProperties: false,
    },
  },
];

const server = new Server(
  { name: "nativeport-search-tools", version: "1.0.0" },
  { capabilities: { tools: {} } },
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args = {} } = request.params;
  try {
    let text;
    if (name === "web_search") {
      text = await webSearch(args);
    } else if (name === "read_url") {
      text = await readUrl(args);
    } else {
      throw new Error(`Unknown tool: ${name}`);
    }
    return { content: [{ type: "text", text }] };
  } catch (err) {
    return { isError: true, content: [{ type: "text", text: err?.message ?? String(err) }] };
  }
});

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("nativeport-search-tools MCP server ready (stdio).");

Install the one dependency:

npm install

Sanity-check it standalone before wiring it into Buzz

The server fails fast and loud if its key is missing:

node server.mjs
# NATIVEPORT_API_KEY is required (set it in the environment) — refusing to start.
# (exits 1)

With the key set, it starts and waits on stdio for MCP frames. This is expected, not a hang:

NATIVEPORT_API_KEY="np_..." node server.mjs
# nativeport-search-tools MCP server ready (stdio).

Ctrl-C to stop it once you’ve confirmed that line prints. Once you’re wired into Buzz, initialize, tools/list, and tools/call for both web_search and read_url all complete correctly over the stdio transport. Each call is billed at NativePort’s normal per-request rate for Serper and Jina; see the Serper and Jina provider pages for current pricing.

The integration gotcha: buzz-agent doesn’t forward your API key

buzz-acp doesn’t launch the MCP server itself. It includes BUZZ_ACP_MCP_COMMAND in the ACP session/new request it sends to whatever agent it’s driving, in this setup, buzz-agent. It’s buzz-agent, on the receiving end of that request, that actually spawns the MCP command as a child process, and it does so carefully: it clears the child’s environment entirely, then repopulates it from exactly two sources, a fixed allowlist of variables that includes HOME, and whatever MCP-server-specific env session/new itself carried. NATIVEPORT_API_KEY is in neither. Exporting it in the parent shell before launching buzz-acp doesn’t help: nothing in that chain forwards it to the MCP child, and server.mjs will refuse to start with the fail-fast error above the moment buzz-agent spawns it.

The fix is a small wrapper that loads the key itself, from a file, after the process is already running, using $HOME, which is forwarded, to find that file.

1. Put the key in its own file, not in an env var buzz-acp has to relay:

mkdir -p "$HOME/.config/nativeport"

Create $HOME/.config/nativeport/buzz-mcp.env containing exactly one line:

NATIVEPORT_API_KEY=np_...

Prefer opening it in an editor ($EDITOR "$HOME/.config/nativeport/buzz-mcp.env") over a shell heredoc with the real key inline. That keeps the key out of your shell history, which a heredoc typed at an interactive prompt won’t. Then lock the file down:

chmod 600 "$HOME/.config/nativeport/buzz-mcp.env"

2. Write an executable wrapper that loads it and execs the server:

Create $HOME/.local/bin/nativeport-search-tools:

#!/usr/bin/env bash
set -euo pipefail
set -a
source "$HOME/.config/nativeport/buzz-mcp.env"
set +a
exec node "$HOME/.local/share/nativeport-search-tools/server.mjs"
mkdir -p "$HOME/.local/bin"
chmod 700 "$HOME/.local/bin/nativeport-search-tools"

set -a exports every variable the sourced file defines, so NATIVEPORT_API_KEY reaches server.mjs as a real environment variable, not just a shell-local one. exec replaces the wrapper process with node rather than leaving it as a parent, so buzz-agent talks to the Node MCP process directly, same as if it had launched server.mjs itself.

3. Point buzz-acp at the wrapper, not at server.mjs directly:

export BUZZ_ACP_MCP_COMMAND="$HOME/.local/bin/nativeport-search-tools"

Nothing about the model connection from the prerequisite tutorial changes. BUZZ_ACP_MCP_COMMAND is a separate setting from OPENAI_COMPAT_*, and this wrapper only ever touches the MCP tool process, never the model call.

Keep the key file and persona/public metadata separate on principle, same as the security guidance in the prerequisite tutorial: buzz-mcp.env should never be something an agent’s persona config or anything published to a channel points at or embeds.

A reliable test prompt

Point a buzz-agent configured per the prerequisite tutorial, with BUZZ_ACP_MCP_COMMAND set as above, at a directive prompt that forces both tools in order:

Use web_search to find Buzz's official GitHub repository and Block's
announcement of it, then use read_url on the most relevant result from
each to confirm the URLs. Answer with both URLs and one sentence citing
what each source says.

What this setup supports

With this MCP server wired in, buzz-agent can call web_search then read_url in the same turn, over stdio, and produce a final answer that cites both URLs, ending with stopReason: "end_turn". Cost scales with the model call plus both tool calls combined; check the Serper and Jina provider pages for current per-request pricing, since the exact total depends on token counts and how many results a model chooses to read.

Scope note. NATIVEPORT_API_KEY can reach the MCP server two ways: passed explicitly in the ACP session/new request’s mcpServers[].env entry, or through the $HOME-based wrapper above for the case where buzz-acp triggers session/new on its own (per the gotcha above). The wrapper above addresses that gap. If you’re driving buzz-agent from a relay-originated session rather than a directly configured one, verify the wrapper picks up the key as expected before relying on it.

Errors you’ll actually hit

  • NATIVEPORT_API_KEY is required ... refusing to start, even though buzz-acp is clearly running: you launched buzz-acp with BUZZ_ACP_MCP_COMMAND pointed at server.mjs directly instead of the wrapper. buzz-agent (which is what actually spawns the MCP process, per the gotcha above) doesn’t forward the key from its own environment; only the wrapper’s source step supplies it.
  • web_search: NativePort/Serper returned HTTP 401 or read_url: NativePort/Jina returned HTTP 401: the key file has the wrong value, or a typo in the variable name (NATIVEPORT_API_KEY, exactly).
  • HTTP 402: the NativePort balance is at $0; both tools fail closed the same way every other route on the gateway does.
  • web_search: request to NativePort timed out / read_url: request to NativePort timed out: the 30-second timeout in server.mjs was hit; a slow page for read_url or a slow upstream for web_search. This is the client-side cap in the code above, not a NativePort-imposed limit.
  • Permission denied when buzz-agent tries to launch the wrapper: chmod 700 wasn’t applied to $HOME/.local/bin/nativeport-search-tools, or it’s missing its shebang.
  • url must use http or https: read_url was called with something other than an http(s):// URL (a file:// path, a bare hostname without scheme, etc.). This check runs before any request leaves the machine.

Security and scope: what read_url actually is

read_url is not a private-network fetcher, and it doesn’t try to be one. It accepts any absolute http(s) URL a model hands it and passes that URL straight through to NativePort’s Jina Reader route. The scheme check in server.mjs only rejects non-http(s) schemes; it doesn’t restrict which public hosts are reachable. The actual fetch happens server-side, at the gateway and at Jina, not from this MCP process or the machine running it, so this tool can’t be used to reach into a private network server.mjs itself can see, but it will happily read whatever public page a model asks it to. Treat the model’s choice of URL the same way you’d treat any other tool input a model controls: don’t assume it only ever asks for pages you’d have picked yourself.

Where to go next