Switch to light mode

Which MCP Server Should You Build? A Decision Guide for the 5 Real Patterns

- 10 min read

Decision guide for choosing the right MCP server pattern

As of mid-2026, there are over 13,000 MCP servers in the wild and 97 million monthly SDK downloads. The ecosystem has exploded. The documentation has not kept pace.

The tutorials teach you how to build an MCP server. They don’t teach you which kind to build or why. That’s the actual decision, and getting it wrong means your server either can’t be shared, can’t scale, or requires infrastructure nobody asked for.

Here’s the decision guide that should exist but doesn’t.

The Fundamental Confusion

MCP servers run in two fundamentally different modes: local (stdio) or remote (HTTP). The SDK supports TypeScript (@modelcontextprotocol/sdk), Python (mcp), and Kotlin - but the transport layer choice is orthogonal to the language choice. You can write a stdio server in Python or TypeScript. Same for HTTP.

The confusion happens because most tutorials pick one mode and run with it, leaving developers to discover the tradeoffs after they’ve already committed. The worst version of this: building an HTTP server when you needed stdio (unnecessary hosting overhead), or building a stdio server when you needed HTTP (can’t share it without asking everyone to clone your repo and install your runtime).

There are five real patterns. Here’s each one with honest tradeoffs.

Pattern 1: Local stdio Script (Simplest)

The shape of it: Your server runs as a child process on the user’s machine. Communication happens over stdin/stdout. Claude Desktop or Claude Code spawns it on demand.

Use when:

  • The tool only needs to run on your machine
  • It talks to local files, local processes, or local databases
  • You don’t need to share it with anyone

Config in claude_desktop_config.json:

{
  "mcpServers": {
    "my-tool": {
      "command": "node",
      "args": ["/absolute/path/to/server.js"]
    }
  }
}

For Python:

