Back to blog

Engineering · Jul 21, 2026

MCP Server Authoring Guide 2026: Building Production-Grade MCP Servers From Scratch — The Authoring Playbook Every Independent Server Author Should Follow

The 12 decisions every MCP server author must make: transport, SDK, license, auth, secrets, tool surface, output format, error handling, rate limiting, idempotency, distribution, documentation. Plus standard patterns (pagination, search, bulk, async, webhooks), security hardening, testing strategy, versioning, and the publishing checklist.

MCP ServerAuthoring GuideProductionSecurityDistributionAI Agents

MCP Server Authoring Guide 2026: Building Production-Grade MCP Servers From Scratch — The Authoring Playbook Every Independent Server Author Should Follow

Companion to the MCP Spotlight series · Last updated: 2026-07-21 Maintainer: Facio engineering · Status: Authoring guide for MCP server authors Audience: Engineers building independent MCP servers to plug into the Facio ecosystem (or any agent-runtime ecosystem)

The MCP ecosystem in 2026 has 500+ published servers. Most are good — a handful are great, a few are security disasters. The gap between "it works on my machine" and "it's production-ready for hundreds of agent deployments" is large. Independent server authors who want their work used in production must navigate a specific set of decisions: what's the right tool surface? How do I handle auth and secrets without leaking them to the agent? How do I make my server safe by default and powerful when needed? How do I distribute it so users trust the install? How do I version and update it without breaking every agent that depends on it?

This post is the authoring playbook every independent server author should follow. It's not a tutorial on any one platform — it's the decision framework that produces production-grade MCP servers across platforms.

The Authoring Decision Stack

Before writing code, an author makes 12 decisions. Each has a principled default; each can be overridden for specific use cases.

DecisionDefaultWhy
Transportstdio (NPX)Lowest friction for local development
SDKOfficial MCP SDK (TypeScript or Python)Best long-term compatibility
LicenseMITMaximum adoption
AuthPer-resource scope isolationPrinciple of least privilege
SecretsEnv-var injection at startupNever in agent context
Tool surfaceDomain-grouped, minimalDiscoverability + context efficiency
Output formatStructured (JSON) for data; Markdown for contentParseable + readable
Error handlingStructured errors with actionable messagesThe agent needs to recover
Rate limitingBuilt-in client-side awareRespect upstream APIs
IdempotencyIdempotency keys for write operationsRetries are safe
DistributionContainer image + signature + provenanceSupply-chain trust
DocumentationOpenAPI-style tool schema + use-case examplesSelf-discoverable by agents

A server that follows this table is already ahead of 80% of published MCP servers. Each default is the right answer for most use cases; deviations need justification.

Decision 1: Transport — stdio, HTTP, or Both?

stdio (Standard Input/Output) is the canonical MCP transport. The agent spawns the server as a subprocess, communicates over stdin/stdout.

npx -y @myorg/mcp-server --api-key=...

Use stdio when:

  • The server is local (runs on the same machine as the agent)
  • The server has access to local resources (filesystem, network, environment)
  • The user installs the server themselves
  • The deployment is single-tenant

HTTP transport is the alternative. The server runs remotely, the agent connects over HTTPS.

# Client config
{
  "mcpServers": {
    "myorg": {
      "url": "https://mcp.myorg.com/mcp",
      "headers": {"Authorization": "Bearer ${credentials.MYORG_API_KEY}"}
    }
  }
}

Use HTTP when:

  • The server is hosted (multi-tenant SaaS)
  • The agent connects from many machines
  • The server is operated by the platform vendor
  • The deployment is shared infrastructure

Both: Some servers (e.g., Stripe, Notion, GitHub) support both. stdio for local dev, HTTP for hosted. They share the same tool surface; the transport is interchangeable.

The recommended default for new servers: stdio primary, HTTP optional. Start with stdio (lowest friction), add HTTP if you need hosted/SaaS.

Decision 2: SDK — TypeScript, Python, or Go?

The official MCP SDK ships in multiple languages:

