DEV Community

Cover image for How to Build an MCP Server for Internal Tools: Architecture, Authentication and Error Handling
Suresh B
Suresh B

Posted on

How to Build an MCP Server for Internal Tools: Architecture, Authentication and Error Handling

Most MCP tutorials stop at a local server that prints the weather. That is a fine way to see the protocol work, and a bad model for anything that touches your company's data.

An internal tools server is different. It runs somewhere other than your laptop, it answers to more than one user, and a mistake in it exposes real records. The protocol changed in 2026 to make that kind of server easier to build, and most of what you will find online still describes the old one.

This walks through the shape that holds up: what the server owns, how a model calls it, how you decide who is allowed to do what, and how errors should read to something that cannot ask a follow-up question.

Everything here runs on the MCP Python SDK 2.x against the 2026-07-28 protocol.

What the server owns, and what it does not

The most useful rule I know for MCP servers: the server is an adapter, not a place to put business logic.

Your internal API already knows how to look up an order, apply a discount or close a ticket. It already has the permission rules. The MCP server's job is to describe a few of those capabilities in a form a model can use, pass the call through, and translate the answer back.

That means:

  • One tool per business capability, not one tool per endpoint. search_orders is a capability. GET /v2/orders?filter= is an endpoint.
  • No new permission model. Decide authorization from the validated token on each call, using the rules your internal service already has.
  • No hidden state. If a piece of work spans two calls, the second call should carry everything it needs.

A server built this way stays boring, and boring is the goal. It also stays auditable: every tool maps to one capability, and every call carries an identity.

Architecture

Three roles, and they are easy to mix up:

  • The host is the application the user talks to, and the thing that owns the model.
  • The client lives inside the host and speaks MCP to exactly one server.
  • The server is what you are building.

For transport you have two realistic choices. stdio is for a server running as a local subprocess on the same machine as the host: no network, no authentication, ideal while developing. Streamable HTTP is what you want for anything shared, because it is an ordinary HTTP endpoint your platform team already knows how to run.

That last point is worth dwelling on, because it changed in 2026. Protocol sessions are gone from Streamable HTTP. There is no initialize handshake and no session id, which means every request is self-describing and any request can land on any instance. The full list of what the 2026-07-28 revision changed is worth reading before you port an older server. Your server sits behind a normal load balancer with no sticky routing, scales horizontally, and needs no shared session store.

If your server does need state across calls, make it explicit: return a handle from the first tool and accept it as an argument on the next. A handle is just a string your application understands, with an expiry and an owner. It survives a request landing on a different instance, which a session never did.

A server that does something real

Install the SDK:

pip install "mcp[cli]"
Enter fullscreen mode Exit fullscreen mode

Here is a server with two tools over a pretend internal orders service. The structure is what matters; substitute your own API calls.

from typing import Annotated

from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

mcp = MCPServer("internal-tools")


class Order(BaseModel):
    order_id: str = Field(description="Internal order identifier.")
    status: str = Field(description="Current fulfilment status.")
    total_cents: int = Field(description="Order total in cents.")


@mcp.tool()
def get_order(
    order_id: Annotated[str, Field(description="Order id, for example A-1001.")],
) -> Order:
    """Look up a single order by its identifier."""
    record = internal_api.fetch_order(order_id)
    if record is None:
        raise ToolError(
            f"No order {order_id!r}. Order ids look like A-1001. "
            "Use search_orders if you only know the customer."
        )
    return Order(**record)


@mcp.tool()
def search_orders(
    customer: Annotated[str, Field(description="Customer account name.")],
    limit: Annotated[int, Field(description="Maximum rows to return.", ge=1, le=50)] = 10,
) -> list[Order]:
    """Find orders belonging to one customer account."""
    rows = internal_api.search(customer=customer, limit=limit)
    if not rows:
        raise ToolError(f"No orders for customer {customer!r}.")
    return [Order(**r) for r in rows]
Enter fullscreen mode Exit fullscreen mode

Four things in that snippet do more work than they appear to.

