ai-readiness pattern beginner 30 minutes

Your MCP Server Needs Two Tools, Not One

The first version had one tool. The agent kept re-running it just to look at results it already had. The fix wasn't more parameters — it was a second tool whose only job was retrieval.

The first version of the obaron-aeo MCP server had one tool: aeo_scan. Pass it a domain, get back a 0-100 AI Readiness score with the top issues. Clean, single-purpose, easy to explain. It also forced agents into a workflow nobody wanted.

The agent would scan example.com, hand back the result, and a few turns later the user would say “what was the score on that scan again?” The agent’s only move was to call aeo_scan a second time. Sometimes the cache cooldown returned the same cached result; sometimes the cooldown had expired and we re-ran the whole pipeline. Either way the agent was paying tokens to re-read work it had already done — and the result that came back wasn’t guaranteed to match what the user actually remembered, because it might be a fresh scan against a slightly different page.

The fix was a second tool: aeo_lookup. Pass it the 8-character scan ID, get back the exact prior result. No re-scan, no cooldown logic, no ambiguity about whether the agent is looking at “the same” scan. The whole change was one mcpServer.tool() call and a single SQLite read.

The Two Tools

Here’s the actual server, trimmed to the shape that matters:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const mcpServer = new McpServer({
  name: "obaron-aeo",
  version: "1.0.0",
});

// --- Tool 1: producer ---
mcpServer.tool(
  "aeo_scan",
  "Scan a website for AI readiness. Accepts a domain or any URL — we automatically scan the homepage. Returns a 0-100 score with top issues. Powered by Obaron (obaron.ai).",
  { domain: z.string().describe("Domain or URL to scan (e.g. example.com, https://example.com/about)") },
  async ({ domain }) => {
    try {
      const result = await runScanInternal(domain);
      return { content: [{ type: "text", text: formatScanResult(result) }] };
    } catch (err) {
      return {
        content: [{ type: "text", text: `Scan failed: ${err.message}` }],
        isError: true,
      };
    }
  }
);

// --- Tool 2: lookup ---
mcpServer.tool(
  "aeo_lookup",
  "Look up a previous AEO scan result by its 8-character ID.",
  { scan_id: z.string().describe("8-character scan ID (e.g. TtIsr_ZA)") },
  async ({ scan_id }) => {
    try {
      const result = lookupScanInternal(scan_id);
      if (!result) {
        return {
          content: [{ type: "text", text: "Scan not found or expired." }],
          isError: true,
        };
      }
      return { content: [{ type: "text", text: formatScanResult(result) }] };
    } catch (err) {
      return {
        content: [{ type: "text", text: `Lookup failed: ${err.message}` }],
        isError: true,
      };
    }
  }
);

That’s it. One tool produces the artifact and persists it under an 8-character ID. The other addresses an existing artifact by that ID and returns the exact same shape.

Why Two and Not One

The temptation when designing an MCP server is to keep the surface small. One tool feels disciplined. Two feels like surface-area creep. The disciplined version is wrong because it conflates two operations that have different cost, different freshness semantics, and different failure modes.

Producer tool: aeo_scan. Expensive. Hits an external site, parses HTML, runs the rubric, writes to a database. Has a cache cooldown so back-to-back calls within a few minutes return the cached result instead of re-running the pipeline. Failure mode: network errors, parse errors, rate-limited targets.

Lookup tool: aeo_lookup. Cheap. One SQLite read by primary key. No external dependencies. No cooldown logic — the result either exists or it doesn’t. Failure mode: the ID doesn’t exist or expired.

When you fold both into one tool, every call pays the producer’s failure surface even when the agent only wanted the lookup. The agent has to phrase its retrieval as “scan again,” and the tool has to guess whether the agent meant “give me what you already have” or “actually re-scan.” Two tools means the agent — and you, reading the request log — knows exactly which intent fired.

The ID Has to Flow

The lookup tool only works if the producer surfaces the ID prominently enough that the agent will keep it in context. Our producer’s response ends with:

example.com: 78/100 — Good

Top issues:
• Missing llms.txt file
• No agents.md discovery file
△ robots.txt blocks GPTBot

3 more issues found — get the full report at obaron.ai

Full results: https://obaron.ai/scan/l/TtIsr_ZA

The ID (TtIsr_ZA) is in the URL on the last line. The agent reads the result, sees the URL, and the ID is now in its working context. When the user asks a follow-up question turns later, the agent has the address it needs to invoke aeo_lookup instead of re-scanning.

If the producer’s output doesn’t include the ID, the lookup tool exists but is unreachable. The two-tool pattern only pays off when the producer’s response is designed to feed the lookup tool’s input — they compose, or neither does its job.

What aeo_lookup Actually Does

The implementation is short enough to read end-to-end:

