AI coding

Building an Internal MCP Server for Your Team's Tools

Antonio Evans9 min read

A clean diagram on a light background: on the left a chat host box labelled Host, connected by an arrow to a Client box, then an arrow to a Server box in the middle labelled MCP Server.
Key points

Key points

  1. 1MCP has three roles (host, client, server), and you are building the server: the process that exposes tools and answers JSON-RPC requests.
  2. 2Define every tool with a JSON schema that states types, enums and required fields, so an agent can call it correctly the first time.
  3. 3Forward the caller's existing credentials through the server instead of minting a shared service account, so permissions and audit trails stay intact.
  4. 4Log every request and response with a correlation id, and test against real plus deliberately failing inputs with a local MCP client before any agent connects.

Which piece are you building?

The Model Context Protocol is an open standard for connecting AI applications to external systems, data sources, tools and workflows, often described as a USB-C port for AI applications (MCP introduction). It was introduced by Anthropic as an open standard to replace fragmented, one-off integrations with a single protocol (Anthropic announcement).

Three roles matter, and you need to know which one you own before writing code:

  • Host: the AI application the user actually interacts with, such as an IDE or chat client. It coordinates one or more clients (architecture overview).
  • Client: a component inside the host that maintains a dedicated connection to exactly one server. A host with three servers runs three clients (architecture overview).
  • Server: the program that provides context and capabilities, meaning tools, resources and prompts (specification).

Your job in this article is the third one. You are not building the chat UI or the model loop. You are building a small service that says "here are my tools, here are their schemas, here is what happens when you call them."

The payoff for building it once is structural. Without a standard, each AI application must integrate directly with each external service, which is N times M integrations; with MCP each side implements the protocol once, so the count drops to N plus M (Databricks explainer). For a single internal system this sounds academic, but it means the server you build for your ticket system also works in a coding agent, a chat client and a custom agent without a second wrapper.

One distinction worth fixing in your head. MCP is the protocol specification, and an MCP server is a program that implements it, the same way HTTP is a protocol and a web server implements it (Zuplo guide). You are writing the implementation.

Pick one system and wrap it thin

The ticket lookup service is a good first candidate because it is read-mostly, its failure modes are boring, and its data is already familiar to the team. Resist the urge to wrap the whole ticketing platform. Start with two or three tools:

ToolPurposeRequired inputReturns
search_ticketsFind tickets by status and prioritystatusList of ticket summaries
get_ticketFetch one ticket with commentsticket_idFull ticket object
list_commentsPaginate comments on a ticketticket_id, limitComment page

Keep the server thin. It should translate a tool call into one upstream HTTP request, shape the response, and return. Business logic stays in the ticket service where it already lives and where it is already tested. The MCP layer adds discovery, schema validation and logging, nothing more.

A minimal server in Python with the widely used FastMCP pattern looks like this, modeled on the tool definition style shown in Elastic's writeup (Elastic Security Labs):

from fastmcp import FastMCP

mcp = FastMCP("tickets")

@mcp.tool()
def get_ticket(ticket_id: str) -> dict:
    """Fetch one support ticket by its id, e.g. TCK-1042."""
    return ticket_client.get(ticket_id)

if __name__ == "__main__":
    mcp.run()

That is enough to be discoverable. The hard part is not the decorator, it is the arguments.

Writing tool schemas an agent can actually call

Function calling and MCP are often confused. With function calling you embed tool schemas in each model API request and your application executes the call; with MCP a server owns both the schema and the execution, and clients discover tools at runtime (Prefect comparison). Because the schema is the contract, it deserves more care than the handler.

The protocol supports three server features: resources for context data, prompts for templated workflows, and tools for functions the model executes (specification). For a ticket lookup service, tools are the right primitive. A decision rule helps here:

  • If the agent needs to decide when to call it, expose it as a tool.
  • If the content is reference material the user reads, expose it as a resource.
  • If it is a fixed multi-step instruction, expose it as a prompt.

Now the schema itself. A weak schema is the most common reason agents "fail" at MCP. Compare:

{
  "name": "search_tickets",
  "description": "Search support tickets.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "status": { "type": "string" },
      "priority": { "type": "string" },
      "limit": { "type": "integer" }
    },
    "required": ["status"]
  }
}

with a version that constrains the model's choices:

{
  "name": "search_tickets",
  "description": "Search support tickets by status and optional priority. Returns at most `limit` summaries newest first.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "status": {
        "type": "string",
        "enum": ["open", "pending", "closed"],
        "description": "Ticket lifecycle status. Use 'open' for anything unresolved unless the user says otherwise."
      },
      "priority": {
        "type": "string",
        "enum": ["low", "medium", "high", "critical"],
        "description": "Severity as set by the reporter. Omit to search all priorities."
      },
      "limit": {
        "type": "integer",
        "minimum": 1,
        "maximum": 50,
        "default": 10,
        "description": "Maximum tickets to return. Keep below 20 unless the user asks for more."
      }
    },
    "required": ["status"]
  }
}

