# MCP vs. API: Three Claims, Proven With Runnable Code

> Source: <https://dev.to/thesnehamk/mcp-vs-api-three-claims-proven-with-runnable-code-cga>
> Published: 2026-09-25 06:44:58+00:00

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<string> {
  // VULNERABLE LINE: `pattern` is concatenated straight into a shell
  // command string. A pattern like `ERROR"; echo INJECTED; echo "`
  // closes the intended quoted argument early and runs `echo INJECTED.`
  // as its own command, with the tool's own process privileges.
  const command = `grep "${pattern}" ${FIXTURE_LOG}`;
  const { stdout } = await execAsync(command, { timeout: 3000 });
  return stdout.trim();
}
```

The 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`.

The fix is two independent layers, either of which alone would have stopped this:

``` js
const SafePatternSchema = z
  .string()
  .min(1)
  .max(200)
  .regex(/^[\w .-]+$/, "pattern may only contain letters, numbers, spaces, dots, hyphens, and underscores");

export async function searchLogsHardened(pattern: string): Promise<string> {
  const parsed = SafePatternSchema.safeParse(pattern);
  if (!parsed.success) {
    throw new InvalidPatternError(parsed.error.issues[0]?.message ?? "invalid pattern");
  }

  // Argument array, not a command string: grep never sees a shell, so
  // there's no shell syntax for a malicious pattern to break out into,
  // even hypothetically.
  const { stdout } = await execFileAsync("grep", [parsed.data, FIXTURE_LOG], { timeout: 3000 });
  return stdout.trim();
}
```

Layer 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.

One 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.

```
cd 03-security-patterns
npm install && npm run demo
```

None 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."

If you really need a complete demo code, I am happy to share the GitHub link
