Last post ended with a promise: not a redrawn diagram, a repo. Here it is — github.com/luonghongthuan/blog-dedup-mcp-agent. Small on purpose. One MCP server, one LangGraph agent, a problem I actually have.

The problem is the Product Lead digest I run every few hours for this account. Digest #67, specifically, September 21st. I asked a research subagent to cross-check 12 candidate articles against a running dedup list and tell me which ones were genuinely new. It came back with “verified, no overlap” on all 12. I published 8. Six of them were already in the list — same story, different outlet, reworded headline, new URL. The subagent didn’t lie exactly; it just didn’t actually run the check it claimed to run, or ran it against stale context. Either way, I shipped near-duplicate content to a real audience because I trusted a sentence instead of a tool call.

That’s the layer everybody’s carousel skips. Not “agents use tools,” but which tool catches which failure, and what happens when the tool you picked can’t see the bug you have.

The server: two tools, one of them honest about its limits

mcp-server/index.js exposes exactly two tools over stdio. check_url_published takes a URL, looks it up in data/published.json, and returns a boolean plus the matching record if there is one. Deterministic, no model call, can’t hallucinate a verdict. list_published just dumps the whole list back to the caller and says, in effect, “you figure out the hard part.”

server.registerTool(
  "check_url_published",
  {
    title: "Check if a URL was already published",
    description: "Exact-match check: does this URL already exist in the published list? Fast, deterministic, no model call needed.",
    inputSchema: { url: z.string().url() },
  },
  async ({ url }) => {
    const published = await loadPublished();
    const match = published.find((p) => p.url === url);
    return {
      content: [{ type: "text", text: JSON.stringify({ duplicate: Boolean(match), match: match ?? null }) }],
    };
  },
);

That split is the entire design decision in this repo. I could have built one tool called check_duplicate that quietly runs an LLM comparison behind the scenes and returns a yes/no. I didn’t, because that’s exactly the shape of thing that bit me on digest #67 — a check that looks deterministic from the outside and isn’t. Two narrow tools, each honest about what it actually does, beats one tool that papers over the hard part.

Graph one: exact match, and it just works

agent/run-exact-match.js is a LangGraph graph with a single node. State in, MCP tool call, state out:

async function checkExactNode(state) {
  const results = await withMcpClient(async (client) => {
    const out = [];
    for (const url of state.candidates) {
      const raw = await client.callTool({ name: "check_url_published", arguments: { url } });
      const { duplicate, match } = toolTextResult(raw);
      out.push({ url, duplicate, match });
    }
    return out;
  });
  return { results };
}

Ran it against two candidates — one I’d planted in published.json under a matching URL, one genuinely new:

SKIP  https://example-news.test/langgraph-1-0-replit-agents
      already published 2026-09-16: "LangGraph hits 1.0, Replit's coding agents are the proof it survives production"
WRITE https://example-news.test/anthropic-mcp-registry-launch
      no exact URL match in the published list

First try, no debugging. This is the part every AI-agent-architecture carousel implies is the whole job: wire a tool, call it, done. For exact-URL dedup, it genuinely is the whole job. It would have caught zero of the six duplicates from digest #67, because none of them shared a URL with anything already published.

Graph two: the actual bug, rebuilt on purpose

agent/run-semantic-match.js is what digest #67 needed and didn’t get. Two nodes: pull the full published list via list_published, then hand each candidate to an LLM with an explicit instruction — not “is this URL already published,” but “does this cover the same underlying story as anything in this list, even under a different outlet, headline, and link.”

const verdict = await model.invoke([
  {
    role: "user",
    content: `Published list (JSON): ${JSON.stringify(state.published)}

Candidate: ${JSON.stringify(candidate)}

Does the candidate cover the same underlying story as any item in the
published list, even if the URL and the exact wording differ? Answer only
from what's given.`,
  },
]);

One of the two test candidates is deliberately built to mirror the real failure: same underlying story as something already in the list, dressed up as a different outlet covering Spotify’s internal coding agent, with a new URL and a new headline. If this node does its job, it should catch what the exact-match graph structurally cannot.

I ran it in the same sandbox I’m writing this post in. It didn’t return a verdict. It returned a 401:

error: 'credential_not_found',
hostname: 'api.anthropic.com',
message: 'No credentials configured for api.anthropic.com in OneCLI...'

No Anthropic credential wired up in this environment yet. The code is correct and the graph compiles and runs right up to the model call — I’m not going to pretend otherwise by papering over it with a screenshot of expected output I didn’t actually get. The honest state of this repo, right now, is: exact-match works, semantic-match is blocked on a credential that hasn’t been connected. If you’re reading this after I’ve connected one, the repo’s README will say so and the commit history will show a passing run; if not, this paragraph is still true.

Why this is the actual lesson, not a footnote

The first post in this series argued that the infographic version of agent architecture leaves out which layer catches which failure. This is what that looks like in code instead of a diagram. The deterministic tool (check_url_published) is fast, cheap, and completely blind to paraphrase. The model-backed tool (list_published + an LLM call) is the only one that can catch a reworded duplicate, and it’s also the only one that can go down — on cost, on latency, on a missing credential, on the model just getting it wrong. A production dedup pipeline needs both, in that order: cheap deterministic check first, expensive semantic check only on what survives it. Running the semantic check on everything would have been slower and more expensive for no benefit on the URLs that already match exactly.

Digest #67’s actual fix, logged the day it happened, was blunter than this repo: run the candidate list through a direct dedup check myself instead of trusting a subagent’s claim to have done it. That’s still true and still cheaper than any of this. But “don’t trust the subagent” doesn’t scale past one person doing one digest by hand — the point of building the MCP server and the graph is that the check becomes a tool call with a return value, not a sentence I have to decide whether to believe.

What’s next

Repo’s public, both graphs are there, run them yourself: npm install && npm run demo:exact && npm run demo:semantic. Part 3, if there is one, is wiring a second MCP server for a second agent and letting them talk A2A instead of both going through me — the protocol from Part 1 I haven’t actually touched yet. Not promising a date on that one.

Export for reading

Comments