LanguageRepositoryBest For
TypeScriptgithub.com/modelcontextprotocol/typescript-sdkNode.js / Deno / Bun environments, npm distribution
Pythongithub.com/modelcontextprotocol/python-sdkPython-native agents, data science, ML tooling
Gogithub.com/modelcontextprotocol/go-sdkHigh-performance servers, system-level tools
Kotlin / Javagithub.com/modelcontextprotocol/kotlin-sdkJVM agents
C# / .NETgithub.com/modelcontextprotocol/csharp-sdk.NET agents
Rubycommunity-maintainedRuby/Rails ecosystems

Use TypeScript if your upstream is JavaScript/TypeScript/Node-friendly. Most users will install via npx.

Use Python if your upstream is Python (FastAPI, Django, Flask, requests-based). Most users will install via uvx.

Use Go if you need maximum performance, single-binary distribution, or system-level access.

For most use cases, TypeScript is the right default — it has the largest NPM ecosystem, the broadest agent-runtime compatibility, and the most published examples.

Decision 3: License — MIT, Apache 2.0, or Custom?

LicenseWhen to Use
MITMaximum adoption; permissive; works for most servers
Apache 2.0Patent grants; corporate-friendly; the model chosen by Notion, Cloudflare, Stripe
CustomOnly if you have specific legal requirements

The default is MIT for community servers, Apache 2.0 for vendor servers that want patent grants. Most published MCP servers pick one or the other.

For servers that wrap a paid SaaS, the license only governs the server code, not the upstream service. The user still needs an account with the SaaS.

Decision 4: Auth — Per-Resource Scope Isolation

The most important decision. Most security incidents in MCP-land trace back to over-broad credentials.

The principle of least privilege says: the agent gets exactly what it needs, nothing more.

For a Stripe MCP server, the recommended pattern:

// Read from env (not from agent context)
const apiKey = process.env.STRIPE_SECRET_KEY;

// Validate the key is a restricted key (not a full secret key)
if (!apiKey || !apiKey.startsWith('rk_')) {
  throw new McpError('invalid_credentials', 'Restricted API keys only');
}

// The server uses the key, but the agent never sees it
const stripe = new StripeApi(apiKey);

The agent calls create_refund. The server uses the key on the agent's behalf. The agent's context never includes the key value.

For multi-tenant SaaS servers, per-tenant keys (or OAuth tokens with tenant scope) are the right default.

Decision 5: Secrets — Env Var Injection at Startup

Secrets must never appear in:

  • The MCP server's tool descriptions
  • The agent's context (no description field that includes ${API_KEY})
  • The audit log
  • The error messages

The canonical pattern is environment-variable injection at startup:

# User configures secrets in their MCP client
{
  "mcpServers": {
    "mcp-server": {
      "command": "npx",
      "args": ["-y", "@myorg/mcp-server"],
      "env": {
        "MYORG_API_KEY": "${credentials.MYORG_API_KEY}",
        "MYORG_API_SECRET": "${credentials.MYORG_API_SECRET}"
      }
    }
  }
}

The MCP client (Claude Desktop, Cursor, etc.) substitutes the secrets at startup, before the agent ever sees the tool surface.

For self-hosting, read from a secret manager (Vault, Doppler, AWS Secrets Manager, OS keychain) at process start. Never write secrets to disk, to logs, to tool outputs.

Decision 6: Tool Surface — Domain-Grouped, Minimal

The tool surface design determines whether the agent uses your server well.

Anti-patterns to avoid:

Anti-patternWhy It's Bad
100+ flat toolsBloats context; loses semantic structure
1 mega-tool that does everythingHard to compose, hard to RBAC
Tool names with platform jargonAgent doesn't know what they do
Tools that overlap (e.g., list_recent and list_latest)Confusing; pick one
Tools with cryptic parametersAgent makes mistakes