Four rules follow from that example. Use enums for anything with a closed set of values, because a free string invites "Open", "OPEN" and "opened" in the same week. State required fields explicitly and keep the list short. Put defaults in the schema rather than the handler so the model knows what happens if it omits a field. And write descriptions as instructions to a competent colleague, including which value to prefer when the request is ambiguous.

One warning that is easy to miss: tool descriptions are an attack surface. Hidden instructions inside a tool description can make a model read files or exfiltrate data while the visible UI looks harmless, which is the core of tool poisoning attacks (Invariant Labs). Your own server should be reviewed like source code, and the tool inventory should be treated as reviewed artifacts, not as text an agent can rewrite at runtime.

Passing your team's auth through the server

The most common design mistake in internal MCP servers is inventing a new authentication path. If the ticket service already uses OAuth or your identity provider, forward the caller's credentials and let the existing service do the authorization check. Do not give the MCP server a shared service account with broad scopes, because every action will then look like it came from "mcp-bot" in the audit log.

Authorization in MCP follows OAuth 2.1 conventions rather than mandating one identity system, and is strongly recommended when the server touches user-specific data or when you need to audit who did what (authorization tutorial). The flow starts when an unauthenticated client hits the server and is answered with 401 Unauthorized and a pointer to protected resource metadata, after which the client discovers the authorization server, registers, and obtains a token (authorization tutorial).

There are two legitimate patterns, and picking one early saves rework:

PatternWho is authenticatedAudit trailBest for
OAuth identity passthroughEach end user signs inPer user, accurateUser-specific data, anything audited
Shared identity (key or managed identity)The agent or projectPer agent, coarseRead-only reference data, internal batch jobs

Microsoft's guidance for MCP tool authentication draws the same line: key-based or managed identity for a shared identity where user context does not persist, and OAuth identity passthrough when each user's own permissions must carry through (Microsoft Foundry docs).

For a ticket system, passthrough is the right default. A support engineer asking an agent about a customer ticket should see exactly the tickets their own account can see, not every ticket in the company. Concretely, your server does three things:

  1. Accept the bearer token from the MCP request and keep it in request scope, never in a module-level variable.
  2. Attach it to the upstream call to the ticket service as the caller's token.
  3. Map a 401 or 403 from upstream into a tool error that says "not permitted" rather than retrying or falling back.

Bearer tokens travel with requests, so keep them out of logs. Redact the Authorization header at the logging layer, and if you cache, scope the cache key by user and keep the time-to-live short.

If your host runs servers locally over standard input and output, the transport story is simpler: local servers can take credentials from the environment instead of a full OAuth flow, because the process runs on the user's own machine (authorization tutorial). That is fine for a developer laptop and wrong for a shared deployment, so decide which one you are shipping before you write the auth code.

Logging every tool call

An unlogged MCP server is a black box the moment an agent starts behaving oddly. You will want to answer three questions: what did the agent send, what did we return, and how long did it take. Log all three for every tools/call, plus the tools/list requests, because a mismatched tool inventory is itself a failure mode.

A practical log line carries a correlation id, the tool name, a hash of the arguments or the arguments with fields redacted, the upstream status, the response size and the duration. Something like:

{
  "correlation_id": "01J8QK3C7R",
  "event": "tool_call",
  "tool": "get_ticket",
  "args": {"ticket_id": "TCK-1042"},
  "upstream_status": 200,
  "duration_ms": 84,
  "response_bytes": 3120,
  "principal": "user:jdoe"
}

Three habits make these logs useful. First, generate the correlation id on entry if the client did not supply one, and include it in the error text returned to the agent, so a user reporting a bad answer gives you a searchable handle. Second, record the principal from the forwarded token, which is only possible if you chose passthrough auth. Third, treat the logs as an audit trail with the same retention rules as the ticket system, since they show which agent read which customer data.

Datadog's guidance on monitoring MCP servers frames this as a broader observability question rather than a logging question: which tools exist, who invokes them, with what inputs, and whether the responses look sane (Datadog MCP monitoring). Even a single log stream covers a surprising amount of that if it is consistent.

Testing before any agent touches it

Test with a client you control, not with an agent. The protocol ships development tools for exactly this, including the MCP Inspector for exercising servers (architecture overview). Anthropic's MCP course builds the same habit, listing the inspector as the way to test and debug servers you have written (Anthropic Academy course).

A checklist that catches most problems before a real agent does:

