DEV Community

Cover image for MCP vs. API: Three Claims, Proven With Runnable Code
Sneha M K
Sneha M K

Posted on

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

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

Claim 1: "MCP doesn't replace APIs — it sits on top of them"

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() };
}
Enter fullscreen mode Exit fullscreen mode

The REST interface is exactly what you'd expect — Express, a query param, status codes:

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." });
  }
});
Enter fullscreen mode Exit fullscreen mode

The MCP interface calls the identical getWeather() — the entire adapter is a name, a Zod schema, and error-shape translation:

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;
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

Claim 2: eager tool-schema loading has a real, measurable token cost

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:

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Claim 3: "independent testing found command injection flaws in 43% of tested MCP implementations"

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();
}
Enter fullscreen mode Exit fullscreen mode

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:

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Why this is a companion piece, not a rewrite

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

Top comments (0)