To build a secure MCP server for an enterprise API, start with a small business capability, enforce permissions before calling the API and return only the fields the caller needs. Use an official SDK for protocol handling. Keep credentials and authorization decisions outside model-generated arguments, and test rejected requests as carefully as successful ones.
This guide provides a Python implementation of a fictional tenant-isolated ticket lookup. The core authorization function and its tests run without a network connection. An MCP adapter exposes that function locally. A separate deployment checklist explains what remains before connecting a real API or exposing a remote endpoint. The example is deliberately read-only: it is not a production authentication system or a claim that installing an SDK secures an integration.
Implementation decisions at a glance
- Use a fixed tool contract such as
get_ticket_summary; do not accept arbitrary URLs, SQL or shell commands. - Derive the principal from a trusted runtime or validated authentication, never a tool parameter.
- Separate the permission check from protocol code so it can be tested directly.
- Use a consistent external response for records that are missing or inaccessible where existence itself is sensitive.
- For remote deployment, implement the applicable MCP authorization flow and verify the actual host/SDK combination.
In this tutorial
1. Define a bounded enterprise API capability
Suppose a support assistant needs to summarize the status of a ticket for an authorized specialist. The tool needs a ticket identifier. It does not need a destination URL, an access token, an arbitrary tenant identifier or a query language. Those additional arguments would let a model propose authority or destinations that should be established by trusted code.
| Contract element | Illustrative decision | Why it matters |
|---|---|---|
| Input | A ticket ID in the format T-1234 | A small validation surface |
| Identity | A principal supplied by the runtime | The model cannot select another user |
| Permission | Ticket-read scope and matching tenant | Both operation and record boundary are checked |
| Output | ID and status only | Contact details and internal notes are unnecessary |
| Failure | Unavailable for missing or inaccessible records | The lookup need not reveal another tenant’s records |
For the protocol roles and overall request path, read the MCP architecture guide. Here the focus is the executable boundary inside the server.
2. Implement permission checks independently of MCP
Save the following as ticket_service.py. All identities and records are fictional. In this example a frozen principal contains the already-established tenant and scopes; it is not a substitute for verifying a real credential.
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class Principal:
tenant: str
scopes: frozenset[str]
RECORDS = {
"T-1042": {"tenant": "alpha", "status": "awaiting review"},
"T-2042": {"tenant": "beta", "status": "in progress"},
}
def lookup_ticket(principal: Principal, ticket_id: str) -> dict:
if not re.fullmatch(r"T-[0-9]{4}", ticket_id):
raise ValueError("Invalid ticket ID")
if "tickets:read" not in principal.scopes:
raise PermissionError("Read permission required")
record = RECORDS.get(ticket_id)
if record is None or record["tenant"] != principal.tenant:
return {"available": False}
return {"available": True, "id": ticket_id,
"status": record["status"]}
The function checks the operation permission before exposing a record and checks tenant ownership before returning fields. Returning a newly constructed dictionary also prevents accidental exposure of the complete internal record. A production implementation may need more granular rules than tenant equality, including record sharing, team membership or purpose restrictions. Keep those rules in the authoritative business service when it owns them.
3. Expose the function through an MCP adapter
Save this as server.py next to the core file. The local adapter uses a fixed fictional principal so that the example is reproducible without credentials. Never expose this fixed identity as a shared remote service.
from mcp.server.fastmcp import FastMCP
from ticket_service import Principal, lookup_ticket
mcp = FastMCP("Tenant ticket lookup demo")
DEMO_PRINCIPAL = Principal("alpha", frozenset({"tickets:read"}))
@mcp.tool()
def get_ticket_summary(ticket_id: str) -> dict:
"""Read a permitted fictional ticket's ID and status."""
return lookup_ticket(DEMO_PRINCIPAL, ticket_id)
if __name__ == "__main__":
mcp.run(transport="stdio")
Create an isolated Python environment and install a compatible official mcp SDK version. Record the resolved version in the project lock file. Configure the host to launch that environment’s interpreter with the absolute path to server.py. Follow the official server tutorial and Python SDK documentation for your selected release’s installation and host configuration.
Protocol traffic uses standard output in this local transport. Send ordinary diagnostics to standard error. If tool discovery fails, first check the interpreter path, environment dependencies and process errors before changing the authorization function.
What changes for a remote endpoint?
A remote service needs real authentication and request-scoped identity. Replace the demonstration principal with identity derived from validated authorization context, then carry that context to the business permission check. Do not simply accept tenant_id or scopes from the tool call. A transport change alone does not implement this boundary.
The MCP authorization specification describes authorization for HTTP-based transports. Use its supported discovery and token-validation behavior through a reviewed implementation. Distinguish an access token intended for the MCP server from credentials needed by a downstream business API; do not blindly forward an incoming bearer token to another service.
4. Test denied access, missing records and invalid input
Save this as test_ticket_service.py. These standard-library tests exercise the core rules without starting an MCP host.
import unittest
from ticket_service import Principal, lookup_ticket
class TicketAccessTests(unittest.TestCase):
def setUp(self):
self.reader = Principal("alpha", frozenset({"tickets:read"}))
def test_allowed_record(self):
self.assertEqual(lookup_ticket(self.reader, "T-1042"),
{"available": True, "id": "T-1042",
"status": "awaiting review"})
def test_other_tenant_is_unavailable(self):
self.assertEqual(lookup_ticket(self.reader, "T-2042"),
{"available": False})
def test_missing_record_is_unavailable(self):
self.assertEqual(lookup_ticket(self.reader, "T-9999"),
{"available": False})
def test_scope_is_required(self):
with self.assertRaises(PermissionError):
lookup_ticket(Principal("alpha", frozenset()), "T-1042")
def test_invalid_identifier(self):
with self.assertRaises(ValueError):
lookup_ticket(self.reader, "https://example.invalid/")
if __name__ == "__main__":
unittest.main()
Run python -m unittest -v test_ticket_service.py from the example directory. The five core tests passed during preparation of this article. That result covers the in-memory permission logic only. It does not validate OAuth, a remote transport, an MCP host, a real enterprise API or production tenant isolation.
After attaching the MCP adapter to a host, repeat the same cases through the protocol. Also test malformed argument types, interrupted connections, a host reconnect and a changed tool definition. A successful direct function test should not be mistaken for a successful end-to-end integration test.
5. Replace the demonstration store with a real API
Keep the tool signature narrow when replacing the store. Configure an approved upstream base address server-side, use a supported client and construct the record path from validated identifiers. Do not let a tool argument override the API host or choose a redirect destination.
Decide explicitly whether the API request uses delegated user authority or a service identity. If a service identity has broad access, the adapter still needs an enforceable record-level authorization path. An application’s successful login does not prove the caller can access every record the service credential can retrieve.
Handle upstream failures deliberately
- Timeout: stop within an agreed deadline and report that current information could not be retrieved.
- Permission failure: deny the lookup rather than retry with a broader identity.
- Rate limit: apply bounded backoff according to the upstream contract; avoid synchronized retry storms.
- Unexpected response: validate the expected schema and reject malformed data.
- Partial information: identify what is missing rather than present a complete-looking summary.
Even read operations can create load or trigger upstream behavior, so confirm retry semantics with the API owner. For future write capabilities, add idempotency and explicit approval requirements before enabling automatic retries.
6. Deployment checklist for an enterprise service
The local example becomes a deployable service only after the surrounding controls are implemented. Treat the following as acceptance criteria to review with the identity, business API and operations owners.
| Area | Required evidence |
|---|---|
| Identity | Invalid, expired and wrong-audience credentials are rejected; caller context cannot be replaced by a tool argument |
| Authorization | Cross-tenant, unshared-record and revoked-access cases fail through the full request path |
| Networking | Only intended ingress and upstream destinations are reachable |
| Secrets | Credentials are managed outside prompts and source files; ownership and rotation are defined |
| Reliability | Timeouts, concurrency, upstream rate limits and recovery behavior are tested |
| Audit | Correlation IDs and authorization outcomes are available without logging access tokens or unnecessary record contents |
| Release | Dependencies and tool contracts are versioned; compatibility tests and rollback are available |
Apply a content trust boundary too. Retrieved text may be relevant business data while containing malicious instructions. It cannot grant permission to call other tools or change the deployment configuration. Review the official MCP security guidance alongside the organization’s existing API security controls.
Troubleshooting common implementation failures
Works locally, fails in the host: verify the host launches the same interpreter and environment. Tools are discoverable but calls fail: compare the published input schema with the actual arguments. Another tenant’s record appears: stop rollout and inspect the trusted principal and record permission path. Logs contain secrets: restrict access, follow the credential incident process and correct logging before resuming.
For a Python-versus-TypeScript decision, choose the language your team can operate and test reliably, then confirm the required authentication and transport support in its official SDK. A familiar runtime with strong tests is more useful than selecting a language because an example is shorter.
Common questions about building an MCP server
How do you deploy an MCP server for business applications?
Can I pass the tenant ID as a tool argument?
Is a local stdio server automatically secure?
Should the MCP server return the complete API response?
Are the tutorial’s tests sufficient for production?
Scope an integration that your team can operate
Discuss an AI agent integration assessment with Prominent AI Services LLP. Bring the intended capability, API owner, permission model and expected failure cases so the implementation starts with a reviewable boundary.
Technical references reviewed 2 November 2026. Demonstration records are fictional; no customer system was accessed for the example.
