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.