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.
| Decision | Default | Why |
|---|---|---|
| Transport | stdio (NPX) | Lowest friction for local development |
| SDK | Official MCP SDK (TypeScript or Python) | Best long-term compatibility |
| License | MIT | Maximum adoption |
| Auth | Per-resource scope isolation | Principle of least privilege |
| Secrets | Env-var injection at startup | Never in agent context |
| Tool surface | Domain-grouped, minimal | Discoverability + context efficiency |
| Output format | Structured (JSON) for data; Markdown for content | Parseable + readable |
| Error handling | Structured errors with actionable messages | The agent needs to recover |
| Rate limiting | Built-in client-side aware | Respect upstream APIs |
| Idempotency | Idempotency keys for write operations | Retries are safe |
| Distribution | Container image + signature + provenance | Supply-chain trust |
| Documentation | OpenAPI-style tool schema + use-case examples | Self-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:
| Language | Repository | Best For |
|---|---|---|
| TypeScript | github.com/modelcontextprotocol/typescript-sdk | Node.js / Deno / Bun environments, npm distribution |
| Python | github.com/modelcontextprotocol/python-sdk | Python-native agents, data science, ML tooling |
| Go | github.com/modelcontextprotocol/go-sdk | High-performance servers, system-level tools |
| Kotlin / Java | github.com/modelcontextprotocol/kotlin-sdk | JVM agents |
| C# / .NET | github.com/modelcontextprotocol/csharp-sdk | .NET agents |
| Ruby | community-maintained | Ruby/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?
| License | When to Use |
|---|---|
| MIT | Maximum adoption; permissive; works for most servers |
| Apache 2.0 | Patent grants; corporate-friendly; the model chosen by Notion, Cloudflare, Stripe |
| Custom | Only 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
descriptionfield 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-pattern | Why It's Bad |
|---|---|
| 100+ flat tools | Bloats context; loses semantic structure |
| 1 mega-tool that does everything | Hard to compose, hard to RBAC |
| Tool names with platform jargon | Agent doesn't know what they do |
Tools that overlap (e.g., list_recent and list_latest) | Confusing; pick one |
| Tools with cryptic parameters | Agent 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 Type | Format | Example |
|---|---|---|
| Records, lists, queries | Structured JSON | [{id, name, ...}, ...] |
| Search results | Structured JSON with relevance | [{id, title, score}, ...] |
| Documents, articles | Markdown | # Title\n\nBody... |
| Code files | Markdown (code-fenced) | ```typescript ... ``` |
| Status / health | Structured JSON | {status, version, ...} |
| Errors | Structured 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:
| Pattern | Code | Recovery Hint |
|---|---|---|
| Missing credentials | auth_missing | "Set MYORG_API_KEY env var" |
| Invalid credentials | auth_invalid | "Verify key in dashboard" |
| Rate limit | rate_limit_exceeded | "Retry after N seconds" |
| Not found | not_found | "Verify resource ID" |
| Validation error | validation_error | List of validation failures |
| Upstream error | upstream_error | "Upstream API returned 5xx; retry" |
| Timeout | timeout | "Increase timeout or retry" |
| Internal error | internal_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:
- Read the rate-limit headers (
X-RateLimit-Remaining,X-RateLimit-Reset) - Implement client-side throttling (don't hammer the upstream)
- Expose rate-limit status (a tool or capability that reports current limits)
- 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:
- Reject over-broad credentials — full secret keys banned, restricted keys only
- Validate input schemas — Zod or JSON Schema with strict validation
- Sanitize output — strip credentials, PII, internal metadata before returning
- No tool descriptions with sensitive content — descriptions are agent-visible
- Idempotency for writes — safe retries, no duplicates
- Rate limiting — respect upstream APIs
- Audit logging — every tool call logged (the MCP client should record it)
- No arbitrary code execution — don't expose
eval,exec,run_command - No filesystem access — use the Filesystem MCP if you need files
- Read-only by default — require explicit
--write-enabledfor 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 Type | What It Covers |
|---|---|
| Unit tests | Each tool's logic, with mocked upstream API |
| Integration tests | Real upstream calls (test mode / sandbox) |
| Contract tests | Tool schemas match the published spec |
| Security tests | Injection, path traversal, prompt injection defense |
| Permission tests | Restricted key enforcement, scope isolation |
| Idempotency tests | Retries don't create duplicates |
| Rate-limit tests | The server respects upstream limits |
| Error handling tests | Every error code has the right message |
| HITL gate tests | Destructive 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:
- NPM (for TypeScript) —
npm publish - PyPI (for Python) —
uvxpulls from here - Docker Hub —
docker push myorg/mcp-server - GitHub Container Registry — backup registry
- Docker MCP Catalog —
hub.docker.com/mcp(curated list) - 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
LICENSEfile - 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.