The type hints are the input schema. The SDK builds it from your signature, so limit: int with ge=1, le=50 becomes a constraint the SDK enforces before your function body runs. A model asking for 999 rows gets a validation error back and never reaches your internal API.

The docstring is the description. This is the text the model reads when deciding whether to call your tool. It is documentation for a reader who cannot ask you what you meant, so write it for that reader.

The return type is the output schema. Declaring -> Order makes the SDK publish an output_schema alongside the input schema in tools/list, before the tool is ever called. The host gets machine-readable data rather than a sentence it has to parse.

Annotated[..., Field(description=...)] puts a description on individual arguments. Worth doing for anything whose meaning is not obvious from the name.

One detail to be aware of when you return a list: the structured result is wrapped in a result key, because a bare list is not a JSON object. search_orders above comes back as {"result": [{"order_id": "A-1001", ...}]}. Harmless once you know, confusing when you do not.

Authentication

For a shared server, this is the part worth slowing down for.

The model is not your user. The host is not your user. A token arrives with each request, and everything you decide flows from validating it yourself.

The SDK has no opinion about what a valid token looks like, which is correct: yours probably comes from your own identity provider. You implement one method.

from pydantic import AnyHttpUrl
from mcp.server import MCPServer
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings

RESOURCE = "https://tools.internal.example.com/mcp"


class MyTokenVerifier(TokenVerifier):
    async def verify_token(self, token: str) -> AccessToken | None:
        claims = await identity_provider.introspect(token)
        if claims is None:
            return None
        return AccessToken(
            token=token,
            client_id=claims["sub"],
            scopes=claims.get("scopes", []),
            resource=RESOURCE,
        )


mcp = MCPServer(
    "internal-tools",
    token_verifier=MyTokenVerifier(),
    auth=AuthSettings(
        issuer_url=AnyHttpUrl("https://auth.internal.example.com"),
        resource_server_url=AnyHttpUrl(RESOURCE),
        required_scopes=["orders:read"],
        validate_token_resource=True,
    ),
)
Enter fullscreen mode Exit fullscreen mode

Returning None rejects the request. That is the whole contract.

AuthSettings describes your server as a resource server. issuer_url is the authorization server that issues tokens, resource_server_url is this endpoint's public URL, required_scopes must all be present on every token, and validate_token_resource=True refuses any token whose audience is not this server.

Turn that last one on. Without it, a token minted for a different service with the same signature is accepted by yours, which is the confused deputy problem wearing a hat. The SDK will make it the default for resource servers in 3.0; there is no reason to wait.

From those settings, the SDK serves an RFC 9728 Protected Resource Metadata document, which is how a client that has never seen your server discovers where to get a token:

{
  "resource": "https://tools.internal.example.com/mcp",
  "authorization_servers": ["https://auth.internal.example.com/"],
  "scopes_supported": ["orders:read"],
  "bearer_methods_supported": ["header"]
}
Enter fullscreen mode Exit fullscreen mode

Scopes are not enough

Scopes answer "may this caller use this tool". They do not answer "may this caller see this row".

That second question has to be answered inside the tool, per call, from the validated identity:

from mcp.server.auth.middleware.auth_context import get_access_token


@mcp.tool()
def search_orders(customer: str, limit: int = 10) -> list[Order]:
    """Find orders belonging to one customer account."""
    token = get_access_token()
    rows = internal_api.search(
        customer=customer,
        limit=limit,
        acting_as=token.client_id,   # the internal API applies its own rules
    )
    return [Order(**r) for r in rows]
Enter fullscreen mode Exit fullscreen mode

Push the identity down to the service that already owns the permission rules, rather than reimplementing them in the adapter. Filter before you fetch, not after: data the caller may not see should never be loaded into a variable in your process, let alone into a model's context. That is the same constraint that shapes retrieval design in production RAG systems, where the permission filter has to live inside the query rather than in a pass over the results afterwards. It is the kind of authorization aware retrieval work that is far cheaper to design in than to retrofit, and it is worth more than any amount of prompt-level guarding.