{
  "mcpServers": {
    "my-tool": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}

What you get: Zero infrastructure. No hosting. No auth. No versioning. It just works on your machine.

The real downsides: The user (you) must have the runtime installed - Node, Python, whatever you used. The server restarts on every Claude Desktop launch, which means any in-memory state is gone. And “share this with a teammate” means “here’s a repo, clone it, install dependencies, update your config.”

This pattern covers probably 60% of legitimate MCP use cases. Most tools people actually want to build are machine-local. If you’re connecting to your local database, reading files from your filesystem, or wrapping a CLI tool you already have installed, stdio is the right call.

Pattern 2: npm Package (Distributable stdio)

The shape of it: Same as Pattern 1, but instead of pointing at a local file, users install your package via npx. The server still runs locally via stdio.

Use when:

  • You want to share a local tool with others
  • Users shouldn’t need to clone a repo or manage a copy of your code
  • The tool runs locally on each user’s machine

Config:

{
  "mcpServers": {
    "your-tool": {
      "command": "npx",
      "args": ["-y", "your-package-name"]
    }
  }
}

The -y flag auto-accepts the install prompt. Users get your latest published version without any manual update step.

Real example: ollama-mcp ships this way. Users add it to their config with npx -y ollama-mcp and it just works, pulling from npm on first run.

The real downsides: You need to publish to npm. That means versioning, changelogs, and the overhead of a public package. It also means your server is public - anyone can install it. Not a problem for most tools, but worth being explicit about.

This is the right pattern when your tool is genuinely useful to other developers but still fundamentally local - it reads their files, talks to their local services, or wraps a CLI they have installed.

Pattern 3: .mcpb Bundle (Claude Desktop Native)

The shape of it: A .mcpb file is a zip archive containing your server files plus a manifest.json, renamed with the .mcpb extension. Users install it by dragging it into the Claude Desktop Extensions panel.

Use when:

  • You’re specifically targeting Claude Desktop users
  • You want GUI-based installation with no CLI involvement
  • Your users aren’t developers and won’t touch a config file

Minimal manifest.json:

{
  "name": "My Tool",
  "version": "1.0.0",
  "description": "What this tool does",
  "server": {
    "command": "node",
    "args": ["server.js"]
  }
}

Build the bundle:

zip -r my-tool.mcpb server.js manifest.json package.json node_modules/

Real example: yayoboy/Claude-Desktop-LLM ships .mcpb files for exactly this reason - clean installation for Claude Desktop users.

The real downsides: Claude Desktop only. This format doesn’t work with Claude Code, other MCP clients, or anything programmatic. You also can’t install .mcpb bundles via API or scripting - it’s a GUI drag-and-drop operation. Bundle size matters because everything needs to be in the zip.

Use this pattern when your target user is “someone who uses Claude Desktop” not “a developer configuring AI tooling.”

Pattern 4: HTTP Server with Bearer Auth (Team/Shared Tool)

The shape of it: Your server runs persistently on infrastructure you control. Clients connect over HTTP with an Authorization header. Multiple users connect to the same server instance.

Use when:

  • Multiple people on your team need access to the same tool
  • The tool lives on a server (talks to a database, internal API, or external service)
  • You want one central instance rather than per-user installations

Minimal server structure (TypeScript):

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

const server = new McpServer({ name: "team-tool", version: "1.0.0" });

// Register your tools here
server.tool("query-db", { query: z.string() }, async ({ query }) => {
  // tool implementation
});

const transport = new StreamableHTTPServerTransport({ port: 3000 });
await server.connect(transport);

Auth middleware validates the Bearer token before requests reach your tools:

app.use((req, res, next) => {
  const token = req.headers.authorization?.replace("Bearer ", "");
  if (token !== process.env.MCP_SECRET) {
    return res.status(401).json({ error: "Unauthorized" });
  }
  next();
});

Client config (using the remote URL):

{
  "mcpServers": {
    "team-tool": {
      "url": "https://your-server.example.com/mcp",
      "headers": {
        "Authorization": "Bearer your-shared-token"
      }
    }
  }
}

The real downsides: You need to host and maintain a server. You need to manage the shared token - rotation, distribution, revocation. Everyone sharing a token means you can’t distinguish between users in your logs. This works fine for a small team where you trust everyone equally. It breaks down when you need per-user audit trails or the ability to revoke access for a specific user.

This is the right pattern for internal tooling: your team’s Jira integration, your internal documentation search, your company’s database query tool.

Pattern 5: HTTP Server with OAuth PKCE (Public/Production)

The shape of it: Full OAuth 2.0 with PKCE flow. Users authenticate individually. Your server issues per-user tokens. This is the complete MCP auth spec.

Use when:

  • You’re building a product other people will use
  • Users shouldn’t share tokens
  • You need per-user audit trails or the ability to revoke individual access
  • You’re integrating with a service that has its own OAuth flow

The honest assessment of complexity: This is not a weekend project. A correct PKCE implementation requires handling the authorization endpoint, token endpoint, refresh flow, and PKCE verifier/challenge pair. The MCP SDK has helpers, but you’re still building OAuth infrastructure.

The flow:

  1. Client generates a PKCE verifier and challenge
  2. Client redirects user to your authorization endpoint
  3. User authenticates (to your system or a provider you delegate to)
  4. Your server issues an authorization code
  5. Client exchanges code + verifier for tokens
  6. All subsequent requests use Bearer tokens

Use this pattern only if: You’re building something other people will use publicly and you need real user isolation. If you’re building internal tooling or a tool for your own use, Pattern 4 is simpler and sufficient.

The MCP spec documents this pattern fully. Don’t roll your own PKCE - use a library.

The Decision Tree

Run through this in order:

Just you, on your machine? - stdio script (Pattern 1). Stop here.

Want to share with other developers without hosting anything? - npm package (Pattern 2). Stop here.

Targeting Claude Desktop users specifically, who aren’t developers? - .mcpb bundle (Pattern 3). Stop here.

Your team needs shared access to a tool that lives on a server? - HTTP + bearer auth (Pattern 4). Stop here.

Building a product for external users with per-user auth requirements? - HTTP + OAuth PKCE (Pattern 5).

The Common Mistake

The most frequent wrong turn I see: building an HTTP server when you need stdio.

Someone reads a tutorial about HTTP MCP servers, builds one, deploys it to Fly.io, sets up authentication - and then realizes they’re the only user and the tool just reads their local files. They’ve added $20/month hosting and significant auth complexity to solve a problem that a 50-line stdio script would have handled.

The second most common mistake: building a stdio script when you need HTTP. Someone writes a great database query tool, shares the repo with their team, and then fields three hours of “how do I set this up” questions per new team member. An HTTP server with a shared token takes thirty minutes to deploy to Render and requires zero setup on client machines.

The stdio vs HTTP distinction is entirely about deployment model, not capability. Both can call external APIs. Both can run complex logic. Both support the full MCP tool/resource/prompt spec. The only question is: where does the server run and who connects to it?

A Note on SDK Versions

The TypeScript SDK added StreamableHTTPServerTransport in v1.0+ to replace the older SSE-based transport. If you’re following older tutorials that reference SSEServerTransport, you’re using a deprecated pattern. The current HTTP transport is StreamableHTTP.

For local stdio, the transport is still StdioServerTransport and hasn’t changed meaningfully.

Pick the pattern that matches your actual deployment requirement. Then build the simplest version that satisfies it.

© 2024 Shawn Mayzes. All rights reserved.