The domain-grouped pattern (Resend-style: 10 groups, ~25 tools):

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    // Emails group
    { name: 'send_email', description: 'Send a single transactional email', ... },
    { name: 'list_emails', description: 'List recently sent emails', ... },
    // Contacts group
    { name: 'create_contact', description: 'Add a new contact to your audience', ... },
    { name: 'list_contacts', description: 'List contacts', ... },
    // Domains group
    { name: 'verify_domain', description: 'Verify DNS records for a sending domain', ... },
    // ...
  ]
}));

The grouping matches the user's mental model. The agent thinks "I need to send an email" → send_email. "I need to verify the domain" → verify_domain.

Naming conventions:

  • Verbs for actions: create_*, update_*, delete_*, list_*, get_*, search_*
  • Plural nouns for collections: list_emails, search_contacts
  • Singular nouns for single items: get_email, update_contact
  • Underscore_separated (snake_case) — the convention across most published servers

Description discipline:

  • One sentence describing what the tool does
  • One sentence describing when to use it
  • List of required parameters
  • List of optional parameters
  • Example call (where useful)

The agent reads the description as its primary hint. Bad descriptions lead to bad tool use.

Decision 7: Output Format — Structured (JSON) for Data, Markdown for Content

Different data types deserve different formats:

Data TypeFormatExample
Records, lists, queriesStructured JSON[{id, name, ...}, ...]
Search resultsStructured JSON with relevance[{id, title, score}, ...]
Documents, articlesMarkdown# Title\n\nBody...
Code filesMarkdown (code-fenced)```typescript ... ```
Status / healthStructured JSON{status, version, ...}
ErrorsStructured JSON{error: {code, message, ...}}

Why this split:

  • Structured JSON is parseable — the agent can select specific fields, filter, transform
  • Markdown is human-readable — used when the agent will quote or summarize the content

For a documentation-style response (Notion, Confluence, search results), default to Markdown. For API-style responses (Stripe, GitHub, Sentry), default to structured JSON.

Decision 8: Error Handling — Structured, Actionable

Errors are how the agent learns to recover. Bad error messages break workflows; good error messages teach the agent.

The bad pattern (HTTPException with raw dump):

{"error": "AxiosError: Request failed with status code 401"}

The agent doesn't know what to do.

The good pattern (structured error with code + message + actionable hint):

{
  "error": {
    "code": "auth_invalid_credentials",
    "message": "The API key is invalid or expired.",
    "actionable": "Verify the API key is a restricted key (starts with 'rk_'). Check that it has not expired in the dashboard. See https://docs.myorg.com/auth for credential management.",
    "documentation_url": "https://docs.myorg.com/errors/auth-invalid"
  }
}

The agent knows: (1) what went wrong, (2) what to do about it, (3) where to learn more. The agent can recover and retry.

Common error patterns your server should handle:

PatternCodeRecovery Hint
Missing credentialsauth_missing"Set MYORG_API_KEY env var"
Invalid credentialsauth_invalid"Verify key in dashboard"
Rate limitrate_limit_exceeded"Retry after N seconds"
Not foundnot_found"Verify resource ID"
Validation errorvalidation_errorList of validation failures
Upstream errorupstream_error"Upstream API returned 5xx; retry"
Timeouttimeout"Increase timeout or retry"
Internal errorinternal_error"Report at https://github.com/..."

The code field is machine-readable (for automated retries), the message field is human-readable (for the agent to explain), the actionable field is recovery-oriented (what to do next).

Decision 9: Rate Limiting — Built-In, Client-Aware

Most upstream APIs have rate limits. Your server should:

  1. Read the rate-limit headers (X-RateLimit-Remaining, X-RateLimit-Reset)
  2. Implement client-side throttling (don't hammer the upstream)
  3. Expose rate-limit status (a tool or capability that reports current limits)
  4. Handle rate-limit errors with automatic retry + backoff
async function withRateLimit<T>(fn: () => Promise<T>): Promise<T> {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await fn();
    } catch (e) {
      if (e.code === 'rate_limit_exceeded') {
        const retryAfter = e.headers['retry-after'] || Math.pow(2, attempt);
        await sleep(retryAfter * 1000);
        continue;
      }
      throw e;
    }
  }
  throw new McpError('rate_limit_exceeded', 'Retries exhausted');
}