Error handling

Errors in an MCP server have an unusual audience: a model, which cannot ask you a clarifying question and will try something else based only on what you wrote.

There are three outcomes, and the difference matters.

Raise ToolError when the tool ran correctly and the answer is a problem the caller could fix. The request succeeds at the protocol level and returns a result with is_error set to true, carrying your message where the model reads it. The structured content is empty, because a failed call has no value to structure.

Raise MCPError when the protocol itself is the problem, not the tool.

Let anything else escape and it is a crash. The model learns only that the call failed; your log gets the traceback. That is the correct behaviour for an unexpected bug, and the wrong outcome for anything you could have anticipated.

The difference between a good and bad tool error is entirely in the message. Compare:

Error executing tool get_order: 404
Enter fullscreen mode Exit fullscreen mode
Error executing tool get_order: No order 'NOPE'. Order ids look like
A-1001. Use search_orders if you only know the customer.
Enter fullscreen mode Exit fullscreen mode

Both are one line. The second tells the model what went wrong, what the right shape looks like, and which tool to try instead. It will often recover on the next turn without the user seeing anything.

Write error messages as instructions to a competent colleague who cannot see your code.

Two more cases worth handling deliberately: an expired handle should say so explicitly rather than returning empty results, and a timeout against a slow internal service should say the work may still be running, because a model that assumes failure will retry an operation that already happened.

Running it

For local development against a host on the same machine:

if __name__ == "__main__":
    mcp.run()                       # stdio
Enter fullscreen mode Exit fullscreen mode

For a shared deployment:

if __name__ == "__main__":
    mcp.run(transport="streamable-http")
Enter fullscreen mode Exit fullscreen mode

The SDK also exposes a Streamable HTTP ASGI application, so you can mount the server inside an existing FastAPI or Starlette service rather than running a separate process. For internal tooling that is often the least disruptive option: the MCP endpoint lives beside the API it wraps, behind the same ingress and the same auth infrastructure.

Test with the Inspector before you point a model at it. mcp dev server.py starts your server with an interactive UI where you can read the generated schemas and call tools by hand. Most schema mistakes are obvious there and invisible once a model is in the loop.

Failure modes worth knowing about

  • Buffering proxies. Streamed responses depend on the proxy passing events through as they arrive. An nginx-style proxy that buffers will make a working server look dead.
  • Tool name collisions. A host aggregating several servers may see two search tools. Name yours for the domain, not the verb.
  • Schema drift. Your tool signature is a published contract. Changing an argument's meaning without changing its name breaks callers silently.
  • Trusting annotations. Tool annotations from another server are hints, not security. Never let one decide whether an action is safe.
  • Forwarded tokens. Do not pass the caller's token through to unrelated downstream services. Exchange it for one scoped to the specific thing you are calling.
  • Origin validation. For HTTP servers, validate the Origin header and bind local servers to localhost, or a web page can reach a server that was only meant to be local.

Readiness checklist

Area Ready when
Scope One tool per business capability; no business logic that belongs in the internal API
Schemas Input constraints on every argument; a declared return type; descriptions written for a model
State No dependence on a session; cross-call state is an explicit handle with an expiry and an owner
Authentication Tokens verified on every call; audience validation on; scopes required
Authorization Row-level decisions made per call from the validated identity, by the service that owns the rules
Errors ToolError for anything the caller can fix, with a message naming the fix; crashes logged, not leaked
Transport stdio locally, Streamable HTTP when shared; Origin validated; proxies pass events unbuffered
Operations Rate limits; idempotency on writes; logs that record identity and tool, not payloads

Build the smallest server that exposes one capability, wire the token check, and call it from a real host before adding a second tool. The gap between a demo server and an internal one is almost entirely in the parts that have nothing to do with the model, which is the same gap the production readiness checklist for LLM applications covers for the system around it.

Drafted with AI assistance and reviewed, edited and approved by the author.

Top comments (0)