[01]The article
An MCP server is a typed, capability-negotiated interface that lets an AI client discover and invoke external tools, read contextual resources, and retrieve reusable prompts. A production-quality server is not merely a set of functions behind JSON-RPC: it is a security boundary, schema contract, and operational service whose behavior must remain predictable when a model calls it incorrectly.
Key Takeaways
MCP uses a host-client-server architecture. The AI application is the host, each configured integration is an MCP client connection, and the MCP server exposes tools, resources, and prompts through JSON-RPC messages.
Tools perform actions; resources provide context; prompts package workflows. Treating all three as callable functions creates ambiguous interfaces, excessive permissions, and poor model behavior.
The server should validate every tool argument independently of the model. JSON Schema constrains the wire format, but business validation must enforce authorization, bounds, state transitions, and tenant isolation.
stdio is the simplest local transport, while Streamable HTTP is the production remote transport. The transport decision determines authentication, concurrency, process management, observability, and deployment architecture.
Capability negotiation is part of initialization, not an optional handshake. The client and server exchange protocol versions and supported capabilities before normal requests such as
tools/listorresources/read.Security requires both protocol controls and domain controls. Authentication identifies the caller; authorization determines which tools, resources, records, and side effects that caller may access.
A deployable MCP server needs protocol-level tests. Unit-testing the underlying business function does not prove that schemas, error envelopes, pagination, capability declarations, or transport behavior work with a real MCP client.
What Is an MCP Server and How Does Its Architecture Work?
The Model Context Protocol (MCP) is an open protocol for connecting AI applications to external context and capabilities. An MCP server exposes tools, resources, and prompts; an MCP client embedded in an AI host discovers those primitives and makes JSON-RPC requests on the model’s behalf. Initialization negotiates protocol versions and capabilities before normal traffic begins.
MCP standardizes the boundary between an AI application and an integration. The model does not normally open a database connection or call an HTTP API directly. Instead, the host decides which MCP server to connect to, the client maintains that connection, and the model receives descriptions of available operations in a form it can select.
The protocol uses JSON-RPC 2.0 messages. A request has an identifier and a method:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {
"city": "London"
}
}
}
The server returns either a result or a protocol error associated with the same request identifier. Notifications do not have an identifier and do not receive a response. This distinction matters for logging and retries: a client can safely correlate a request-response pair, but a notification is not an operation with an observable result.
MCP primitives
A tool is a model-invocable operation such as create_ticket, query_orders, or run_report. Its input schema describes arguments, and its result should contain machine-readable content plus enough human-readable context for the model to interpret the outcome.
A resource is addressable data identified by a URI, such as file:///repo/README.md, postgres://analytics/schema/orders, or git://repository/commit/abc123. Resources are generally read-oriented context. A server may advertise resource templates when the URI contains variable components.
A prompt is a reusable, parameterized prompt template. It helps a client construct a workflow such as “review this pull request” or “summarize these incident logs” without forcing the server to execute the workflow itself.
| Primitive | Primary question | Typical operation | Side effects |
|---|---|---|---|
| Tool | “What action can the model request?” | tools/call |
Possible, including writes |
| Resource | “What context can the client read?” | resources/read |
Normally none |
| Prompt | “What reusable instructions can the client use?” | prompts/get |
None |
The separation is architectural rather than cosmetic. A resource should not secretly mutate a database, and a tool should not masquerade as a static document merely because the client can read its output. Clear semantics improve consent prompts, audit logs, caching, and model selection.
Host, client, and server
The host is the AI application, such as an IDE, desktop assistant, or agent runtime. It owns the model conversation and commonly enforces user consent. The host creates one MCP client per server connection.
The MCP client manages protocol state for one server: transport framing, initialization, capability negotiation, request correlation, cancellation, and connection errors. A host can connect to several servers simultaneously, but each client-server session remains a distinct protocol relationship.
The MCP server owns integration logic and credentials for its domain. It should expose the smallest useful interface rather than mirror every endpoint in an upstream API. A GitHub server might expose search_issues and create_issue, not hundreds of thin wrappers that force the model to reconstruct GitHub’s REST API.
Initialization and capability negotiation
A client begins with initialize, including its supported protocol version and client capabilities. The server responds with the selected protocol version, server information, and server capabilities. The client then sends notifications/initialized.
Conceptually, the exchange resembles:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {
"roots": {
"listChanged": true
}
},
"clientInfo": {
"name": "example-host",
"version": "1.0.0"
}
}
}
The server must not assume that a client supports every optional feature. If the server declares list-change notifications, the client may expect notifications/tools/list_changed; if it does not, the server should not rely on that mechanism. Pin a protocol version in tests and verify the SDK’s supported versions before upgrading.
A request lifecycle normally follows these stages:
- The host supplies the model with discovered tool, resource, or prompt metadata.
- The model selects an operation and produces arguments.
- The client validates the outgoing message against the server’s advertised schema where supported.
- The server authenticates and authorizes the request.
- The handler validates domain constraints and performs the operation.
- The server returns content, structured data, or a typed error.
- The host decides whether to show the result, ask for confirmation, retry, or continue the conversation.
MCP’s protocol specification defines the wire contract; an SDK supplies the implementation machinery. They are not interchangeable: an SDK convenience decorator does not remove the need to understand the protocol semantics.
How Should You Choose an MCP SDK and Structure the Project?
Choose an MCP SDK based on the runtime that already owns your integration, not on decorator syntax. The official TypeScript SDK fits Node.js services and browser-adjacent ecosystems; the official Python SDK fits Python APIs, data systems, and asynchronous workers. Evaluate transport support, schema generation, authentication hooks, cancellation, testing utilities, and release compatibility before committing.
The official SDKs are the default choice because they track protocol details and provide server, client, transport, and type abstractions. TypeScript projects commonly use @modelcontextprotocol/sdk; Python projects commonly use the mcp package and its FastMCP interface. Pin an SDK version and read its changelog before adopting a newer protocol revision.
| Decision | TypeScript / Node.js | Python |
|---|---|---|
| Best fit | Existing Node APIs, monorepos, streaming services | Data platforms, Python libraries, FastAPI ecosystems |
| Schema strategy | TypeScript types plus Zod or SDK schema helpers | Type hints, Pydantic, SDK-generated schemas |
| Local transport | stdio | stdio |
| Remote transport | Streamable HTTP and SDK adapters | Streamable HTTP and ASGI integration |
| Main risk | Runtime/module-version mismatches | Event-loop blocking and dependency drift |
A high-level framework such as FastMCP reduces boilerplate, but a lower-level server API can be preferable when you need custom middleware, unusual authentication, explicit response envelopes, or tight control over protocol behavior. Do not select a framework solely because it makes the first tool easy to write; inspect how it handles errors, timeouts, structured output, and HTTP lifecycle events.
A maintainable project keeps protocol adapters thin and business logic independently testable:
mcp-server/
├── pyproject.toml
├── src/
│ └── server/
│ ├── __init__.py
│ ├── app.py # server construction and transport startup
│ ├── config.py # typed environment configuration
│ ├── auth.py # identity and authorization policy
│ ├── schemas.py # input and output models
│ ├── tools/
│ │ ├── __init__.py
│ │ ├── tickets.py
│ │ └── reports.py
│ ├── resources/
│ │ └── documentation.py
│ ├── prompts/
│ │ └── incident_review.py
│ └── services/
│ ├── ticket_service.py # domain operations
│ └── report_service.py
└── tests/
├── test_services.py
├── test_tools.py
└── test_protocol.py
Handlers should translate MCP inputs into domain calls and translate domain results back into MCP content. They should not contain SQL construction, token refresh logic, retry policy, and authorization rules all at once. This division allows the same service to be tested without a live MCP session and lets the protocol layer evolve independently.
Configuration should be typed and loaded once at startup. Fail fast when required values such as an upstream base URL or signing-key identifier are absent. Never silently substitute a development credential in production.
A minimal Python setup might begin with:
uv add "mcp[cli]" pydantic-settings
uv run python -m server.app
The exact extra names and transport flags can change between SDK releases, so pin the dependency and verify commands against the installed version. The MCP SDK documentation should be treated as an API reference, not as a substitute for protocol tests.
How Do You Implement Reliable MCP Tools?
A reliable MCP tool combines a narrow contract, schema validation, authorization, bounded execution, and model-readable results. Define arguments with explicit types and descriptions, reject invalid domain states before side effects, return stable structured output, and distinguish user-correctable failures from authentication, dependency, and server faults.
A tool schema is part of the model interface. Names should be specific and action-oriented: create_jira_issue is safer than jira, and delete_customer communicates more risk than update. Descriptions should state constraints and side effects, not marketing language.
Using Python’s high-level SDK:
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
mcp = FastMCP("ticketing")
class TicketResult(BaseModel):
ticket_id: str
url: str
status: str
@mcp.tool()
async def create_ticket(
project: str = Field(description="Project key, for example ENG"),
title: str = Field(min_length=5, max_length=120),
body: str = Field(min_length=1, max_length=20_000),
priority: str = Field(default="normal"),
) -> TicketResult:
"""Create a ticket after authorization and validation."""
allowed_priorities = {"low", "normal", "high"}
if priority not in allowed_priorities:
raise ValueError(
f"priority must be one of {sorted(allowed_priorities)}"
)
# Replace this with an authorized domain-service call.
ticket = await ticket_service.create(
project=project,
title=title,
body=body,
priority=priority,
)
return TicketResult(
ticket_id=ticket.id,
url=ticket.url,
status=ticket.status,
)
The SDK generates a schema from the type annotations, but a generated schema is not sufficient. Validate:
- Shape: required fields, types, enum values, string lengths, array sizes.
- Meaning: project exists, date range is ordered, currency is supported.
- Authority: the caller can access the project and perform the operation.
- Safety: paths remain within an allowed root, queries cannot exceed a row limit, and requested timeouts are bounded.
- State: a ticket can be closed only if it is currently open.
For destructive operations, prefer a two-phase design. A preview tool returns the proposed change and an idempotency key; a separate commit tool applies it after explicit user confirmation. If a single tool must mutate state, require an idempotency key and record it with the operation.
Tool failures should preserve useful distinctions. An invalid argument is actionable by the model; an expired credential usually requires host intervention; an upstream timeout may be retryable; an internal exception should not expose stack traces or secrets. The MCP result model supports an isError signal for tool-level failures, while JSON-RPC errors are better for malformed requests or protocol failures.
A practical error mapping looks like this:
| Failure | Tool result | Model-facing message |
|---|---|---|
| Invalid enum or missing business field | isError: true |
State the valid values and correction |
| Permission denied | isError: true |
Identify the denied operation without leaking policy internals |
| Upstream timeout | isError: true |
Say that the operation timed out and whether retry is safe |
| Bug or unexpected exception | Server error/log event | Generic failure; detailed trace stays in logs |
| Malformed JSON-RPC request | JSON-RPC error | Protocol error with standard code |
Return both concise text and structured data when the client supports structured content. Text helps the model explain the outcome; stable fields such as ticket_id, status, and next_action let clients render or automate it without parsing prose.
Do not return raw ORM objects, unbounded result sets, binary blobs, or entire upstream responses. Normalize timestamps to an explicit timezone, cap output size, paginate large results, and include a continuation token only if the client and tool contract can use it predictably.
Tool execution also needs deadlines. Pass a timeout from the handler to the upstream client, cancel in-flight tasks when the MCP request is cancelled, and avoid blocking an asynchronous event loop with synchronous database or filesystem calls. Retries must be operation-aware: retrying a read may be safe, while retrying a payment or ticket creation without idempotency can duplicate side effects.
When Should an MCP Server Expose Resources or Prompts?
Expose resources for addressable context, prompts for reusable instructions, and tools for computation or side effects. A resource should answer “what data can the client read?”, a prompt should answer “which workflow template can the user select?”, and a tool should answer “what operation can the model request?” Keep those boundaries explicit to avoid duplicated interfaces.
Resources work well for repository files, API documentation, database schema descriptions, incident timelines, and generated reports. A resource URI should identify the content deterministically enough for the client to reason about it. If content changes, include version metadata, an updated_at value, or an explicit subscription/update mechanism where supported.
A resource template can expose parameterized context:
@mcp.resource("docs://project/{project}/runbook")
async def project_runbook(project: str) -> str:
if not project.isidentifier():
raise ValueError("invalid project identifier")
document = await docs_service.get_runbook(project)
if document is None:
raise ValueError("runbook not found")
return document.markdown
Do not use a resource as a disguised search endpoint. Searching is an operation with input parameters and often ranking or filtering logic, so search_documents belongs as a tool. The resulting document can then be exposed or returned as resource content.
Prompts are useful when the workflow has stable instruction structure but variable arguments:
@mcp.prompt()
def incident_review(service: str, incident_id: str) -> str:
return (
f"Review incident {incident_id} for service {service}. "
"Use the incident timeline, deployment history, and runbook. "
"Separate confirmed facts, hypotheses, and recommended actions."
)
A prompt should not claim that a tool was called or that a resource was read. It supplies instructions and references; the host or model still performs the actual retrieval and invocation. Keep prompt arguments constrained and document expected resource names or tool sequences.
| Requirement | Resource | Prompt | Tool |
|---|---|---|---|
| Read stable or generated context | Yes | No | Sometimes |
| Provide reusable instructions | No | Yes | No |
| Query or transform data | Usually no | No | Yes |
| Cause external side effects | No | No | Yes |
| Suitable for caching | Often | Usually | Depends |
| Requires confirmation | Rarely | No | Often for writes |
A useful pattern is resource plus tool: expose schema://warehouse/orders as read-only context and query_orders as a constrained operation. Another is prompt plus tools: expose incident_review as the workflow template while keeping actual log retrieval and remediation behind separately authorized tools.
How Do You Connect an MCP Server to an AI Client?
Connect a local MCP server over stdio when one host launches one process; use Streamable HTTP when a server must be remote, shared, or independently operated. In either case, the client initializes the session, negotiates capabilities, lists available primitives, and invokes operations only after the server confirms readiness.
Local stdio
stdio is a process transport: the host launches the server subprocess and sends protocol messages through standard input and output. It is a good default for desktop applications, IDE integrations, and local developer tools because it avoids opening a network listener.
The server must write protocol messages only to stdout. Logs belong on stderr. A single accidental print("connected") on stdout can corrupt the JSON-RPC stream and cause an apparently unrelated parse error in the client.
A representative configuration is:
{
"mcpServers": {
"ticketing": {
"command": "uv",
"args": ["run", "--directory", "/opt/ticketing", "python", "-m", "server.app"],
"env": {
"TICKETING_ENV": "production"
}
}
}
}
Use absolute paths or a controlled working directory. Do not rely on the host’s shell initialization, interactive virtual environments, or an inherited PATH. For local secrets, prefer the host’s credential facility or an OS-level secret store instead of embedding tokens in this file.
Streamable HTTP
Streamable HTTP is appropriate when the MCP server runs as a network service. It supports HTTP request/response operation and streaming where needed, allowing a reverse proxy, container scheduler, identity provider, and standard service telemetry to sit around the server.
A remote deployment should define:
- The endpoint path and accepted HTTP methods.
- Authentication and token audience.
- Origin and host validation.
- Request size, connection, and execution timeouts.
- Whether sessions are stateful or stateless.
- Proxy behavior for streaming and connection termination.
Server initialization in Python may look like:
from server.app import mcp
if __name__ == "__main__":
mcp.run(transport="streamable-http")
The exact startup API depends on the SDK release. In production, place the application behind a TLS-terminating proxy or use platform-managed TLS, and ensure the proxy does not buffer a stream that the client expects to receive incrementally.
The transport does not determine the application’s capabilities. A stdio server and an HTTP server can advertise identical tools, but their trust boundaries differ substantially.
Discovery and invocation
A client should not hard-code a tool’s schema forever. After initialization it can call tools/list, resources/list, or prompts/list, then cache the result until the server reports a list change or the session is recreated. The client should inspect inputSchema before constructing arguments and handle unknown optional fields conservatively.
A connection sequence is:
- Open stdio or HTTP transport.
- Send
initializewith client version and capabilities. - Verify the server’s selected protocol version.
- Send
notifications/initialized. - Discover tools, resources, and prompts.
- Present risky tools for user approval according to host policy.
- Invoke the selected operation.
- Handle result, tool error, cancellation, or transport failure.
Compatibility depends on more than “supports MCP.” Check the client’s protocol revision, transport support, authentication implementation, sampling or elicitation behavior, structured-output handling, and resource UI. Test against the exact client versions you intend to support.
How Do You Secure, Test, and Debug an MCP Server?
Secure an MCP server as an untrusted-input, privileged integration boundary. Authenticate remote clients, authorize every operation and object, validate arguments again on the server, isolate secrets, restrict network and filesystem access, and test the complete JSON-RPC lifecycle rather than only the underlying business functions.
For stdio, the host typically controls process launch, so authentication may be provided by the local operating system and host configuration. That is not automatically safe: a malicious local process, compromised plugin, or overly broad filesystem permission can still access the server’s credentials.
For HTTP, use standard bearer-token validation with issuer, audience, signature, expiry, and scope checks. MCP’s authorization guidance is based on OAuth concepts; do not invent a custom query-string token scheme. Keep the resource server, authorization server, and MCP client roles clear. A valid token proves identity and intended audience, not permission to delete every record.
Authorization should be checked at the domain boundary:
async def require_permission(identity, action: str, project: str) -> None:
if not identity:
raise PermissionError("authentication required")
if not policy.allows(
subject=identity.subject,
scopes=identity.scopes,
action=action,
resource=f"project:{project}",
):
raise PermissionError("not authorized for this project")
The handler must also protect against path traversal, server-side request forgery, shell injection, oversized payloads, unsafe deserialization, and prompt-injection-mediated authority escalation. A model may be manipulated by content returned from a resource; that content must not be allowed to override the server’s authorization policy.
Use separate credentials for development, staging, and production. Redact access tokens, authorization headers, API keys, and sensitive tool arguments in logs. Give upstream credentials the narrowest scopes possible and rotate them without rebuilding the server. If a tool can execute code or access a filesystem, run it in a sandbox with explicit mounts, CPU, memory, and network limits.
Testing layers
Protocol tests should launch the server through its actual transport and exercise:
initializewith supported and unsupported protocol versions.- Capability declarations and list methods.
- Valid and invalid tool arguments.
- Tool-level errors versus JSON-RPC errors.
- Cancellation and timeout behavior.
- Resource URI validation and missing-resource behavior.
- Authentication and authorization failures.
- Output size limits and pagination.
- Concurrent requests, if the server advertises or relies on concurrency.
A compact test strategy uses a fake upstream service and a real MCP client from the same SDK family:
async def test_create_ticket_rejects_unknown_priority(mcp_client):
result = await mcp_client.call_tool(
"create_ticket",
{
"project": "ENG",
"title": "Test issue",
"body": "Body",
"priority": "critical",
},
)
assert result.is_error is True
assert "priority" in result.text.lower()
The test should assert the wire-visible behavior, not merely that a Python exception was raised. Add contract tests for schemas so a renamed field or accidental required parameter fails in CI.
Observability and common failures
Log a request identifier, tool name, authenticated subject, latency, outcome class, upstream dependency, and retry count. Do not log complete prompts, secrets, or unrestricted tool arguments. Emit metrics for invocation count, error rate, latency percentiles, timeout count, and output bytes.
Common integration failures include:
- No response during startup: the process printed logs to stdout or never entered the SDK’s run loop.
- Tool is invisible: the handler was not registered, the server’s capability declaration is wrong, or the client cached an old tool list.
- “Invalid params”: generated JSON Schema differs from the client’s argument shape, often because a field was unintentionally made required.
- HTTP connection closes immediately: a proxy buffers or times out streaming responses, or the server uses a stateful session behind a load balancer without session affinity.
- Authentication succeeds but calls fail: the token audience, scope, tenant, or resource-level authorization check is wrong.
- Model repeatedly misuses a tool: the description is ambiguous, the schema permits unsafe combinations, or the tool is too broad.
The MCP Inspector is useful for interactive discovery and invocation during development. It does not replace automated tests, security review, or testing through the target AI host.
How Do You Deploy and Operate an MCP Server in Production?
Deploy an MCP server as a small, versioned service with explicit configuration, bounded resources, and observable failure behavior. Use a supervised process for stdio or a containerized HTTP service behind TLS and an identity-aware proxy; pin dependencies, expose health signals, and roll out schema changes without breaking existing clients.
For a local stdio server, process management belongs to the host. Your responsibilities are deterministic startup, clean shutdown, stderr logging, and bounded resource use. Return a nonzero exit code on unrecoverable configuration failure so the host can report that the integration is unavailable.
For a remote server, a common architecture is:
AI host
|
| TLS + OAuth bearer token
v
Reverse proxy / API gateway
|
v
MCP HTTP service
|
+--> domain services
+--> databases
+--> upstream APIs
The gateway can enforce TLS, request-size limits, rate limits, token validation, and network policy. The MCP service remains responsible for tool-level authorization because only it understands the domain object and requested side effect.
Configuration and process management
Use environment variables or a secret manager for deployment-specific values:
MCP_TRANSPORT=streamable-http
MCP_BIND_HOST=0.0.0.0
MCP_PORT=8080
UPSTREAM_BASE_URL=https://api.example.com
OAUTH_ISSUER=https://identity.example.com
OAUTH_AUDIENCE=mcp-ticketing
MAX_TOOL_TIMEOUT_SECONDS=30
Validate configuration at startup and distinguish operational settings from tool behavior. A timeout should be configurable within a safe maximum, not supplied without bounds by the caller.
Set CPU and memory limits, file-descriptor limits, and upstream connection pools. Graceful shutdown should stop accepting new requests, allow bounded in-flight work to finish, close clients, and then exit. If the service uses stateful sessions, plan for reconnects and load-balancer routing; stateless request handling is easier to scale horizontally.
Health, logs, and scaling
A liveness check should answer whether the process is running. A readiness check should answer whether it can accept traffic and has required dependencies configured. Do not make readiness depend on every upstream API being healthy if the server can still serve read-only resources or return a controlled dependency error.
Scale based on concurrent tool execution, upstream quotas, and latency rather than request count alone. A tool that launches a report query may consume an entire database connection for seconds. Use per-user and global concurrency limits, queues for long jobs, and asynchronous job resources when an operation exceeds normal request timeouts.
Version the server and its schemas. Adding an optional field is usually compatible; renaming a tool, changing an enum, tightening an input constraint, or changing the meaning of a field is a contract change. Prefer a new tool name for incompatible semantics, such as create_ticket_v2, and deprecate the old operation with telemetry showing remaining usage.
A safe rollout sequence is:
- Add protocol and domain contract tests.
- Deploy the new version behind a canary or restricted audience.
- Compare invocation errors, latency, authorization denials, and upstream load.
- Verify discovery and invocation from each supported client.
- Expand traffic gradually.
- Retain rollback capability and the previous schema implementation.
Treat tool descriptions as production API documentation. A description change can alter model behavior even when the JSON Schema is unchanged, so review it like code and test representative prompts against it.
Frequently Asked Questions
Which MCP SDK should I use for a new server?
Use the official SDK for the language already used by the integration: the TypeScript SDK for Node.js services and the Python SDK for Python applications. The choice should be based on transport support, schema generation, authentication integration, cancellation, observability hooks, and the maintainability of your surrounding code—not on whether one decorator looks shorter.
Python is a natural fit when the server wraps data-science libraries, internal Python APIs, or asynchronous database clients. TypeScript is often simpler when the server belongs in an existing Node.js monorepo or shares types with a web service. In either language, begin with the high-level server API and drop to lower-level protocol primitives only when you need custom middleware or response behavior.
Pin the SDK version in your lockfile. MCP protocol revisions and SDK APIs evolve independently, and an upgrade can change generated schemas, transport startup, or structured-output behavior. Build a small compatibility test that initializes the server, lists primitives, calls one read tool, calls one failing tool, and shuts down cleanly. That test gives more useful evidence than a package version alone.
Should I build an MCP server in Python or TypeScript?
Python and TypeScript can both implement a correct MCP server; the better choice is the language that already owns the domain logic and operational tooling. Python is convenient for data systems and Pydantic-based validation, while TypeScript provides strong integration with Node.js services, Zod schemas, and JavaScript deployment platforms.
Do not choose based on model quality. The model interacts with the protocol contract, not the implementation language. A poorly described TypeScript tool behaves worse than a carefully bounded Python tool, and vice versa.
Consider runtime behavior. Python asynchronous handlers must not perform blocking file or database operations on the event loop. Node.js handlers must avoid CPU-heavy synchronous work that blocks all connections. Compare database drivers, OAuth middleware, tracing libraries, container images, and your team’s incident-response experience.
Keep domain services separate from MCP handlers so a later language decision does not force a protocol rewrite. The handler should validate MCP inputs, call a service, and format a result. If the integration is already a FastAPI application, Python may minimize deployment complexity; if it is already a NestJS or Express service, TypeScript may do the same.
When should I use stdio rather than Streamable HTTP?
Use stdio when a trusted local host launches a dedicated MCP process, such as an IDE or desktop application. Use Streamable HTTP when clients connect over a network, the service needs independent deployment, or several users share the same integration.
stdio has a small attack surface and no listener, but its lifecycle is controlled by the host. It is unsuitable for a central service unless you deliberately wrap it in another process. It also makes remote authentication, rate limiting, and centralized telemetry less natural.
Streamable HTTP fits standard service infrastructure: TLS termination, OAuth validation, reverse proxies, container orchestration, and horizontal scaling. It introduces network threats and operational concerns, including origin validation, request limits, proxy buffering, idle timeouts, and session handling.
Do not confuse transport with authorization. A local stdio process may have dangerous filesystem access, while an HTTP service may be tightly scoped. In both cases, implement domain authorization and validate tool arguments on the server. Test the exact transport used by each target client because a server that works over stdio may fail through an HTTP proxy or client-specific streaming implementation.
How do MCP clients discover my tools?
After initialization, an MCP client uses the server’s negotiated capabilities to call list methods such as tools/list, resources/list, and prompts/list. The server returns names, descriptions, input schemas, and metadata supported by the protocol revision and SDK.
The host usually passes this information to the model as available context. The model then selects a tool and emits arguments. The client may perform preliminary schema checks, but the server must repeat validation because clients can be buggy, outdated, compromised, or bypassed by another caller.
If tools change during a session, the server can advertise list-change support and notify the client when the list has changed. Otherwise, clients may cache the initial list until reconnect. A tool that was registered after startup may therefore remain invisible to an existing connection.
When a tool does not appear, inspect three layers: registration code, the server’s advertised capabilities, and the client’s cached discovery result. Use an MCP Inspector or a protocol test client to call tools/list directly. Also verify that startup completed and that diagnostic output did not corrupt a stdio stream.
Do I need authentication for a local MCP server?
A local stdio server may not need application-level authentication if the host, operating-system account, process permissions, and configuration are trusted. That assumption should be documented, because local integrations can still expose credentials or execute actions with the user’s privileges.
Authentication is required when an HTTP server is reachable by untrusted or semi-trusted clients. It is also appropriate for a local server that accesses high-value systems under a service identity rather than the interactive user’s identity. In that case, the server should not treat “running locally” as proof that every request is authorized.
Even without authentication, implement authorization boundaries. Restrict filesystem roots, allowed commands, database schemas, network destinations, and tenant identifiers. Never accept a caller-provided path or account ID as authority. Derive identity and scope from the host or validated token, then check the requested resource against that identity.
For remote deployments, validate token signature, issuer, audience, expiry, and scopes. Protect tokens in transit with TLS and never place them in URLs. Log identity metadata and authorization outcomes, but redact the credential itself.
How many tools should an MCP server expose?
Expose the smallest coherent set that lets a model complete the intended workflows safely. Tool count is less important than semantic overlap, argument complexity, output size, and permission scope. Ten narrow tools with clear contracts can be easier for a model to use than two generic tools with dozens of modes.
Avoid a universal execute tool whose arguments contain arbitrary SQL, shell commands, REST paths, or a mini-language. Such a tool transfers schema discovery, authorization, and safety decisions to the model. Instead, wrap domain operations with constrained inputs and explicit limits.
Split a tool when operations have different side effects or authorization requirements. search_tickets and delete_ticket should not be one operation with an action enum if they require different consent and audit policies. Conversely, do not split one atomic domain transaction into steps that allow inconsistent intermediate state.
Measure actual use. Record invocation failures, repeated retries, invalid argument rates, and user cancellations. If models repeatedly select the wrong tool, improve names and descriptions or reduce overlap. Tool design is an API-design problem with probabilistic callers; observed behavior should inform revisions.
How should an MCP server handle destructive tools?
A destructive MCP tool should require explicit authorization, communicate its side effect in its name and description, validate the target, and use idempotency or a preview-confirmation flow. The model’s selection is not equivalent to human consent unless the host explicitly defines it that way.
For important writes, use two stages. A preview operation computes the exact changes, affected objects, and irreversible consequences. A commit operation receives a short-lived confirmation token or idempotency key tied to the preview, user, tenant, and target. The server verifies that the proposed state has not changed before applying it.
At minimum, require an idempotency key for operations such as creating tickets, sending messages, provisioning resources, or charging an account. Store the key and resulting operation so a retry returns the original result rather than performing the side effect twice.
Authorization must occur immediately before the mutation, not only when the preview was generated. Record who requested the operation, which tool and arguments were used, what policy allowed it, and the upstream result. Return a concise result with an immutable operation identifier that the host can show to the user.
How do I test an MCP server before connecting it to a real AI client?
Test at three levels: domain-service unit tests, MCP handler tests, and end-to-end protocol tests through the actual transport. Unit tests verify business rules; handler tests verify schema-to-service translation; protocol tests verify initialization, discovery, errors, cancellation, and framing.
Use fake upstream services rather than production credentials. Test malformed and boundary inputs, including missing required fields, extra fields, oversized strings, invalid URIs, unauthorized tenant identifiers, timeouts, duplicate idempotency keys, and upstream partial failures.
The end-to-end test should launch the server as a subprocess for stdio or as a real HTTP application for Streamable HTTP. A real MCP client should perform initialize, send notifications/initialized, list tools, invoke valid and invalid operations, and close the connection. Assert both structured fields and human-readable content.
Also test client compatibility. Different hosts can differ in support for protocol revisions, streaming, structured output, resource display, and authentication. Maintain a small matrix of supported clients and run smoke tests after SDK or server-description changes. The goal is not merely “the function works”; it is “a client can discover, safely invoke, and correctly interpret the operation.”
What does production readiness mean for an MCP server?
Production readiness means the server has a stable protocol contract, authenticated and authorized access, bounded tool execution, tested transports, secret isolation, observable failures, and a rollback plan. A successful local demo proves none of those properties by itself.
Define supported protocol and client versions. Pin dependencies and build reproducible artifacts. Validate configuration at startup, use TLS for remote traffic, and enforce request, output, concurrency, and upstream time limits. Ensure graceful shutdown and decide whether sessions are stateful or stateless before adding replicas.
Monitor invocation latency, error classes, authorization denials, timeout rates, upstream failures, active connections, and output sizes. Correlate logs with request IDs while redacting credentials and sensitive arguments. Health checks should distinguish process liveness from readiness to serve.
Treat tool schemas and descriptions as versioned APIs. Additive optional fields are usually safer than renames or changed meanings; incompatible behavior deserves a new tool name or an explicit migration period. Roll out through a canary, verify the target clients, compare metrics, and retain the prior version until real usage confirms compatibility.
Conclusion
The repeatable way to build an MCP server is to start with domain operations, assign each one deliberately to a tool, resource, or prompt, and then expose only the smallest typed contract that a model needs. Implement handlers as a thin protocol layer over independently tested services; validate and authorize again at the server boundary; choose stdio for host-managed local processes and Streamable HTTP for independently operated services.
The single most actionable next step is to build one read-only tool end to end: define its schema, implement domain validation, run it through a real MCP client over the intended transport, and add a protocol test before adding write operations. That slice reveals SDK, client, schema, logging, and deployment problems while the security consequences remain small.
After that foundation, study JSON-RPC and protocol design and the deeper material in /blog, especially authorization for agentic systems and evaluation of tool-using agents. Those adjacent topics determine whether an MCP server remains dependable when its caller is no longer a careful human, but a probabilistic system operating across long workflows.