← Back to list

The Intern and the Bureaucrat: How I Taught an LLM to Stop Writing My Code

Or: what happened when I tried to turn every API on the internet into an MCP server, and why the smartest thing my AI pipeline does is…

Uday Chandra · 2026-07-11 17:08 · 0 claps · 9.2 min read paywalled
#model-context-protocol #agentic-ai #agentic-ai-architecture #ai #ai-agent
Open on Medium ↗
Wiki topics: LLM · Large Language Models AGT · AI Agents AI · AI · General 🏛️ · Architecture

The Intern and the Bureaucrat: How I Taught an LLM to Stop Writing My Code

Or: what happened when I tried to turn every API on the internet into an MCP server, and why the smartest thing my AI pipeline does is refuse to let the AI do most of the work.

It started with a third cup of coffee and a fourth MCP server

Here’s a confession: I love the Model Context Protocol. MCP is the thing that finally lets an AI agent pick up a tool call a real API, fetch real data, do real work through one clean, standard interface. Point any MCP-aware agent at an MCP server and suddenly it can talk to Stripe, GitHub, your internal inventory service, whatever.

Here’s the other confession: I was getting really tired of writing MCP servers.

Because every single one follows the same ritual. You open the API’s documentation. You squint at forty endpoints. You decide which fifteen actually deserve to be tools (an agent staring at 400 auto-generated tools is an agent having a very bad day). You hand-translate each one name, description, typed parameters, the HTTP call itself. You wire up the server. You run it. It crashes. You fix the import. You run it again.

Somewhere around server number four, a thought crept in that every engineer knows well:

Wait. I’m a human being, manually transcribing structured data from one machine-readable format into another. Why?

An OpenAPI spec is already a contract. It already knows that GET /users/{id} takes a required path parameter called id of type integer. The information is right there, sitting in YAML, bored out of its mind. All I was doing was moving it carefully, slowly, occasionally wrongly into Python.

So I did what any reasonable person in 2026 does. I decided to automate myself out of the job. The project became API2MCP: feed it any API description a pristine OpenAPI spec or a messy page of prose documentation, and get back a validated, downloadable, actually-runnable MCP server.

Simple, right?

Well. Let me tell you about the intern.

Act I: The intern who invents things

The obvious first draft of this project is one prompt long:

“Dear LLM, here’s an OpenAPI spec. Please write me a complete MCP server. Love, me.”

And honestly? The demo is dazzling. The model writes something that looks like a server. It has decorators. It has docstrings. It has vibes.

The spec says an endpoint takes customer_id. The intern's version takes customer_id and a limit parameter that appears nowhere in the documentation invented, presumably, because APIs usually have one, and the intern likes to be helpful. The spec says integer; the intern wrote string, and nothing will complain until an agent calls the tool at 2 a.m. Run the same prompt twice and you get two different servers, which makes testing a philosophical exercise. And every so often the output simply doesn't import a plausible-looking file that falls over the moment Python touches it.

None of this is the model being “bad.” It’s the model being what it is: a brilliant, probabilistic text engine being asked to do a job that requires certainty. I was paying tokens for the model to re-derive facts that already existed as structured data and re-derive them with a nonzero error rate.

That’s when the framing clicked, and it became the one-line constitution the entire project is built on:

Never ask an LLM to do what a parser can do reliably.

The intern is genuinely gifted. The trick is figuring out which desk to sit them at.

Act II: Enter the bureaucrat

Every good story needs a unsung hero. Meet the parser: unglamorous, pedantic, utterly incorruptible. The parser has never had an original thought in its life, and that is precisely its charm. Give it an OpenAPI spec and it will extract every path, method, parameter, and type the same way, every time, forever, for free.

API2MCP is essentially a workplace drama between these two characters, refereed by a state machine. The office looks like this:

Notice the dotted line to the LLM. Dotted, not solid, on purpose. The pipeline only walks over to the intern’s desk when it has a problem no deterministic tool can solve. Everything else stays with the bureaucrats.

And the pipeline itself a seven-node LangGraph reads like a shift roster. Green badges are deterministic staff. Blue badges are the intern, supervised:

Langgraph flow

Langgraph flow

Only three blue nodes. And as we’ll see, even those three wear handcuffs.

They all pass around a single, typed briefcase : the shared Pydantic state:

class PipelineState(BaseModel):
    raw_input: str
    input_kind: Literal["openapi", "prose"] | None = None
    endpoints: list[Endpoint] = []          # the facts
    tools: list[ToolSpec] = []              # the judgment calls
    generated_files: dict[str, str] = {}    # filename -> source
    validation_error: str | None = None
    repair_attempts: int = 0                # the leash

Every node reads the briefcase, does one job, and returns a partial update. This is also, incidentally, what powers the live pipeline rail in the UI, the frontend is literally watching the briefcase change hands.

Act III: A day in the pipeline

Scene 1: ingest, or: "is this YAML or a ransom note?"

First question when input arrives: is this a structured spec, or a wall of prose? You could ask the LLM. You could also just… check for a dictionary key.

def ingest(state: PipelineState) -> dict:
    try:
        doc = yaml.safe_load(state.raw_input)   # YAML is a superset of JSON
        if isinstance(doc, dict) and ("openapi" in doc or "swagger" in doc):
            return {"input_kind": "openapi"}
    except yaml.YAMLError:
        pass
    return {"input_kind": "prose"}

Cost: zero tokens. Error rate: zero. The bureaucrat’s opening move.

Scene 2: parse, where the bureaucrat shines

If it’s a spec, an actual parser walks it into normalized Endpoint models. Every type, every required flag, every path parameter lifted straight from the contract, no interpretation involved. This one node is where the entire "deterministic-first" bet pays out hardest: the single richest source of truth in the whole system never passes through a language model at all.

Scene 3: llm_extract, the intern's first real job

But what about prose docs? The kind that say, in flowing English, “To create an order, POST to /orders with a customer ID and a list of line items"? There is no parser on Earth for that sentence. This is genuine ambiguity natural language with structure hiding inside it and this is the intern's specialty.

Even so, the intern doesn’t get free-form output. It’s forced to fill in the exact same Pydantic Endpoint schema the parser produces:

EXTRACT_SYSTEM = """You extract API endpoints from documentation.
Return ONLY endpoints explicitly described in the text.
Do not invent parameters, paths, or methods.
If a detail is not stated, omit it."""

model = get_model().with_structured_output(EndpointList)  # schema-enforced

Two locks on this door. with_structured_output means anything malformed is rejected before it touches state, the model cannot hand back garbage-shaped data. And the prompt makes the job description explicit: you are an extractor, not an author. No inventing that helpful little limit parameter.

Here’s my favorite structural detail of the whole system: parse and llm_extract are two roads into the same city. Whether the input was rigorous YAML or a rambling blog post, both paths deposit identical Endpoint models into the briefcase. From this point on, the pipeline genuinely does not know or care which door the data came through. Downstream determinism doesn't depend on upstream luck.

Scene 4: curate, the one job that actually needs taste

Now, a plot point. Suppose the API has 200 endpoints. Should the MCP server expose 200 tools?

Absolutely not. An agent drowning in 200 tools spends its context window reading a phone book. What an agent wants is the fifteen tools that matter, each with a name and a one-line description written for a reasoning machine what it does, and when to reach for it. Choosing those fifteen, and writing those lines, is a judgment call. Judgment is the intern’s other legitimate specialty.

But… by now you can guess the pattern the intern chooses; the code binds:

CURATE_SYSTEM = """You are selecting which API endpoints become agent tools.
Reference endpoints by their integer index. Do not add endpoints."""

result = model.invoke([...])          # structured output, again
for choice in result.selections:
    ep = state.endpoints[choice.index]     # code looks up the REAL endpoint
    name = _dedupe(choice.name, seen)      # code resolves name collisions
    tools.append(ToolSpec(name=name, description=choice.description, endpoint=ep))

Two war stories from this scene, because every project has scars:

The floating tool. Early on, the model would occasionally return a beautifully named, beautifully described tool… attached to no endpoint at all. It had dropped the index. A name and a description, drifting free of any actual API call a ghost tool. The fix wasn’t a sterner prompt. The fix was making the index a required field on the schema and having the code look up the real endpoint from state, trusting nothing the model echoed back about it. The model votes; the code counts the votes.

The name feud. GET /users/{id} and GET /users both, quite reasonably, want to be called get_user. Rather than hoping the model notices the collision (it sometimes didn't), a deterministic _dedupe settles it: get_user, get_user_2. Boring. Correct. Done.

Scene 5: generate, or the intern does NOT get the keyboard

This is the scene where most “AI code generator” stories hand the intern a keyboard and pray. API2MCP does the opposite, and it’s the hill the whole project dies on:

The LLM never writes a single line of Python.

By the time we reach generate, the briefcase holds fully typed ToolSpec objects. Turning typed data into source code isn't a creative act it's rendering, and rendering is a solved problem called Jinja2:

{% for tool in tools %}
@mcp.tool()
def {{ tool.name }}(
    {%- for p in tool.endpoint.parameters %}
    {{ p.name }}: {{ p.py_type }}{% if not p.required %} = None{% endif %},
    {%- endfor %}
) -> dict:
    """{{ tool.description }}"""
    resp = client.request("{{ tool.endpoint.method.upper() }}",
                          f"{{ tool.endpoint.path_template }}", ...)
    resp.raise_for_status()
    return resp.json()
{% endfor %}

Think about what this deletes from the universe of possible bugs. Hallucinated imports: impossible. Invalid syntax: impossible. Type drift between spec and code: impossible. The generated server is exactly as reliable as the template and the template is ordinary code, written once, tested once, by a human who was paying attention.

Scene 6: validate, the trust-nothing checkpoint

But suppose something did slip through. A weird edge case, a template bug, a cursed endpoint name. “Looks right” is not a quality bar. So before any bundle leaves the building, it faces two cheap, ruthless, deterministic gates:

  1. Byte-compile every generated file syntax errors die here, instantly.
  2. Import the server in a subprocess bad references, broken imports, anything that explodes at module load dies here.
proc = subprocess.run(
    [sys.executable, "-c", "import server"],
    cwd=tmpdir, capture_output=True, text=True, timeout=30,
)
if proc.returncode != 0:
    return {"validation_error": f"import: {proc.stderr}"}

Why a subprocess instead of just importing in-process? Because the generated code is, by definition, untrusted isolating it keeps the app’s own namespace clean, and the captured stderr becomes a perfectly formatted bug report for the next scene.

Scene 7: repair, the intern's last chance (exactly two of them)

When validation fails, the pipeline doesn’t panic and it doesn’t loop forever. It walks back to the intern’s desk with the failing source and the exact error message and says: fix this specific thing. Then it re-validates. And critically it does this at most twice.

generate and validate

generate and validate

def route_after_validate(state: PipelineState) -> str:
    if state.validation_error is None:
        return "done"
    if state.repair_attempts >= MAX_REPAIRS:
        return "give_up"
    return "repair"

An unbounded repair loop is a token bonfire with a hang risk attached. Two attempts mops up nearly every transient hiccup; past that, a loud failure with a clean stack trace is worth more than an optimistic infinite spin. Even forgiveness has a budget.

The whole story in one diagram

One more character deserves a bow: the frontend. It’s a single HTML file. No bundler, no node_modules, no build step FastAPI just serves it. It shows the pipeline rail lighting up node by node, a review table of curated tools, tabbed previews of every generated file, and a download button. A project whose entire pitch is "the output just runs" should probably not require a fragile build pipeline to demonstrate itself. It felt like a matter of principle.

Final thoughts

If you skimmed everything and landed here, take this with you:

Never ask an LLM to do what a parser can do reliably.

The temptation with a powerful model is to hand it the whole job and applaud. The more durable move the senior move, I’d argue is to slice the problem into the parts that are genuinely ambiguous and the parts that merely look hard, give the model only the former, lock its outputs behind schemas, and independently verify everything it touches on the way out.

In this pipeline the LLM does exactly two jobs a parser cannot: reading structure out of human prose, and exercising taste about what deserves to be a tool. Everything else parsing, generating, checking belongs to boring, deterministic code that is never wrong twice in different ways.

Feel free to explore code: https://github.com/udaybhookya/mcpify

Author

Uday Chandra Bhookya linkedIn: https://www.linkedin.com/in/uday-chandra/


메타데이터
post_id
2385a224d0be
slug
the-intern-and-the-bureaucrat-how-i-taught-an-llm-to-stop-writing-my-code-2385a224d0be
url
https://medium.com/@bhookyauday/the-intern-and-the-bureaucrat-how-i-taught-an-llm-to-stop-writing-my-code-2385a224d0be
canonical_url
https://medium.com/@bhookyauday/the-intern-and-the-bureaucrat-how-i-taught-an-llm-to-stop-writing-my-code-2385a224d0be
author_url
https://medium.com/@bhookyauday
status
ok
fetched_at
2026-07-13 06:23:13