function lookupScanInternal(scanId) {
  if (!scanId || !/^[A-Za-z0-9_-]{8}$/.test(scanId)) return null;
  const row = getLightningScanById.get(scanId);
  if (!row) return null;
  const result = JSON.parse(row.result_json);
  return { ...result, scan_id: row.id, scanned_at: row.scanned_at };
}

ID validation is a regex (URL-safe base64, 8 chars). One indexed SQLite read. Parse the stored JSON back. That’s the whole tool. Compare against what aeo_scan does — fork a Python subprocess, fetch the target site, run the scoring rubric, write a row, return the formatted result — and the cost difference between the two paths is several orders of magnitude.

This is the point of the second tool. It exists so the cheap operation can be cheap.

Error Shape: Both Tools, Same Convention

Both tools return errors with isError: true and a human-readable text payload:

return {
  content: [{ type: "text", text: "Scan not found or expired." }],
  isError: true,
};

The MCP client uses isError to decide whether the tool succeeded; the text payload is what the agent reads to figure out what to tell the user. Don’t return raw exceptions, don’t throw — wrap and label. The MCP SDK propagates thrown errors as protocol-level failures, which is the wrong layer; tool-level failures (the scan target was unreachable, the underlying pipeline crashed) should be data, not exceptions.

There’s a real judgment call inside this convention: should “scan ID not found” be isError: true or just a plain text response? The argument for plain text is that an unknown ID is an expected outcome of a lookup, not a failure of the lookup tool — flagging it as an error can nudge the agent’s planner to treat a typo’d ID as a system fault and retry or escalate. The argument for isError: true (which is what we ship) is that the user’s intent — “retrieve scan X” — cannot be fulfilled, and the agent should know that without parsing the prose. We chose the second because in our usage the typo case is rare and the “stop and ask the user” behavior is the one we want. If your tool’s miss case is common (e.g., “is this user registered?”), invert the choice.

The Pattern Beyond MCP

This is the same shape as POST /scans + GET /scans/:id in REST. The same shape as git commit + git show <sha>. The same shape as a job queue’s submit + retrieve. Any system where producing work creates an addressable artifact wants two operations: one to produce, one to address.

MCP makes the pattern more visible because the tools are individually advertised to the agent’s tool list at the protocol level. The agent’s planner literally reads the tool descriptions and picks one. If you only ship the producer, the planner has no representation of “retrieve” — it will compose retrieval out of producer calls and be wrong about it.

Setup Notes

The MCP SDK quietly accepts plain JavaScript objects in the third argument to mcpServer.tool(). They register without error, but inputs arrive as undefined. Use Zod schemas. This is its own gotcha and worth a separate read: MCP Tool Schemas Accept Plain Objects — But Only Zod Actually Works.

The obaron-aeo server is live at https://api.obaron.ai/aeo/mcp if you want to add it to a Claude Code or Cursor config and watch the two-tool pattern in action. The full mcpServers config is one line:

{
  "mcpServers": {
    "obaron-aeo": {
      "url": "https://api.obaron.ai/aeo/mcp"
    }
  }
}

Run a scan, copy the ID out of the response, ask a follow-up. Watch the agent invoke aeo_lookup instead of re-scanning. That’s the pattern.

FAQ

Why does an MCP server need a separate lookup tool?

Because agents lose state between turns. The agent that ran your scan tool yesterday is not necessarily the same conversation today, and even within a single conversation the agent may forget the full result it already saw. Without a lookup tool, the agent's only way to revisit a previous result is to re-run the producer tool — which is slower, may hit a cache cooldown, and burns tokens re-reading work it already did. A lookup tool addressed by ID lets the agent retrieve exactly the prior result, cheap and stable.

Why not just return everything from the producer tool and skip the lookup?

You can — for one turn. The problem is the next turn, or the next conversation. The producer's response lives in chat history; chat history gets compacted, summarized, or scrolled out of context. The lookup tool exposes a stable address (the scan ID) that survives all of that. Humans solve this by clicking back to a URL; agents solve it by calling a lookup tool with the ID.

What should the producer tool return to make the lookup tool useful?

An ID, prominently. Our producer returns a formatted text result that ends with both the ID and a public URL: 'Full results: https://obaron.ai/scan/l/TtIsr_ZA'. The agent now has the ID in its context window and can pass it to aeo_lookup later. If the producer doesn't surface the ID, the lookup tool can't be invoked — the design has to flow both ways.

Is the two-tool pattern specific to MCP?

No. The same shape shows up in REST APIs (POST /resource + GET /resource/:id), CLI tools (run + show), and any system where work produces an addressable artifact. MCP just makes it concrete because the tools are individually advertised to the agent's tool list — under-designing here means the agent literally cannot reach a workflow you want it to reach.