MCP vs. API: Three Claims, Proven With Runnable Code A developer demonstrated with three runnable TypeScript projects that MCP acts as an adapter layer over existing APIs rather than replacing them, using the official @modelcontextprotocol/sdk and MCP Inspector. The first project shows a single shared business-logic function exposed through both an Express REST endpoint and an MCP tool, with no duplicated logic and credentials kept server-side in both cases. The original post argued that MCP doesn't replace APIs- it sits on top of them, at a real token cost, with a real attack surface. Here's that argument turned into three small, runnable TypeScript projects instead of assertions. The original "MCP vs. API Explained" https://dev.to/thesnehamk/mcp-vs-api-explained-do-we-still-need-apis-after-mcp-2kkk post made three claims that are easy to state and easy to hand-wave past: that MCP is an adapter over an API rather than a replacement for one, that eager tool-schema loading burns a measured amount of context, and that a known class of vulnerability shows up in a meaningful fraction of MCP tool handlers. This is the follow-up where each of those claims gets a small, self-contained, npm install && run - able project instead of a citation and a shrug. All three are validated against the real @modelcontextprotocol/sdk and the official MCP Inspector. The proof here is architectural: one business-logic function, two interfaces, zero duplicated logic. The shared function lives in weatherService.ts and knows nothing about REST or MCP: export interface WeatherResult { city: string; temperatureC: number; condition: string; observedAt: string; } function lookupUpstream city: string : { temperatureC: number; condition: string } | undefined { void process.env.WEATHER API KEY; // would be used here in a real HTTP call return FIXTURE DATA city.trim .toLowerCase ; } export class CityNotFoundError extends Error { constructor city: string { super No weather data for "${city}". Try one of: ${Object.keys FIXTURE DATA .join ", " } ; this.name = "CityNotFoundError"; } } export function getWeather city: string : WeatherResult { const data = lookupUpstream city ; if data throw new CityNotFoundError city ; return { city, temperatureC: data.temperatureC, condition: data.condition, observedAt: new Date .toISOString }; } The REST interface is exactly what you'd expect — Express, a query param, status codes: js app.get "/weather", req, res = { const city = req.query.city; if typeof city == "string" || city.trim === "" { res.status 400 .json { error: "Query parameter 'city' is required." } ; return; } try { res.json getWeather city ; } catch error { if error instanceof CityNotFoundError { res.status 404 .json { error: error.message } ; return; } res.status 500 .json { error: "Unexpected server error." } ; } } ; The MCP interface calls the identical getWeather — the entire adapter is a name, a Zod schema, and error-shape translation: js const server = new McpServer { name: "weather-mcp-demo", version: "1.0.0" } ; server.registerTool "get weather", { title: "Get Weather", description: Get the current weather for a city.\n\nArgs:\n - city string, required : city name, e.g. "Delhi"\n\nReturns the temperature Celsius , condition, and observation time. , inputSchema: { city: z.string .min 1 .describe "City name, e.g. 'Delhi'" }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, async { city } = { try { const result = getWeather city ; return { content: { type: "text", text: JSON.stringify result, null, 2 } }; } catch error { if error instanceof CityNotFoundError { return { isError: true, content: { type: "text", text: Error: ${error.message} } }; } throw error; } } ; Run both and diff what changed: nothing about the lookup, the fixture data, or the error cases. Only the calling contract — an HTTP query param vs. a JSON-Schema-described tool call — and where credentials live WEATHER API KEY stays server-side in both, never reaching the REST client or the model . That's the whole claim, made mechanically checkable instead of asserted. cd 01-same-backend-two-interfaces npm install && npm run build npm run api curl "http://localhost:3000/weather?city=Delhi" npm run mcp:inspect or: npx @modelcontextprotocol/inspector --cli node dist/mcp/server.js \ --method tools/call --tool-name get weather --tool-arg city=Delhi The original post cited two numbers from other sources — a GitHub MCP server reportedly burning ~50K tokens just initializing, and a 100+ tool database server measured wasting up to 81% of context before a single query runs. This benchmark doesn't reproduce those exact servers; it reproduces the mechanism, with a real tokenizer, on a comparably shaped synthetic tool pool, so the gap is measured rather than quoted secondhand: js const POOL SIZES = 10, 25, 50, 100, 150, 200 ; const TASK TOOLS NEEDED = 3; // a single task typically only needs a handful of tools function tokenCount schema: ToolSchema : number { return encode JSON.stringify schema .length; // gpt-tokenizer, cl100k base } function eagerCost pool: ToolSchema : number { return pool.reduce sum, tool = sum + tokenCount tool , 0 ; } function lazyCost pool: ToolSchema , tasksNeeded: number : number { const metaCost = tokenCount searchToolsMetaSchema ; const neededCost = pool.slice 0, tasksNeeded .reduce sum, tool = sum + tokenCount tool , 0 ; return metaCost + neededCost; } Eager sends every tool's full JSON Schema up front, every turn, regardless of relevance — the pattern the original post described as common practice. Lazy sends one small search tools meta-schema initially, and only fetches full schemas for the ~3 tools a given task actually needs — the "progressive/lazy tool disclosure" pattern raised in that post's comments as the practical fix. Running it: cd 02-context-cost-benchmark npm install && npm run benchmark At 100 tools in the pool, eager disclosure spends every one of those tools' schemas before a single user query runs; lazy disclosure spends the meta-schema plus ~3 real schemas — a 96% reduction in this synthetic pool. That's the same order of magnitude as the independently measured 81% figure the original post cited for a real 100+ tool server, using a different tool set and a different tokenizer — which is the point: the mechanism cost scales linearly with server size under eager loading, and stays roughly flat under lazy loading isn't specific to one vendor's server; it's structural to how the two disclosure strategies behave as a tool pool grows. This is the one worth actually seeing broken and fixed, because the vulnerable version is the kind of code that looks completely reasonable at a glance: export async function searchLogsVulnerable pattern: string : Promise