For multi-tenant servers, rate-limit per tenant — one tenant's burst shouldn't starve others.

Decision 10: Idempotency — Idempotency Keys for Write Operations

For write operations (create, update, delete), the agent may retry on transient errors. Without idempotency, retries create duplicates.

// Idempotency key from the agent
interface CreateResourceArgs {
  idempotency_key: string;  // The agent provides a stable key
  name: string;
  // ...
}

async function createResource(args: CreateResourceArgs) {
  // Check if already created with this key
  const existing = await db.idempotency.get(args.idempotency_key);
  if (existing) return existing;
  
  // Perform the create
  const resource = await upstream.create(args);
  
  // Record the idempotency mapping
  await db.idempotency.set(args.idempotency_key, resource);
  return resource;
}

The agent provides an idempotency key (UUID, hash of parameters, etc.). The server deduplicates. Retries are safe.

Decision 11: Distribution — Container Image + Signature + Provenance

How users install your server determines adoption:

# Option 1: NPX (lowest friction for JS ecosystem)
npx -y @myorg/mcp-server

# Option 2: uvx (lowest friction for Python ecosystem)
uvx myorg-mcp-server

# Option 3: Docker (production, multi-tenant)
docker pull myorg/mcp-server:latest
docker run -i --rm myorg/mcp-server

# Option 4: Published to Docker MCP Catalog (best for discovery)
# Submit at hub.docker.com/mcp

For maximum trust (production deployments, regulated environments), distribute via Docker with signature + provenance:

# Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist/ ./dist/
ENTRYPOINT ["node", "dist/index.js"]

# Sign with cosign
cosign sign myorg/mcp-server:v1.2.3 \
  --key cosign.key

# Generate SLSA provenance
slsa-github-generate --repo myorg/mcp-server

Then publish to hub.docker.com (with MCP catalog submission) and ghcr.io as backup.

For supply-chain verification, ship a cosign.pub key, document the verification in the README, and encourage users to verify before installation.

Decision 12: Documentation — OpenAPI-Style Schema + Examples

Your README is the first thing users see. The minimum bar:

# @myorg/mcp-server

Brief description (1 sentence).
Authentication: MyOrg API key (env: MYORG_API_KEY, restricted key required).

## Tools

### send_email
Send a transactional email.

Parameters:
- to (string[], required): Recipient addresses
- subject (string, required): Email subject
- body (string, required): Plaintext email body
- html (string, optional): HTML email body
- from (string, optional, default: "hello@example.com"): Sender address

Returns: { id: string, status: "queued" }