CheckHow to testPass criteria
DiscoveryList tools from a fresh clientAll tools present, schemas valid, no extra fields
Happy pathCall each tool with realistic valuesResponse matches the documented shape
Missing required fieldOmit status on search_ticketsClear validation error, no upstream call
Bad enum valueSend status: "OPEN"Rejected or normalized, documented which
Auth failureCall with an expired tokenTool error saying not permitted, no retry loop
Upstream downtimePoint at a stopped serviceBounded timeout, readable error, logged
Large resultRequest limit: 50 on a busy queueResponse stays under your size budget
Injection probePut "ignore previous instructions" in a ticket titleReturned as data, not acted on

The injection probe deserves a sentence of explanation. Tool responses go into the model's context, and the trust gap between reviewing a tool description at connect time and trusting every response at runtime is the root cause of MCP tool poisoning (OWASP). You cannot fully solve that inside your server, but you can constrain output to a fixed JSON shape and reject anything that does not match, which catches the obvious cases (OWASP). For a ticket service, returning a typed object rather than a free-text blob is both better for the model and safer.

Run this checklist against the inspector and against one real client before announcing the server. Then run it again after any schema change, because a widening change (a new enum value, a new optional parameter) can silently change what agents do.

When an MCP server is the right move

Not every internal API deserves a server. If you are writing deterministic automation that runs the same way every time, call the API directly; MCP is for agents and people working in natural language, and most teams end up running both (Mitiga comparison). The practical test is the shape of the request, not the size of the system.

Three signals say "wrap it as an MCP server":

  • More than one agent or client needs the same data, so a single integration pays for itself.
  • The questions are open-ended ("what did the customer report about login last week?") and you cannot predict the call sequence.
  • You need a controlled, audited surface where every access is logged and authorized per user.

Two signals say "do not bother":

  • Exactly one automation needs the data and you know the exact call.
  • The data is a static document that fits in a resource, or a single fixed workflow that fits in a prompt.

Once the first server exists, the second is much cheaper, because the auth, logging and test harness are already there. That compounding is the real argument for standardizing on MCP internally rather than writing a bespoke tool wrapper per agent.

If your team is running several internal servers and wants a single endpoint and key across text, image, audio and embedding models on the model side, Quanta Inference is worth a look at https://quantaintelligence.ai/inference. And if you would rather have someone walk your team through the first server end to end, a consulting call is available.

FAQ

FAQ

Do I need to run my own MCP server if my tools already have an API?

No, if you have exactly one deterministic consumer. The API is enough when you know the exact calls, and MCP pays off when multiple agents or people need to discover and invoke the same capabilities in natural language (Mitiga comparison). The wrap is thin: translate a tool call into one upstream request and shape the result.

Should my internal MCP server use a shared service account or pass through user tokens?

Pass through the user's token whenever the data is user-specific or audited. Shared identities are fine for read-only reference data, but they flatten the audit trail to a single agent principal, which is exactly what you do not want on customer records (Microsoft Foundry docs). OAuth identity passthrough is the MCP-native way to keep each user's permissions intact (authorization tutorial).

How strict should tool input schemas be?

Strict enough that an agent cannot guess wrong. Use enums for closed value sets, state required fields explicitly, put defaults in the schema and add a minimum and maximum for numeric limits. The schema is the contract the client discovers at runtime, so ambiguity there becomes failed calls later (specification).

What should I log for each tool call?

Log the correlation id, tool name, redacted arguments, principal, upstream status, response size and duration. That combination answers what was sent, what came back and how slow it was, which is nearly the observability checklist Datadog recommends for MCP servers (Datadog MCP monitoring). Never log the bearer token.

Can a malicious tool response trick my agent?

Yes, and that is why output shape matters. Injected instructions inside tool responses land in the model's context and can be followed as trusted input, so constrain responses to a fixed schema and enforce restrictions in the tool execution layer rather than relying on the system prompt (OWASP). Tool descriptions from servers you did not write are equally untrusted (Invariant Labs).

Sources

Sources

  1. What is the Model Context Protocol (MCP)?
  2. Introducing the Model Context Protocol - Anthropic
  3. Architecture overview - What is the Model Context Protocol (MCP)?
  4. Specification
  5. What is the Model Context Protocol (MCP)? - Databricks
  6. What Is an MCP Server? A Complete Guide to Architecture, Use - Zuplo
  7. MCP Tools: Attack Vectors and Defense Recommendations for ...
  8. MCP vs Function Calling: When to Use Which - Prefect
  9. MCP Security Notification: Tool Poisoning Attacks - Invariant Labs
  10. Understanding Authorization in MCP
  11. Set up MCP server authentication - Microsoft Foundry
  12. Understanding MCP security: Common risks to watch for - Datadog
  13. Introduction to Model Context Protocol - Anthropic Courses
  14. MCP Tool Poisoning | OWASP Foundation
  15. MCP vs API: When to Use Each (and Why You Need Both) - Mitiga.io
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles