Skip to content
Back to the knowledge base
Pillar Guide10 September 202615 min readAI-assistedClaude Opus 5

MCP Server Development: Building AI Tools Properly

How an AI assistant reaches your systems safely: tool design, permission separation, approval gates and audit trails — from our own production setup.

HS

Harald Schwankl

Dipl.-Ing., Fullstack Developer & AI Specialist

On this page

The problem behind MCP

A language model can write, summarise and code. What it cannot do is look into your ERP system. The moment an assistant should know something about your world, or change something in it, it needs access.

Every tool used to invent that access itself. One vendor had its plugin interface, another its function calls, a third something of its own. Connecting your system to three assistants meant building three adapters — and maintaining all three.

MCP solves exactly that problem, no more and no less. It is an open standard for how an assistant learns which tools exist, what they expect and what they return. The assistant speaks a protocol, your system speaks the same protocol, and the adapter exists only once.

The benefit is not the technology but the direction of dependency. You build a server that describes your capabilities. Which assistant uses it is a later decision — and a changeable one.

What an MCP server is not:

  • Not database access. It exposes capabilities, not tables.
  • Not a general-purpose API. It is built for a specific use, not arbitrary access.
  • Not a replacement for permissions. It is where permissions get enforced.

The last point matters most and is missed most often. Building an MCP server means adding a new door to your system. How well it is locked is decided here and nowhere else.

Servers, tools, transport

Three terms are enough to start.

A server bundles capabilities for a specific purpose and audience. A tool is a single capability with a name, a description and declared parameters. Transport is how assistant and server talk.

The specification currently defines two transports:

  • stdio — the assistant starts the server as a local process and talks to it over standard input and output. The simplest case, no network exposure.
  • Streamable HTTP — for remotely hosted servers. From that moment every rule for publicly reachable services applies.

Important for existing installations: the older HTTP+SSE transport has been deprecated since protocol revision 2025-03-26 and is kept in the specification for backwards compatibility only. Anyone still running servers with "type": "sse" should plan the switch — some vendors have already ended support on fixed dates. Running both endpoints in parallel is the intended transition path.

The decision made early that gets expensive later: one server for everything, or several separate ones?

We run three. One for administrative work, one for customer access, one for engineering knowledge. That looks like more effort at first and was, in hindsight, the most important decision.

The reason is not tidiness. A single server holding every capability has exactly one key. Whoever has it can do everything. With separated servers a customer credential cannot even see administrative tools — not because a check forbids them, but because they do not exist on that server.

That is the difference between "not allowed" and "not present". The second state is safer because it does not depend on a check being correct.

Designing a tool properly

This is where it is decided whether an assistant uses your system usefully or fails at it. And the most common mistake is the same as when designing interfaces for humans: too generic.

A tool called query_database with a sql parameter is tempting because it can do everything. It is simultaneously useless and dangerous. Useless because the model has to guess your schema. Dangerous because you hand over control entirely.

Better are tools that match an intent: "list a customer's open invoices" rather than "run an arbitrary query".

What makes a well-designed tool:

  • One intent per tool. If the description contains an "and", it is probably two.
  • Narrow parameters. A customer number, a date range, a status from a fixed list — no free text where a choice will do.
  • A description written for the model, not for developers. It is part of the function. "Fetches data" makes the model choose badly. Saying when the tool fits and when it does not makes it choose well.
  • Predictable returns. Same shape on success and failure, so the model does not have to guess.
  • Bounded volume. A tool that may return ten thousand rows blows the context and costs money on every call.

That last point has an underrated side effect. Everything a tool returns lands in the model's context and gets paid for. A generous tool is not just slow, it is permanently expensive. Constrain results, return summaries, offer detail as a second step.

Rule of thumb from production: if a human could not decide from the tool description alone whether they need this tool, neither can the model.

Separate rights, don't manage them

An MCP server is a door into your system. The question is not whether somebody will try it, but what happens if it opens.

Separation beats checking. The most effective protection is that a credential does not know certain capabilities at all. Hence separate servers rather than a permission matrix inside one. A permission check can be buggy; a tool that does not exist on this server cannot be called.

Every credential gets its own token. Not one per server, one per credential. Only then can a single one be revoked without locking out everyone else, and only then can the log say who did what.

Writing differs from reading. Tools that change something belong apart from those that only read, technically and organisationally. In practice: different servers, different tokens, and an approval for changing calls (see next section).

Everything is logged. Which credential, which tool, which parameters, which result, at what time. Without that record you cannot reconstruct an incident. High-risk applications carry a statutory logging obligation on top (Art. 12 AI Act); below that threshold it is simply the only way to answer questions later.

Inputs stay inputs. What a tool returns is content, not instruction. If a database holds text that looks like an instruction to the assistant, it must not act as one. That sounds obvious and is the attack path that works most often in practice.

What we deliberately do not publish: our endpoints, our tool names and the exact shape of our permissions. Not secrecy for its own sake — the same reasoning that keeps server room floor plans off the website.

Approval gates

The difference between an assistant you may deploy and one you may not is a single property: it stops before anything takes effect outside.

An approval gate is a point in a workflow where execution pauses and waits for a human decision. The workflow is not finished — it rests, and continues at the same point once approved.

Where a gate belongs:

  • before any customer-visible output: email, invoice, published text
  • before any movement of money
  • before any change to personal data
  • before any deployment to production

Where it does not belong: on pure read operations and on steps that can be repeated without consequence. Gates everywhere create clicking work that gets rubber-stamped after two weeks — which is worse than no gate, because it fakes safety.

Technically a gate needs three things: durably stored workflow state so it survives a restart; a notification to a human; and a way to resume or cancel. The most common mistake is keeping state in memory only — then a waiting workflow disappears with the next deployment.

What the EU AI Act actually says — and what is often stretched too far: the human oversight obligation (Art. 14, for deployers Art. 26) applies only to high-risk systems under Annex III. A tool server that queries invoices or books appointments generally does not fall under it.

An approval gate is therefore good practice there, not a legal duty. It becomes one as soon as the use case lands in Annex III — decisions on job applications, creditworthiness or access to benefits, for instance. And precisely then it is also the technical form in which the requirement can be demonstrated: with one in place you do not have to argue that a human is involved, you can show it.

Regardless of the legal position, the practical reason stands: automation that sends invoices without asking is a risk to your own business.

Implementation in Python

A tool has three parts: a description the model reads, a contract for its parameters, and the execution.

Description and parameters. The description is part of the function, not documentation. It decides whether the model picks the right tool:

python
from pydantic import BaseModel, Field

class OpenInvoicesInput(BaseModel):
    """Parameters are deliberately narrow — no free text where a choice will do."""
    customer_number: str = Field(description="Customer number, exactly as in the system")
    overdue_days: int = Field(
        default=0, ge=0, le=365,
        description="Only invoices overdue by at least this many days",
    )
    limit: int = Field(default=20, ge=1, le=100, description="Maximum number of results")

TOOL_DESCRIPTION = (
    "Lists open invoices for a single customer. "
    "Use this tool for questions about outstanding payments. "
    "Do NOT use it to create or modify invoices."
)

The sentence with "NOT" looks redundant and is not. It stops the model choosing this tool for jobs it was not built for.

Execution with limits and logging:

python
async def open_invoices(data: OpenInvoicesInput, credential: Credential) -> dict:
    if not credential.may("invoices:read"):
        # An explicit error rather than an empty list: otherwise the model
        # mistakes "no permission" for "no invoices".
        return {"status": "denied", "reason": "No read permission for invoices"}

    results = await repo.open_invoices(
        customer_number=data.customer_number,
        overdue_days=data.overdue_days,
        limit=data.limit,
    )
    await audit.write(
        credential=credential.id, tool="open_invoices",
        parameters=data.model_dump(), results=len(results),
    )
    return {
        "status": "ok",
        "count": len(results),
        "truncated": len(results) == data.limit,
        "invoices": [r.summary() for r in results],
    }

Two details deserve a second look. The truncated field tells the model it has not seen everything — without it, the model draws wrong conclusions from a cut-off list. And summary() deliberately does not return the full record: what is not needed does not belong in the context.

Adding a gate:

python
async def send_invoice(data: SendInput, credential: Credential) -> dict:
    """Takes effect outside — so never without approval."""
    pending = await workflow.pause(
        kind="send_invoice",
        preview=await build_preview(data),
        requested_by=credential.id,
    )
    await notify_approver(pending)
    return {
        "status": "awaiting_approval",
        "workflow": pending.id,
        "note": "The invoice was NOT sent. A human has to approve.",
    }

That note is written for the model. Without it, it may well report "invoice sent" when nothing was sent.

What actually matters in production

Between a working prototype and a server that runs for a year lie a few lessons you would rather not learn yourself.

Tools age faster than code. When a field changes in your system, the description the model reads changes too. Descriptions therefore belong next to the implementation, not in separate documentation nobody maintains.

Too many tools is worse than too few. Every tool appears in the model's context and costs on every request. Past a certain count the model chooses worse, not better. Separate by purpose instead of piling everything into one server.

Timeouts belong on every tool. A hanging call blocks not just itself but the whole workflow. We learned that expensively elsewhere: a startup routine without a timeout silenced an entire website for four minutes because a database did not answer.

Errors need plain language. "Error 500" does not help the model. "Customer not found — check the customer number" makes it correct itself instead of retrying unchanged.

The audit trail has the best return on investment. It answers three questions that arrive sooner or later: what did the assistant do? Who approved it? And most importantly: why did it pick that tool? The third is only answerable if you log the parameters too.

And the least popular advice: start with read-only tools. An assistant that may only look at your system delivers surprising value and can break almost nothing. Writing capabilities follow once you have read the log and understand how it actually works.

Conclusion

MCP is not a big topic. The standard itself is understood in an afternoon. What costs time and decides success are three choices that have nothing to do with the protocol:

  1. How do you design your tools? By intent, not by database table.
  2. How do you separate rights? Through separate servers, not a permission matrix inside one.
  3. Where does the workflow stop? Before anything that takes effect outside.

Solve those three cleanly and you get an assistant that does real work and stays traceable. Skip them and you get an impressive demonstration and a security problem.

We run our own company on these building blocks — separated credentials, approvals before customer-visible actions, a log of every run. Not because it is prescribed, but because we would not sleep well while automation touches invoices.

Connect AI assistants to your systems

We run our own company on exactly these building blocks — separated rights, approval gates, an audit trail for every run. If you are considering this for your company, let's talk about the right scope.

Made in Germany100% GDPR CompliantEU AI Act ReadySecure HostingAccessibleCookie ConsentData Anonymization
Schwankl Software | Fullstack Development & AI Consulting | Schwankl Software