Example:
\`\`\`json
{
  "tool": "send_email",
  "args": {
    "to": ["customer@example.com"],
    "subject": "Welcome",
    "body": "Welcome to MyOrg!"
  }
}
\`\`\`

### create_contact
...

## Authentication
1. Create a restricted API key at https://dashboard.myorg.com/api-keys
2. Set MYORG_API_KEY env var
3. Restart your MCP client

## Installation
\`\`\`bash
npx -y @myorg/mcp-server
\`\`\`

## Security
- Restricted keys only (full secret keys are rejected)
- Secrets never logged
- Idempotency keys required for write operations

## License
MIT

## Contributing
See CONTRIBUTING.md
\`\`\`

The documentation must be enough to install, configure, and use the server without reading the source code. Every tool, every parameter, every return shape, every error case.

The Pattern Library: Standard Recipes

Beyond the 12 decisions, production-grade servers follow a few standard patterns:

The Pagination Pattern

For list operations, support cursor-based pagination:

interface ListArgs {
  limit?: number;          // default 20, max 100
  cursor?: string;         // opaque cursor from previous response
}

interface ListResponse<T> {
  items: T[];
  next_cursor?: string;    // present if more results
}

The agent passes cursor to fetch the next page. No offset-based pagination (which is brittle when data changes).

The Search Pattern

For search operations, support filter + sort + pagination:

interface SearchArgs {
  query?: string;          // full-text search
  filter?: {              // structured filter
    status?: string;
    created_after?: string;  // ISO 8601
    // ...
  };
  sort?: { field: string; direction: 'asc' | 'desc' };
  limit?: number;
  cursor?: string;
}

The agent combines fuzzy search with structured filters. The result is precise, predictable search.

The Bulk Operations Pattern

For operations that touch many items:

// Option 1: batch in one call
bulk_create({ items: [...] })  // up to 100 per call

// Option 2: idempotent bulk with progress
start_bulk({ items: [...] })  // returns job_id
get_bulk_status({ job_id })   // poll for status

For SaaS APIs, batch is the right primitive. For most other use cases, idempotent jobs with status polling works well.

The Async Operation Pattern

For long-running operations (deploy, migration, build):

// Start the operation
start_operation({ type: "deploy", config: {...} });
  → Returns: { operation_id, status: "running" }

// Poll for status
get_operation_status({ operation_id });
  → Returns: { operation_id, status: "running" | "succeeded" | "failed", result?: {...} }

The agent starts the operation, polls for status, retrieves the result when done. Non-blocking operations don't block the agent.

The Webhook Configuration Pattern

For servers that emit events:

// Configure webhook
create_webhook({
  url: "https://fac.io/api/webhooks/myorg",
  events: ["issue.created", "issue.updated"],
  signing_secret: "whsec_..."
});

// The agent receives events via Facio's webhook receiver
// (handled by Facio's MCP gateway)

Webhooks make the agent event-driven, not just request-driven. The server can push events to the agent as they happen.

Security Hardening: The Non-Negotiable List

Beyond the patterns, every server must:

  1. Reject over-broad credentials — full secret keys banned, restricted keys only
  2. Validate input schemas — Zod or JSON Schema with strict validation
  3. Sanitize output — strip credentials, PII, internal metadata before returning
  4. No tool descriptions with sensitive content — descriptions are agent-visible
  5. Idempotency for writes — safe retries, no duplicates
  6. Rate limiting — respect upstream APIs
  7. Audit logging — every tool call logged (the MCP client should record it)
  8. No arbitrary code execution — don't expose eval, exec, run_command
  9. No filesystem access — use the Filesystem MCP if you need files
  10. Read-only by default — require explicit --write-enabled for write tools

For servers with write tools, the Facio HITL pattern applies:

// Severity hint per tool
const TOOL_SEVERITY = {
  list_*: 'read',
  get_*: 'read',
  create_*: 'write',
  update_*: 'write',
  delete_*: 'destructive',
  bulk_*: 'destructive',
};

const tool = {
  name: 'delete_resource',
  description: 'Permanently delete a resource. Requires HITL approval.',
  severity: 'destructive',  // hints to the Facio / MCP client
  // ...
};

The MCP client (Facio) reads the severity hint and applies the right HITL gating.

Testing Strategy

Before publishing, every server must pass:

Test TypeWhat It Covers
Unit testsEach tool's logic, with mocked upstream API
Integration testsReal upstream calls (test mode / sandbox)
Contract testsTool schemas match the published spec
Security testsInjection, path traversal, prompt injection defense
Permission testsRestricted key enforcement, scope isolation
Idempotency testsRetries don't create duplicates
Rate-limit testsThe server respects upstream limits
Error handling testsEvery error code has the right message
HITL gate testsDestructive tools surface to the Facio HITL layer

CI pipeline must include all of these. A server that ships without tests is a server that breaks in production.

Versioning + Compatibility

Servers must follow semantic versioning strictly:

  • MAJOR (1.0 → 2.0): breaking tool changes, removed tools, required config changes
  • MINOR (1.0 → 1.1): new tools, new parameters, deprecation warnings
  • PATCH (1.0 → 1.0.1): bug fixes, no API changes

Deprecation policy:

  • Announce in the tool's description (e.g., "DEPRECATED: use list_v2 instead. Will be removed in v2.0")
  • Log deprecation warnings to stderr (visible to operators)
  • Keep deprecated tools working for at least one MAJOR version
  • Document the migration path in the README

For breaking changes, ship major version bumps with clear migration guides. Never silently break.

Distribution Channels

After authoring, distribute via:

  1. NPM (for TypeScript) — npm publish
  2. PyPI (for Python) — uvx pulls from here
  3. Docker Hubdocker push myorg/mcp-server
  4. GitHub Container Registry — backup registry
  5. Docker MCP Cataloghub.docker.com/mcp (curated list)
  6. Glama MCP directory — community catalog

For maximum trust, ship to all six. Each channel reduces friction for a different deployment.

The Authoring Checklist

Before publishing, verify:

  • Transport: stdio works, HTTP works (if applicable)
  • License: MIT or Apache 2.0, declared in LICENSE file
  • Auth: Restricted credentials only, env-var injection, no secrets in logs
  • Tool surface: Domain-grouped, minimal, well-named
  • Descriptions: One sentence, actionable, parameter list
  • Output format: Structured JSON for data, Markdown for content
  • Error handling: Structured errors with code, message, actionable
  • Rate limiting: Built-in, retry-aware
  • Idempotency: Required for write operations
  • Distribution: NPX / uvx / Docker, signature + provenance
  • Documentation: README + every tool documented + example calls
  • Tests: Unit + integration + security + permission + idempotency + rate-limit
  • Versioning: semver strict, deprecation policy, migration guides
  • HITL severity: Tools declare severity (read/write/destructive)
  • License: Clear, max-adoption choice
  • Community: Issues, discussions, contributing guide

A server that passes this checklist is production-ready for Facio and the broader MCP ecosystem.

Bottom Line

Building a production-grade MCP server in 2026 is a matter of following the playbook. The 12 decisions + standard patterns + security hardening + tests + distribution channels produce a server that's safe, useful, and adopted.

The MCP ecosystem rewards servers that follow the playbook. Independent authors who ship secure, well-documented, well-tested servers find their work used by hundreds of agent deployments. Those who skip the playbook — weak credentials, sloppy tool surfaces, no tests — create the next breach headline.

Make the boring choices. MIT license. Per-resource auth. Env-var secrets. Structured errors. Idempotent writes. Then make the creative ones — the tool surface, the use cases, the value your server unlocks.

The framework is laid out in this post. The decisions are yours.


MCP Server Authoring Guide is part of the Facio engineering documentation. Companion to the MCP Spotlight series covering individual MCP servers. Last updated: 2026-07-21.

Keep reading

More on Engineering

View category
Jul 20, 2026Engineering

MCP Spotlight: GitHub MCP Server — The Official Code-and-Collaboration Bridge With Fine-Grained PATs, PR/Issue Workflows, and the Engineering-Workflow Default for Agents

The official GitHub MCP Server by GitHub — ~40 focused tools covering issues, PRs, code, commits, branches, Actions, releases, reviews, security alerts. Fine-Grained Personal Access Token support for per-repo + per-action scope isolation. Branch-protection-aware merge control. MIT-licensed. The code-collaboration default for AI agents in 2026.

Jul 19, 2026Engineering

MCP Spotlight: Docker MCP Server — The Container Operations Bridge With `mcp://` Catalog Protocol, MCP Toolkit, and the Container-Default Reference for Agents

Docker's official MCP infrastructure — the Docker Hub MCP Catalog (300+ verified servers with signatures + provenance), the MCP Toolkit (isolated runtime execution via Docker MCP Gateway), and the mcp:// URL protocol for declarative installation. Apply container-ecosystem primitives (signing, isolation, secrets, orchestration) to MCP.

Jul 18, 2026Engineering

MCP Spotlight: Resend MCP Server — The Email Bridge With 10 Tool Groups, Send/Receive Symmetry, and the Transactional-Default Reference for Agents

The official Resend MCP Server by Resend — ~25 tools across 10 domain groups covering emails, contacts, broadcasts, domains, webhooks, segments, topics, contact properties, API keys, received emails. Send/receive symmetry, Topics for GDPR preference management, hosted at mcp.resend.dev or self-hosted via NPX. MIT-licensed.