Article
What if You Don't Need MCP at All?
mariozechner.at
- After months of agentic coding frenzy, Twitter is still ablaze with discussions about MCP servers. I previously did some very light benchmarking to see if Bash tools or MCP servers are better suited for a specific task. The TL;DR: both can be efficient if you take care.
- many of the most popular MCP servers are inefficient for a specific task. They need to cover all bases, which means they provide large numbers of tools with lengthy descriptions, consuming significant context.
- MCP servers also aren’t composable. Results returned by an MCP server have to go through the agent’s context to be persisted to disk or combined with other results.
- People will recommend Playwright MCP or Chrome DevTools MCP for the use cases I illustrated above. Both are fine, but they need to cover all the bases. Playwright MCP has 21 tools using 13.7k tokens (6.8% of Claude’s context). Chrome DevTools MCP has 26 tools using 18.0k tokens (9.0%). That many tools will confuse your agent, especially when combined with other MCP servers and built-in tools. Using those tools also means you suffer from the composability issue: any output has to go through your agent’s context. You can kind of fix this by using sub-agents, but then you rope in all the issues that sub-agents come with.
- Here’s my minimal set of tools, illustrated via the README.md:
Browser Tools
Minimal CDP tools for collaborative site exploration.Start Chrome
```bash ./start.js # Fresh profile ./start.js —profile # Copy your profile (cookies, logins) ``` Start Chrome on:9222with remote debugging.Navigate
```bash ./nav.js https://example.com ./nav.js https://example.com —new ``` Navigate current tab or open new tab.Evaluate JavaScript
```bash ./eval.js ‘document.title’ ./eval.js ‘document.querySelectorAll(“a”).length’ ``` Execute JavaScript in active tab (async context).Screenshot
```bash ./screenshot.js ``` Screenshot current viewport, returns temp file path. - This is all I feed to my agent. It’s a handful of tools that cover all the bases for my use case. Each tool is a simple Node.js script that uses Puppeteer Core. By reading that README, the agent knows the available tools, when to use them, and how to use them via Bash. When I start a session where the agent needs to interact with a browser, I just tell it to read that file in full and that’s all it needs to be effective. Let’s walk through their implementations to see how little code this actually is.
- So how does this compare to the MCP servers I mentioned above? Well, to start, I can pull in the README whenever I need it and don’t pay for it in every session. This is very similar to Anthropic’s recently introduced skills capabilities. Except it’s even more ad hoc and works with any coding agent. All I need to do is instruct my agent to read the README file.
- Adding the Pick Tool
When the agent and I try to come up with a scraping method for a specific site, it’s often more efficient if I’m able to point out DOM elements to it directly by just clicking on them. To make this super easy, I can just build a picker. Here’s what I add to the README:
Pick Elements
```bash ./pick.js “Click the submit button” ``` Interactive element picker. Click to select, Cmd/Ctrl+Click for multi-select, Enter to finish. And here’s the code: #!/usr/bin/env node import puppeteer from “puppeteer-core”; const message = process.argv.slice(2).join(” ”); if (!message) { console.log(“Usage: pick.js ‘message’”); console.log(“\nExample:”); console.log(’ pick.js “Click the submit button”’); process.exit(1); } const b = await puppeteer.connect({ browserURL: “http://localhost:9222”, defaultViewport: null, }); const p = (await b.pages()).at(-1); if (!p) { console.error(”✗ No active tab found”); process.exit(1); } // Inject pick() helper into current page await p.evaluate(() => { if (!window.pick) { window.pick = async (message) => { if (!message) { throw new Error(“pick() requires a message parameter”); } return new Promise((resolve) => { const selections = []; const selectedElements = new Set(); const overlay = document.createElement(“div”); overlay.style.cssText = “position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;pointer-events:none”; const highlight = document.createElement(“div”); highlight.style.cssText = “position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);transition:all 0.1s”; overlay.appendChild(highlight); const banner = document.createElement(“div”); banner.style.cssText = “position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1f2937;color:white;padding:12px 24px;border-radius:8px;font:14px sans-serif;box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;z-index:2147483647”; const updateBanner = () => { banner.textContent =${message} (${selections.length} selected, Cmd/Ctrl+click to add, Enter to finish, ESC to cancel); }; updateBanner(); document.body.append(banner, overlay); const cleanup = () => { document.removeEventListener(“mousemove”, onMove, true); document.removeEventListener(“click”, onClick, true); document.removeEventListener(“keydown”, onKey, true); overlay.remove(); banner.remove(); selectedElements.forEach((el) => { el.style.outline = ""; }); }; const onMove = (e) => { const el = document.elementFromPoint(e.clientX, e.clientY); if (!el || overlay.contains(el) || banner.contains(el)) return; const r = el.getBoundingClientRect(); highlight.style.cssText =position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);top:${r.top}px;left:${r.left}px;width:${r.width}px;height:${r.height}px; }; const buildElementInfo = (el) => { const parents = []; let current = el.parentElement; while (current && current !== document.body) { const parentInfo = current.tagName.toLowerCase(); const id = current.id ?#${current.id}: ""; const cls = current.className ?.${current.className.trim().split(/\s+/).join(".")}: ""; parents.push(parentInfo + id + cls); current = current.parentElement; } return { tag: el.tagName.toLowerCase(), id: el.id || null, class: el.className || null, text: el.textContent?.trim().slice(0, 200) || null, html: el.outerHTML.slice(0, 500), parents: parents.join(” > ”), }; }; const onClick = (e) => { if (banner.contains(e.target)) return; e.preventDefault(); e.stopPropagation(); const el = document.elementFromPoint(e.clientX, e.clientY); if (!el || overlay.contains(el) || banner.contains(el)) return; if (e.metaKey || e.ctrlKey) { if (!selectedElements.has(el)) { selectedElements.add(el); el.style.outline = “3px solid #10b981”; selections.push(buildElementInfo(el)); updateBanner(); } } else { cleanup(); const info = buildElementInfo(el); resolve(selections.length > 0 ? selections : info); } }; const onKey = (e) => { if (e.key === “Escape”) { e.preventDefault(); cleanup(); resolve(null); } else if (e.key === “Enter” && selections.length > 0) { e.preventDefault(); cleanup(); resolve(selections); } }; document.addEventListener(“mousemove”, onMove, true); document.addEventListener(“click”, onClick, true); document.addEventListener(“keydown”, onKey, true); }); }; } }); const result = await p.evaluate((msg) => window.pick(msg), message); if (Array.isArray(result)) { for (let i = 0; i < result.length; i++) { if (i > 0) console.log(""); for (const [key, value] of Object.entries(result[i])) { console.log(${key}: ${value}); } } } else if (typeof result === “object” && result !== null) { for (const [key, value] of Object.entries(result)) { console.log(${key}: ${value}); } } else { console.log(result); } await b.disconnect(); Whenever I think it’s faster for me to just click on a bunch of DOM elements instead of having the agent figure out the DOM structure, I can just tell it to use the pick tool. It’s super efficient and allows me to build scrapers in no time. It’s also fantastic to adjust the scraper if the DOM layout of a site changed. - Here’s how I’ve set things up so I can use this with Claude Code and other agents. I have a folder
agent-toolsin my home directory. I then clone the repositories of individual tools, like the browser tools repository above, into that folder. Then I set up an alias: alias cl=“PATH=$PATH:/Users/badlogic/agent-tools/browser-tools:&& claude —dangerously-skip-permissions” This way all of the scripts are available to sessions of Claude, but don’t pollute my normal environment. I also prefix each script with the full tool name, e.g. browser-tools-start.js, to eliminate name collisions. I also add a single sentence to the README telling the agent that all the scripts are globally available. This way, the agent doesn’t have to change its working directory just to call a tool script, saving a few tokens here and there, and reducing the chances of the agent getting confused by the constant working directory changes. Finally, I add the agent tools directory as a working directory to Claude Code via/add-dir, so I can use@README.mdto reference a specific tool’s README file and get it into the agent’s context. I prefer this to Anthropic’s skill auto-discovery, which I found to not work reliably in practice. It also means I save a few more tokens: Claude Code injects all the frontmatter of all skills it can find into the system prompt (or first user message, I forgot, see https://cchistory.mariozechner.at)