{"slug": "mcp-vs-api-three-claims-proven-with-runnable-code", "title": "MCP vs. API: Three Claims, Proven With Runnable Code", "summary": "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.", "body_md": "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.\n\nThe 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.\n\nThe 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:\n\n```\nexport interface WeatherResult {\n  city: string;\n  temperatureC: number;\n  condition: string;\n  observedAt: string;\n}\n\nfunction lookupUpstream(city: string): { temperatureC: number; condition: string } | undefined {\n  void process.env.WEATHER_API_KEY; // would be used here in a real HTTP call\n  return FIXTURE_DATA[city.trim().toLowerCase()];\n}\n\nexport class CityNotFoundError extends Error {\n  constructor(city: string) {\n    super(`No weather data for \"${city}\". Try one of: ${Object.keys(FIXTURE_DATA).join(\", \")}`);\n    this.name = \"CityNotFoundError\";\n  }\n}\n\nexport function getWeather(city: string): WeatherResult {\n  const data = lookupUpstream(city);\n  if (!data) throw new CityNotFoundError(city);\n  return { city, temperatureC: data.temperatureC, condition: data.condition, observedAt: new Date().toISOString() };\n}\n```\n\nThe REST interface is exactly what you'd expect — Express, a query param, status codes:\n\n``` js\napp.get(\"/weather\", (req, res) => {\n  const city = req.query.city;\n  if (typeof city !== \"string\" || city.trim() === \"\") {\n    res.status(400).json({ error: \"Query parameter 'city' is required.\" });\n    return;\n  }\n  try {\n    res.json(getWeather(city));\n  } catch (error) {\n    if (error instanceof CityNotFoundError) {\n      res.status(404).json({ error: error.message });\n      return;\n    }\n    res.status(500).json({ error: \"Unexpected server error.\" });\n  }\n});\n```\n\nThe MCP interface calls the identical getWeather() — the entire adapter is a name, a Zod schema, and error-shape translation:\n\n``` js\nconst server = new McpServer({ name: \"weather-mcp-demo\", version: \"1.0.0\" });\n\nserver.registerTool(\n  \"get_weather\",\n  {\n    title: \"Get Weather\",\n    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.`,\n    inputSchema: { city: z.string().min(1).describe(\"City name, e.g. 'Delhi'\") },\n    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n  },\n  async ({ city }) => {\n    try {\n      const result = getWeather(city);\n      return { content: [{ type: \"text\", text: JSON.stringify(result, null, 2) }] };\n    } catch (error) {\n      if (error instanceof CityNotFoundError) {\n        return { isError: true, content: [{ type: \"text\", text: `Error: ${error.message}` }] };\n      }\n      throw error;\n    }\n  }\n);\n```\n\nRun 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.\n\n```\n`cd 01-same-backend-two-interfaces\nnpm install && npm run build\nnpm run api        # curl \"http://localhost:3000/weather?city=Delhi\"\nnpm run mcp:inspect # or: npx @modelcontextprotocol/inspector --cli node dist/mcp/server.js \\\n                    #     --method tools/call --tool-name get_weather --tool-arg city=Delhi`\n```\n\nThe 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:\n\n``` js\nconst POOL_SIZES = [10, 25, 50, 100, 150, 200];\nconst TASK_TOOLS_NEEDED = 3; // a single task typically only needs a handful of tools\n\nfunction tokenCount(schema: ToolSchema): number {\n  return encode(JSON.stringify(schema)).length; // gpt-tokenizer, cl100k_base\n}\n\nfunction eagerCost(pool: ToolSchema[]): number {\n  return pool.reduce((sum, tool) => sum + tokenCount(tool), 0);\n}\n\nfunction lazyCost(pool: ToolSchema[], tasksNeeded: number): number {\n  const metaCost = tokenCount(searchToolsMetaSchema());\n  const neededCost = pool.slice(0, tasksNeeded).reduce((sum, tool) => sum + tokenCount(tool), 0);\n  return metaCost + neededCost;\n}\n```\n\nEager 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:\n\n```\ncd 02-context-cost-benchmark\nnpm install && npm run benchmark\n```\n\nAt 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.\n\nThis 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:\n\n```\nexport async function searchLogsVulnerable(pattern: string): Promise<string> {\n  // VULNERABLE LINE: `pattern` is concatenated straight into a shell\n  // command string. A pattern like `ERROR\"; echo INJECTED; echo \"`\n  // closes the intended quoted argument early and runs `echo INJECTED.`\n  // as its own command, with the tool's own process privileges.\n  const command = `grep \"${pattern}\" ${FIXTURE_LOG}`;\n  const { stdout } = await execAsync(command, { timeout: 3000 });\n  return stdout.trim();\n}\n```\n\nThe critical detail the comment calls out: the caller here is a model, not a human typing a known-safe string into a terminal. \"The pattern will usually be reasonable\" was never a real safety property, and it's an even worse one when the input can come from a tool result upstream, a prompt-injected instruction, or a plain model mistake — any of which can hand `pattern` arbitrary shell syntax, because `exec()` runs its argument through `/bin/sh -c`.\n\nThe fix is two independent layers, either of which alone would have stopped this:\n\n``` js\nconst SafePatternSchema = z\n  .string()\n  .min(1)\n  .max(200)\n  .regex(/^[\\w .-]+$/, \"pattern may only contain letters, numbers, spaces, dots, hyphens, and underscores\");\n\nexport async function searchLogsHardened(pattern: string): Promise<string> {\n  const parsed = SafePatternSchema.safeParse(pattern);\n  if (!parsed.success) {\n    throw new InvalidPatternError(parsed.error.issues[0]?.message ?? \"invalid pattern\");\n  }\n\n  // Argument array, not a command string: grep never sees a shell, so\n  // there's no shell syntax for a malicious pattern to break out into,\n  // even hypothetically.\n  const { stdout } = await execFileAsync(\"grep\", [parsed.data, FIXTURE_LOG], { timeout: 3000 });\n  return stdout.trim();\n}\n```\n\nLayer one is a strict allowlist regex that rejects shell metacharacters before they reach a process call. Layer two is structural rather than a filter: `execFile()` with an argument array never invokes a shell at all, so even a gap in the regex has no shell syntax available to exploit — the pattern string is passed to `grep` as inert data, not parsed as command syntax. The repo's test harness runs an actual injection payload `(ERROR\" /dev/null; echo INJECTED_BY_ATTACKER; echo \")` against both versions live: the vulnerable one executes the injected echo, the hardened one rejects the input outright via the regex before `execFile` is ever called.\n\nOne more thing surfaced building this demo, worth calling out because it's a distinct bug class from injection: the original `exec()` call had no timeout, and a pattern that makes `grep` hang — reading from stdin instead of the fixture file, for instance — blocks the handler indefinitely. Both versions in the repo now set an explicit `timeout: 3000`, which is a denial-of-service mitigation, not a security fix for the injection itself. It's worth having both, and worth knowing they're not the same guarantee.\n\n```\ncd 03-security-patterns\nnpm install && npm run demo\n```\n\nNone of these three demos change the conclusion of the original post — they were built specifically to test whether that conclusion survives contact with actual code, and it does. MCP genuinely is a thin adapter over the same business logic an API already exposes; eager tool-schema loading genuinely does scale badly with server size in a way lazy disclosure avoids; and the injection vulnerability class genuinely does come from an unremarkable-looking line of string interpolation that a regex and an argument array both independently close off. If you're building an MCP server, the practical takeaway isn't \"avoid MCP\" — it's \"assume your tool handlers face the same untrusted-input discipline as a public API endpoint, because the caller is a model, not a person who reads your intended usage and stays inside it.\"\n\nIf you really need a complete demo code, I am happy to share the GitHub link", "url": "https://wpnews.pro/news/mcp-vs-api-three-claims-proven-with-runnable-code", "canonical_source": "https://dev.to/thesnehamk/mcp-vs-api-three-claims-proven-with-runnable-code-cga", "published_at": "2026-09-25 06:44:58+00:00", "updated_at": "2026-09-25 06:58:55.087119+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools"], "entities": ["Model Context Protocol", "@modelcontextprotocol/sdk", "MCP Inspector", "Express", "TypeScript", "Zod"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/mcp-vs-api-three-claims-proven-with-runnable-code", "markdown": "https://wpnews.pro/news/mcp-vs-api-three-claims-proven-with-runnable-code.md", "text": "https://wpnews.pro/news/mcp-vs-api-three-claims-proven-with-runnable-code.txt", "jsonld": "https://wpnews.pro/news/mcp-vs-api-three-claims-proven-with-runnable-code.jsonld"}}