An MCP server is not “an API with a nicer schema.” It is a capability boundary that may be discovered and invoked by a model-driven client. Production quality therefore depends on four contracts:

  1. Transport: how messages, sessions, reconnects, and cancellation move.
  2. Identity: how a client proves the principal and how claims are propagated.
  3. Capability: which tool is available and whether this exact call is allowed.
  4. Data: how results, pagination, provenance, and large artifacts are exposed.

This guide uses Node.js-shaped pseudocode where it clarifies the boundary, but it does not pretend to be a copy-paste server. Pin an MCP SDK and specification revision in your own repository before implementing.

Key Takeaways

  • Use stdio for a local process boundary and a current HTTP transport for remote deployment; legacy SSE routes are compatibility concerns, not a universal default.
  • Treat MCP as a protocol layer. Gateway authentication, resource authorization, business policy, and audit remain application responsibilities.
  • Validate access tokens with a trusted issuer, audience/resource, algorithm policy, expiry, and key rotation.
  • Never accept a client-selected session as proof of identity. Bind server-issued session state to the authenticated principal, tenant, and transport.
  • Split read and write tools. Require explicit workflow approval for money, deletion, publication, external messages, or other consequential effects.
  • Tool annotations are hints, not guarantees.
  • Return bounded pages or authorized Resources instead of unbounded JSON.
  • Treat tool descriptions, resources, and results as untrusted input that may contain prompt injection.
  • Rate-limit initialization, discovery, calls, reconnects, and result downloads separately.
  • Test malformed JSON-RPC, expired tokens, session confusion, cross-tenant access, reconnect races, oversized results, and cancellation.

Choose the Transport by Deployment

stdio

Use stdio when the client launches a local process:

text
client process
  stdin/stdout JSON-RPC
  -> MCP server process
  -> local OS permissions

This can be a strong boundary when the process is launched under a dedicated OS user and has a restricted filesystem and network policy. It is not automatically safe: local environment variables, inherited credentials, shell execution, and filesystem access still matter.

Remote HTTP

Remote servers need HTTP concerns that local stdio does not:

  • TLS and certificate validation;
  • authentication and token audience;
  • origin and proxy behavior;
  • session lifecycle and reconnects;
  • request size, rate, and concurrency limits;
  • cancellation and server shutdown;
  • tenant-aware authorization.

The current MCP specification defines a Streamable HTTP transport. Older SSE-plus-POST SDK examples may still be useful for migration, but verify their lifecycle and security behavior before adopting them for new systems.

Do not describe SSE as “the MCP streaming protocol.” SSE is an HTTP delivery mechanism; MCP message transport and session semantics are defined by the selected specification and SDK.

Architecture

flowchart LR Client["MCP Client / Host"] --> TLS["TLS + Gateway"] TLS --> Auth["Token Validation"] Auth --> Session["Session Registry"] Session --> Router["MCP Protocol Router"] Router --> Policy["Tool Policy + Object Authorization"] Policy --> Tool["Tool Handler"] Tool --> Data[("Business APIs / Data")] Tool --> Result["Bounded Result or Resource Link"] Result --> Router

Keep these responsibilities separate:

Layer Owns Must not assume
Transport framing, connection, session, cancellation that a connection is authorized
Token validator signature, issuer, audience, expiry, claims that a claim grants every object
Session registry server-created session and principal binding that a client-supplied ID is trustworthy
Tool policy capability, tenant, object, purpose, side effect that a model chose safely
Tool handler business validation and transaction that schema validation proves ownership
Result layer size, provenance, redaction, pagination that tool output is trusted instructions

OAuth and Token Validation

For a remote server, implement the MCP authorization flow and resource-server requirements supported by your chosen revision and client. Do not invent a shared secret in application code.

At minimum, validation should establish:

  • token signature using an allowlisted algorithm;
  • trusted issuer;
  • intended resource or audience;
  • expiry and not-before;
  • required scopes;
  • tenant and subject claims;
  • key rotation and clock-skew policy.

Then map the authenticated principal to your own authorization service. A token saying scope=orders:read still does not prove access to order 792318 in tenant A.

Security-Oriented Middleware Shape

The following is a boundary fragment, not a complete OAuth implementation:

javascript
export async function authenticateRemoteRequest(req, res, next) {
  const header = req.headers.authorization ?? "";
  const match = /^Bearer ([^\s]+)$/.exec(header);
  if (!match) {
    return res.status(401).json({ error: "missing_bearer_token" });
  }

  try {
    const claims = await verifyAccessToken(match[1], {
      issuer: process.env.OAUTH_ISSUER,
      audience: process.env.MCP_RESOURCE,
      algorithms: ["RS256", "ES256"], // illustrative; pin the provider-approved set
    });

    const subject = typeof claims.sub === "string" ? claims.sub : undefined;
    const tenant = typeof claims.tenant_id === "string"
      ? claims.tenant_id
      : undefined;
    if (!subject || !tenant) {
      throw new Error("required_identity_claim_missing");
    }
    const rawScope = claims.scope;
    const scopeList = Array.isArray(rawScope)
      ? rawScope.filter((value) => typeof value === "string")
      : typeof rawScope === "string"
        ? rawScope.split(/\s+/).filter(Boolean)
        : [];

    req.principal = {
      subject,
      tenant,
      scopes: new Set(scopeList),
    };
    return next();
  } catch {
    return res.status(401).json({ error: "invalid_access_token" });
  }
}

verifyAccessToken must use the provider’s JWKS/key-rotation implementation, reject an absent issuer or audience, and never fall back to a development secret. Do not log the token or place it in a trace attribute.

Session Binding

A session is server state, not an identity mechanism. On initialization:

  1. authenticate the request;
  2. negotiate the protocol version and capabilities;
  3. create a server-generated opaque session identifier if the transport requires one;
  4. bind it to subject, tenant, scopes, client identity, expiry, and transport;
  5. reject requests whose session binding and token do not match;
  6. expire and revoke it on logout, credential revocation, or idle timeout.

Never use a query parameter supplied by the client as the authoritative session owner. Avoid storing mutable principal data directly on a transport object unless the SDK explicitly supports it; keep it in a typed session registry.

javascript
const sessions = new Map(); // production: durable or bounded distributed store

function createSession(principal, transport) {
  const id = crypto.randomUUID();
  sessions.set(id, {
    id,
    subject: principal.subject,
    tenant: principal.tenant,
    scopes: [...principal.scopes],
    transport,
    // Illustrative TTL: derive this from the deployment's session policy.
    expiresAt: Date.now() + 15 * 60 * 1000,
  });
  return id;
}

function requireSession(id, principal) {
  const session = sessions.get(id);
  if (!session || session.expiresAt < Date.now()) {
    throw new Error("session_not_found");
  }
  if (
    session.subject !== principal.subject ||
    session.tenant !== principal.tenant
  ) {
    throw new Error("session_principal_mismatch");
  }
  return session;
}

This map is illustrative. A multi-instance deployment needs bounded storage, eviction, routing or shared state, and race-safe close/reconnect behavior.

Tool Design and Authorization

A tool description and input schema help a model select a capability. They do not authorize it.

Prefer purpose-built tools:

text
get_order_summary(order_reference)
list_invoice_pages(order_reference, cursor)
request_refund_review(order_reference, reason_code)

Avoid a catch-all:

text
api_request(method, path, headers, body)

For every call, check:

  • authenticated subject and tenant;
  • tool and scope allowlist;
  • object ownership and row-level policy;
  • argument schema and business invariants;
  • resource version or freshness;
  • side-effect class and approval;
  • idempotency and retry semantics;
  • rate, time, and output budgets.

Separate read and write tools. A tool that can both GET and DELETE hides a consequential policy distinction behind an argument.

Annotations Are Hints

MCP tool annotations such as readOnlyHint, destructiveHint, idempotentHint, and openWorldHint communicate expected behavior to hosts. A malicious or buggy server can lie, and a host may interpret hints differently. Use them for UX, review, and conservative preflight; enforce actual policy in the server.

Bounded Results and Large Data

Large results create memory, latency, token, and disclosure risks. Do not assume the protocol will magically stream an unbounded JSON object.

Use one of these patterns:

  1. Pagination: return a bounded page with an opaque cursor and expiry.
  2. Resource link: return a separately authorized resource reference rather than the full content.
  3. Summary plus retrieval: return counts and relevant excerpts, then fetch selected ranges.
  4. Application download: use a short-lived, scoped download flow for binary artifacts.

Every page request must re-check authorization. A cursor should not reveal raw database offsets or allow a user to switch tenants.

javascript
function pageResponse(rows, nextCursor) {
  const maxBytes = 256 * 1024;
  const payload = JSON.stringify({ rows, nextCursor });
  if (Buffer.byteLength(payload) > maxBytes) {
    throw new Error("result_too_large");
  }
  return {
    content: [{ type: "text", text: payload }],
    structuredContent: { count: rows.length, nextCursor },
  };
}

For binary data, use the content type and Resource semantics supported by the selected MCP version and client. Base64 is an encoding, not a confidentiality or size solution; prefer a short-lived authorized resource when files are large.

Results Are Untrusted

Tool and Resource content may contain:

  • prompt injection;
  • stale or cross-tenant data from an upstream bug;
  • HTML, Markdown links, code, or file paths;
  • secrets and personal data;
  • oversized or recursive structures.

Validate result shape, redact unnecessary fields, attach provenance and timestamps, cap depth and bytes, and tell the host that external content is data rather than instructions. Never let a result choose a privileged tool without passing through the same policy engine.

Rate, Timeout, and Reliability Controls

Set independent budgets for:

  • initialization and discovery;
  • active sessions and reconnects;
  • calls per subject, tenant, server, and tool;
  • concurrent calls and in-flight bytes;
  • per-tool timeout and total request deadline;
  • pagination cursor lifetime;
  • retries and idempotency;
  • server shutdown and cancellation.

Classify failures:

Failure Response
malformed request structured protocol error; do not retry
expired token re-authenticate; do not replay blindly
authorization denial stop and audit
transient read timeout bounded retry if safe
ambiguous write timeout query by idempotency key before retry
oversized result request a smaller page or resource

Do not return stack traces, tokens, SQL, or internal topology to the model or client.

Testing and Observability

Test at the protocol and business boundaries:

  • initialization with unsupported versions;
  • malformed JSON-RPC and unknown methods;
  • expired, wrong-audience, wrong-tenant, and revoked tokens;
  • session fixation and principal mismatch;
  • tool discovery filtering;
  • cross-tenant object access;
  • false or missing annotations;
  • pagination cursor tampering and expiry;
  • oversized, malformed, poisoned, and stale results;
  • cancellation, reconnect, timeout, duplicate, and partial failure.

Record:

  • request and trace IDs;
  • protocol and server versions;
  • hashed subject and tenant references;
  • method, tool name, policy decision, and error code;
  • bytes, rows, latency, retry count, and result classification.

Do not record bearer tokens or full sensitive arguments by default. Use redaction, access control, sampling, and retention limits.

Production Checklist

  • [ ] Transport and MCP specification revision are pinned and tested.
  • [ ] Remote access uses TLS and a standards-based authorization flow.
  • [ ] Token issuer, audience/resource, algorithm, expiry, scopes, and key rotation are checked.
  • [ ] Sessions are server-created, principal-bound, expiring, and revocable.
  • [ ] Tool authorization checks tenant, object, purpose, and side effect.
  • [ ] Read and write capabilities are separate.
  • [ ] Annotations are treated as hints, never as permission.
  • [ ] Results are bounded, schema-validated, redacted, and provenance-aware.
  • [ ] Pages and resource links re-check authorization.
  • [ ] Time, rate, concurrency, retry, and byte budgets are enforced.
  • [ ] Tool results cannot directly become privileged instructions.
  • [ ] Protocol, abuse, reconnect, and cross-tenant tests run in CI.
  • [ ] Traces exclude tokens and unnecessary personal data.

Frequently Asked Questions

Can an MCP server expose a generic HTTP proxy tool?

Avoid it. A generic proxy makes authorization, SSRF, data egress, and audit nearly impossible to reason about. Expose narrow tools for owned operations and validate destinations in code.

Does remote transport make a local server less safe?

It changes the threat model. Remote deployment adds network identity, session, replay, rate, proxy, and multi-tenant risks. A local process can still be dangerous if it inherits broad OS permissions.

Is JWT the only valid authentication method?

No. Use the authorization mechanism required by your deployment and MCP revision. JWT access tokens are one common resource-server representation; token format does not replace scope and object authorization.

Should every tool result be returned as text?

No. Use structured content for data the client can validate, resources for larger artifacts, and text for human-readable summaries. Keep the result bounded and treat all content as untrusted.

Conclusion

An enterprise MCP server is a policy-enforced capability service. Transport gets messages there; authentication identifies a principal; authorization decides whether this exact operation is allowed; the result layer limits what comes back.

Start with a narrow read-only tool, pin the protocol and SDK versions, test the negative path, and add writes only behind explicit workflow approval and idempotent transactions. That path produces a server users can trust instead of a remote JSON endpoint with an AI label.

Primary Sources