Level 2 · Day 14 of 90

MCP Registry & Discovery

How Claude Desktop finds servers, the anatomy of configuration files, package-based installation patterns, tool namespacing, and everything you need to publish a discoverable MCP server.

📍 Phase Spark
🎯 Level 2 of 9
⏱ Read time
🔧 Topic Registry & Discovery

How Does a Host Know Which Servers Exist?

MCP servers don't broadcast themselves. There is no automatic detection, no mDNS, no service registry running in the background. Every host application needs to be explicitly told where to find its servers — and this gap is exactly what the discovery layer solves.

When Claude Desktop launches, it faces a fundamental question: what tools does the user want me to have? The answer lives entirely in a configuration file. Without that file, Claude has no tools, no resources, no prompts — just the base language model. With it, Claude can suddenly search GitHub, query databases, send Slack messages, and read your file system.

This might feel like a limitation, but it is actually a deliberate security design. Explicit configuration means:

The current ecosystem has three distinct discovery mechanisms, each suited to different deployment contexts:

📄
Static Configuration
JSON file that lists servers with their command, args, and environment. The dominant pattern today. Simple, auditable, version-controllable.
claude_desktop_config.json
📦
Package Installation
npm / uvx packages that ship as runnable MCP servers. Install once, configure by name. The "app store" model for MCP.
npm / uvx / npx
🌐
Remote Registry
Community-curated lists and future official registries. Browse, verify, and install servers from a central catalogue. Still emerging.
mcp.so · glama.ai

By the end of today you will understand all three, know which to use when, and be able to publish a server that any host can discover and run.

Full Configuration File Anatomy

The claude_desktop_config.json file is the primary discovery mechanism for Claude Desktop. Understanding every field unlocks full control over which servers load, how they authenticate, and what resources they consume.

The file lives at different paths per OS:

OSPath
macOS~/Library/Application Support/Claude/claude_desktop_config.json
Windows%APPDATA%\Claude\claude_desktop_config.json
Linux~/.config/Claude/claude_desktop_config.json

Here is a fully annotated config with every supported option:

📄 claude_desktop_config.json — complete anatomy
{
  "mcpServers": {

    // ── Stdio server (local process) ──────────────────────────
    "github": {
      "command": "npx",              // executable to run
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx"
      }
    },

    // ── Python / uvx server ───────────────────────────────────
    "filesystem": {
      "command": "uvx",
      "args": ["mcp-server-filesystem", "/home/user/projects"],
      "env": {}
    },

    // ── Local TypeScript project (dev mode) ───────────────────
    "my-server": {
      "command": "node",
      "args": ["/absolute/path/to/dist/index.js"],
      "env": {
        "NODE_ENV": "production",
        "API_KEY": "secret-goes-here"
      }
    },

    // ── HTTP / Streamable HTTP remote server ──────────────────
    "remote-analytics": {
      "url": "https://mcp.example.com/mcp",  // uses Streamable HTTP
      "headers": {
        "Authorization": "Bearer eyJ..."
      }
    },

    // ── Docker-based server ───────────────────────────────────
    "postgres": {
      "command": "docker",
      "args": [
        "run", "--rm", "-i",
        "-e", "DATABASE_URL",
        "mcp/postgres:latest"
      ],
      "env": {
        "DATABASE_URL": "postgresql://user:pass@host:5432/db"
      }
    }
  }
}
💡
Key rule: Use command + args for stdio servers (local process). Use url for remote HTTP/Streamable HTTP servers. You cannot mix them in one entry. For stdio servers, the process is spawned by Claude Desktop; for URL servers, Claude Desktop connects as an HTTP client.

After editing the config, Claude Desktop must be fully restarted (not just reloaded) to pick up changes. There is currently no hot-reload mechanism.

stdio vs HTTP vs Streamable HTTP in Config

The config format differs depending on transport. Understanding these differences helps you choose the right deployment model and debug connection failures quickly.

stdio
Local Process
  • Spawned as a child process
  • Communicates via stdin/stdout
  • Lives and dies with Claude Desktop
  • No network port needed
  • Best for local tools
  • Config: command + args
HTTP + SSE (legacy)
Remote v1
  • Two endpoints: GET /sse + POST /message
  • Requires reverse proxy (nginx)
  • Needs proxy_buffering off
  • No resumability
  • Config: url → /sse endpoint
  • Deprecated in favour of Streamable HTTP
Streamable HTTP
Remote v2
  • Single endpoint: POST /mcp
  • Supports event-ID resumability
  • Proxy-friendly, serverless-compatible
  • Auth via headers field
  • Config: url → /mcp endpoint
  • Recommended for all new deployments
config examples — all three transports// 1. stdio — starts node process locally
"my-tool": {
  "command": "node",
  "args": ["/Users/you/my-mcp-server/dist/index.js"],
  "env": { "LOG_LEVEL": "info" }
}

// 2. HTTP+SSE — legacy, points to /sse endpoint
"my-remote-v1": {
  "url": "https://mcp.example.com/sse"
}

// 3. Streamable HTTP — points to /mcp endpoint, with auth
"my-remote-v2": {
  "url": "https://mcp.example.com/mcp",
  "headers": {
    "Authorization": "Bearer ${MY_TOKEN}"
  }
}
⚠️
Environment variable interpolation: Some MCP hosts support ${VAR_NAME} syntax in the config. Claude Desktop (as of early 2026) does not interpolate env vars in the headers field — values must be literal strings. For stdio servers, the env object passes variables to the spawned process environment, so you can reference them in server code via process.env.VAR_NAME.

npm, uvx, npx — the MCP "App Store" Model

The most user-friendly MCP servers ship as installable packages. Instead of cloning a repo and running npm install && npm run build, users run a single command or add a one-liner to their config. This is the direction the ecosystem is heading.

npx — Zero-install Node packages

The npx -y pattern downloads and executes an npm package without a permanent install. Claude Desktop uses this constantly for official servers:

claude_desktop_config.json"brave-search": {
  "command": "npx",
  "args": ["-y", "@modelcontextprotocol/server-brave-search"],
  "env": { "BRAVE_API_KEY": "BSAxxxxxxx" }
}

"github": {
  "command": "npx",
  "args": ["-y", "@modelcontextprotocol/server-github"],
  "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxx" }
}

"memory": {
  "command": "npx",
  "args": ["-y", "@modelcontextprotocol/server-memory"]
}

The -y flag auto-accepts the install prompt. Without it, the process blocks waiting for user confirmation — the server never starts.

uvx — Zero-install Python packages

uvx is the Python equivalent — it uses uv's tool runner to execute a Python package in an isolated environment without polluting global Python. Perfect for Python-based MCP servers:

claude_desktop_config.json — Python servers via uvx"filesystem": {
  "command": "uvx",
  "args": ["mcp-server-filesystem", "/Users/you/Documents"]
}

"sqlite": {
  "command": "uvx",
  "args": ["mcp-server-sqlite", "--db-path", "/tmp/mydb.db"]
}

"git": {
  "command": "uvx",
  "args": ["mcp-server-git", "--repository", "/path/to/repo"]
}
ℹ️
Why uvx over pip? Pip installs globally; uvx runs in a throw-away venv created on the fly. This prevents dependency conflicts between servers and makes the system easier to reason about. If uvx is not installed, run curl -LsSf https://astral.sh/uv/install.sh | sh.

Publishing your own npm MCP package

To make your server installable via npx, you need the right package.json shape:

package.json — publishable MCP server{
  "name": "@yourscope/mcp-server-weather",
  "version": "1.0.0",
  "description": "MCP server for weather data via OpenWeatherMap",
  "type": "module",
  "bin": {
    "mcp-server-weather": "./dist/index.js"
  },
  "main": "./dist/index.js",
  "files": ["dist/"],
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "prepublishOnly": "npm run build"
  },
  "keywords": ["mcp", "mcp-server", "weather", "openweathermap"],
  "engines": { "node": ">=18" },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0",
    "zod": "^3.22.0"
  },
  "devDependencies": {
    "typescript": "^5.3.0"
  }
}

The bin field is critical — it tells npm what executable to create when installed. Without it, npx has nothing to run. The entry point must have a shebang: #!/usr/bin/env node.

dist/index.js — entry point with shebang#!/usr/bin/env node
// This line makes the file executable via npx / node directly
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({
  name: 'weather',
  version: '1.0.0'
});

server.tool(
  'get_weather',
  { location: z.string().describe('City name or coordinates') },
  async ({ location }) => {
    const apiKey = process.env.OPENWEATHER_API_KEY;
    if (!apiKey) throw new Error('OPENWEATHER_API_KEY not set');
    const res = await fetch(
      `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(location)}&appid=${apiKey}&units=metric`
    );
    const data = await res.json() as any;
    return { content: [{ type: 'text', text: `${location}: ${data.main.temp}°C, ${data.weather[0].description}` }] };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Once published, users can add your server with just:

"weather": {
  "command": "npx",
  "args": ["-y", "@yourscope/mcp-server-weather"],
  "env": { "OPENWEATHER_API_KEY": "abc123" }
}

The initialize Handshake — Your Server's Identity

Every MCP connection starts with an initialize request–response exchange. This is your server's moment to declare its identity, its protocol version, and exactly which capabilities it supports. Get this right and hosts can discover your features automatically.

The initialize response shape, with all capability fields:

initialize response — complete shape{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-03-26",   // latest spec version
    "serverInfo": {
      "name": "weather",              // short kebab-case name
      "version": "1.0.0"               // semver
    },
    "capabilities": {
      "tools": {
        "listChanged": true           // server can push tool list changes
      },
      "resources": {
        "subscribe": true,            // clients can subscribe to resource updates
        "listChanged": true
      },
      "prompts": {
        "listChanged": true
      },
      "logging": {},                    // server can send log messages to client
      "experimental": {
        "progressReporting": true      // optional experimental features
      }
    }
  }
}

When using the TypeScript SDK's McpServer, capabilities are inferred automatically based on what you register. If you call server.tool(), the SDK sets capabilities.tools. You only need to declare capabilities explicitly when using the low-level Server class:

TypeScript SDK — explicit capability declaration (low-level)import { Server } from '@modelcontextprotocol/sdk/server/index.js';

const server = new Server(
  { name: 'my-server', version: '2.1.0' },
  {
    capabilities: {
      tools: { listChanged: true },
      resources: { subscribe: true, listChanged: true },
      prompts: { listChanged: true },
      logging: {}
    }
  }
);
💡
Capability negotiation: The client sends its capabilities in the initialize request; the server responds with its own. The intersection of both determines what features are available for the session. If the server advertises resources.subscribe but the client does not support subscriptions, subscription calls will be ignored or fail gracefully.

Protocol Version Negotiation

The client sends "protocolVersion": "2025-03-26" (or whatever it supports). The server must respond with a version it also supports. If there is no overlap, the connection must be closed. The SDK handles this for you when using McpServer.

VersionReleaseKey additions
2024-11-05Nov 2024Initial release. stdio + HTTP+SSE transports, Tools/Resources/Prompts primitives
2025-03-26Mar 2025Streamable HTTP transport, resumable events, request batching, enhanced progress

Avoiding Tool Name Collisions Across Servers

When a host loads multiple MCP servers simultaneously, their tools all end up in the same namespace from the AI's perspective. If two servers both expose a tool named search, the AI has no way to distinguish them — and the host has to pick one or fail.

Good tool naming eliminates ambiguity before it starts. Here are the conventions that have emerged from the MCP ecosystem:

Example: Two servers with naive naming cause a collision. Good naming prevents it.

❌ BAD — github server
search list_issues create
❌ BAD — jira server
search list_issues create
✅ GOOD — github server
github_search_repos github_list_issues github_create_issue
✅ GOOD — jira server
jira_search_issues jira_list_issues jira_create_issue

Naming rules that the MCP community follows:

RuleExample (good)Example (bad)
Prefix with service namegithub_create_prcreate_pr
snake_case onlyslack_send_messageslackSendMessage
Verb_noun patterndb_query_tabledb_table
Max 64 charactersgithub_get_pull_requestgithub_fetch_the_current_pull_request_data_from_api
Avoid generic verbs alonenotion_search_pagessearch
Consistent CRUD verbsget, list, create, update, deletefetch, retrieve, make, add, remove

Tool descriptions are equally important. The AI uses the description field to decide when to call the tool. A good description answers three questions: what does this do, when should you use it, and what data does it return?

TypeScript — well-annotated tool registrationserver.tool(
  'github_search_repositories',
  {
    query: z.string().describe(
      'Search query using GitHub search syntax. Supports qualifiers like ' +
      'language:typescript, stars:>100, user:octocat. Example: "mcp server language:typescript"'
    ),
    limit: z.number().int().min(1).max(30).default(10)
      .describe('Maximum number of repositories to return (1-30, default 10)')
  },
  async ({ query, limit }) => { /* ... */ },
  {
    description:
      'Search GitHub repositories by keyword, language, stars, or owner. ' +
      'Returns repo name, description, star count, URL, and primary language. ' +
      'Use when the user asks to find GitHub repos, explore open-source projects, ' +
      'or check if a specific project exists on GitHub.'
  }
);

Keeping Credentials Out of Config Files

The env block in claude_desktop_config.json is the primary way to pass secrets to stdio servers. But hardcoding tokens in a JSON file that might be committed to version control is a serious security risk. Here are the patterns to avoid credential leakage.

Pattern 1: OS Keychain via a wrapper script

Instead of putting the secret directly in the config, call a shell script that reads from the OS keychain:

~/.mcp-wrappers/github.sh#!/bin/bash
# Fetch token from macOS Keychain at runtime
export GITHUB_PERSONAL_ACCESS_TOKEN=$(security find-generic-password \
  -s "github-mcp-token" -w 2>/dev/null)

exec npx -y @modelcontextprotocol/server-github "$@"
claude_desktop_config.json — wrapper pattern"github": {
  "command": "/bin/bash",
  "args": ["/Users/you/.mcp-wrappers/github.sh"]
}

Pattern 2: .env file loaded by the server

For servers you build yourself, load secrets from a .env file in the server's directory using dotenv:

src/index.ts — dotenv loadingimport { config } from 'dotenv';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
config({ path: resolve(__dirname, '../.env') });

// Now process.env.MY_SECRET is available
const apiKey = process.env.MY_API_KEY;
if (!apiKey) {
  console.error('[my-server] MY_API_KEY not set — exiting');
  process.exit(1);
}

Pattern 3: Zod schema validation at startup

Use Zod to validate all required env vars at startup, providing clear error messages when something is missing:

src/config.ts — fail-fast env validationimport { z } from 'zod';

const EnvSchema = z.object({
  GITHUB_TOKEN: z.string().min(1, 'GITHUB_TOKEN is required'),
  SLACK_BOT_TOKEN: z.string().startsWith('xoxb-', 'Must be a bot token'),
  DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL'),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  PORT: z.coerce.number().int().min(1024).max(65535).default(3000)
});

const result = EnvSchema.safeParse(process.env);
if (!result.success) {
  console.error('[config] Invalid environment:');
  result.error.issues.forEach(i => console.error(` - ${i.path.join('.')}: ${i.message}`));
  process.exit(1);
}

export const config = result.data;
⚠️
Never commit claude_desktop_config.json to git if it contains real tokens. Add it to .gitignore. If you need to share your config structure with a team, create a claude_desktop_config.example.json with placeholder values ("GITHUB_TOKEN": "YOUR_TOKEN_HERE") and commit that instead.

Environment variable scoping for stdio servers

When Claude Desktop spawns a stdio process, the child process receives only the variables in the env object — it does not inherit the parent's full environment. This means system variables like PATH, HOME, and NODE_ENV must be explicitly passed if the server needs them:

passing system vars explicitly"my-server": {
  "command": "node",
  "args": ["/path/to/dist/index.js"],
  "env": {
    "PATH": "/usr/local/bin:/usr/bin:/bin",
    "HOME": "/Users/you",
    "NODE_ENV": "production",
    "MY_API_KEY": "secret"
  }
}

Adding and Removing Tools Without Restarting

Static tool registration — calling server.tool() before server.connect() — covers most use cases. But some servers need to change their tool list dynamically: multi-tenant servers where tools depend on user permissions, plugin-style architectures where tools are loaded from a database, or feature flags that toggle tools on/off.

MCP supports dynamic tool updates via the tools/list_changed notification. When a server changes its tool list, it notifies connected clients, which then re-fetch the list with tools/list.

dynamic-tools-server.ts — adding/removing tools at runtimeimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({
  name: 'dynamic-tools',
  version: '1.0.0'
});

// Initial tools — always available
server.tool('ping', {}, async () => ({
  content: [{ type: 'text', text: 'pong' }]
}));

// ── Feature-flag controlled tool ──────────────────────────────
let premiumEnabled = false;

server.tool(
  'admin_enable_premium',
  { enabled: z.boolean() },
  async ({ enabled }) => {
    premiumEnabled = enabled;

    if (enabled) {
      // Register premium tool dynamically
      server.tool(
        'premium_deep_analysis',
        { data: z.string() },
        async ({ data }) => ({
          content: [{ type: 'text', text: `Deep analysis of: ${data}` }]
        })
      );
    } else {
      // Remove premium tool — not yet directly supported,
      // but you can rebuild the server's tool set by restarting
      // or use setRequestHandler at the lower level
    }

    // Notify clients that tool list changed
    await server.server.sendToolListChanged();

    return {
      content: [{ type: 'text', text: `Premium features ${enabled ? 'enabled' : 'disabled'}` }]
    };
  }
);

// ── Permission-based tool loading ─────────────────────────────
async function loadToolsForUser(userId: string) {
  const permissions = await fetchUserPermissions(userId);  // your DB call

  if (permissions.includes('write:database')) {
    server.tool(
      'db_execute_statement',
      { sql: z.string() },
      async ({ sql }) => {
        // ... execute with user context
        return { content: [{ type: 'text', text: 'Executed' }] };
      }
    );
  }

  await server.server.sendToolListChanged();
}

const transport = new StdioServerTransport();
await server.connect(transport);
💡
When to use dynamic registration: Multi-tenant HTTP servers where each connected client has different permissions. Plugin systems where admins can install/uninstall tool sets. Feature flags that roll out capabilities gradually. For simple single-user stdio servers, static registration is always preferable — simpler, more predictable, easier to test.

Where to Find (and List) MCP Servers Today

As of early 2026, there is no official Anthropic-run MCP registry. Instead, a healthy ecosystem of community registries has emerged. Understanding them helps you both discover existing servers and get your own server in front of users.

Official
Anthropic GitHub
The official reference server repository. Maintained by Anthropic. Includes filesystem, GitHub, Slack, PostgreSQL, Brave Search, and more. Gold standard for implementation patterns.
github.com/modelcontextprotocol/servers
Community
mcp.so
The largest community-curated directory. Searchable by category, language, and use case. Submit your server via a pull request to their registry repo.
mcp.so
Verified
Glama.ai
Verified MCP server directory with quality badges. Servers are tested for security and functionality before listing. Good for enterprise use cases.
glama.ai/mcp/servers
Awesome List
awesome-mcp-servers
GitHub awesome-list style curated collection. Markdown format, categorized by domain (databases, cloud, productivity, etc.). Easy to browse and contribute to.
github.com/punkpeye/awesome-mcp-servers
Package
npm (keyword: mcp-server)
Search npm for keywords:mcp-server to find all published MCP server packages. Direct install via npx. Thousands of packages already indexed.
npmjs.com/search?q=keywords:mcp-server
Cursor
Cursor MCP Registry
Cursor IDE's built-in MCP registry. If your server is listed here, Cursor users can install it in one click from the IDE settings panel.
cursor.com/mcp

What a future official registry might look like

The MCP specification hints at a future /.well-known/mcp-registry.json endpoint and a central registry API. Based on the OAuth Dynamic Client Registration pattern (which MCP already uses), a registry entry would likely look something like this:

hypothetical registry entry format{
  "name": "@yourscope/mcp-server-weather",
  "displayName": "Weather MCP Server",
  "version": "1.2.0",
  "description": "Real-time weather data via OpenWeatherMap",
  "author": "Your Name",
  "transport": ["stdio"],
  "runtime": "node",
  "installCommand": "npx -y @yourscope/mcp-server-weather",
  "envRequired": [
    {
      "name": "OPENWEATHER_API_KEY",
      "description": "API key from openweathermap.org",
      "link": "https://openweathermap.org/api"
    }
  ],
  "tools": [
    { "name": "get_weather", "description": "Get current weather for a location" },
    { "name": "get_forecast", "description": "Get 7-day forecast" }
  ],
  "verified": true,
  "securityAudit": "2026-01-15"
}

Best Practices for Server Metadata

A discoverable server is one that hosts and users can understand at a glance — just by reading its metadata. Good metadata also improves the AI's tool selection accuracy, because the LLM uses tool names and descriptions as signals during reasoning.

Here is a complete, well-annotated server bootstrap that follows all best practices:

src/index.ts — self-describing server template#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { config } from './config.js'; // Zod-validated env (see Section 7)

// ── Server identity ──────────────────────────────────────────
const server = new McpServer({
  name: 'weather',                   // short, kebab-case, unique across ecosystem
  version: '1.0.0'                   // semver — bump on every breaking tool change
});

// ── Resource: server health ──────────────────────────────────
server.resource(
  'server://health',
  'mcp-weather://health',
  { mimeType: 'application/json' },
  async () => ({
    contents: [{
      uri: 'mcp-weather://health',
      mimeType: 'application/json',
      text: JSON.stringify({
        status: 'ok',
        version: '1.0.0',
        uptime: process.uptime(),
        tools: ['get_weather', 'get_forecast', 'list_cities'],
        dataSource: 'OpenWeatherMap API v2.5'
      }, null, 2)
    }]
  })
);

// ── Tool: get current weather ────────────────────────────────
server.tool(
  'get_weather',
  {
    location: z.string()
      .min(2)
      .max(100)
      .describe('City name (e.g. "London"), "lat,lon" pair, or zip code with country (e.g. "10001,US")')
  },
  async ({ location }) => {
    const url = new URL('https://api.openweathermap.org/data/2.5/weather');
    url.searchParams.set('q', location);
    url.searchParams.set('appid', config.OPENWEATHER_API_KEY);
    url.searchParams.set('units', 'metric');

    const res = await fetch(url);
    if (!res.ok) {
      return {
        content: [{ type: 'text', text: `Error: ${res.status} — city not found or API error` }],
        isError: true
      };
    }

    const d = await res.json() as any;
    return {
      content: [{
        type: 'text',
        text: [
          `📍 ${d.name}, ${d.sys.country}`,
          `🌡️  Temperature: ${d.main.temp}°C (feels like ${d.main.feels_like}°C)`,
          `☁️  Conditions: ${d.weather[0].description}`,
          `💧 Humidity: ${d.main.humidity}%`,
          `🌬️  Wind: ${d.wind.speed} m/s`
        ].join('\n')
      }]
    };
  },
  {
    description:
      'Get current weather conditions for any city or location. ' +
      'Returns temperature, conditions, humidity, and wind speed. ' +
      'Use for real-time weather queries. ' +
      'Note: data updates every 10 minutes from OpenWeatherMap.'
  }
);

// ── Startup ──────────────────────────────────────────────────
process.on('SIGTERM', async () => {
  console.error('[weather] shutting down gracefully');
  process.exit(0);
});

const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[weather] server started — waiting for connections');
💡
Health resource pattern: Exposing a server://health resource is a community convention — not in the spec, but widely adopted. It lets operators quickly verify the server is running and see its capabilities without reading source code. Monitoring tools can poll it to check server status.

Managing Multiple Servers: Priority, Routing, and Deduplication

A production Claude Desktop setup might run 5–10 MCP servers simultaneously. When tools overlap, when two servers return contradictory data, or when a request could go to multiple servers — the host needs a clear resolution strategy.

How Claude Desktop resolves conflicts

Claude Desktop presents all tools from all active servers to the model in a flat list. The model uses tool names and descriptions to pick the right one. This is why good naming (Section 6) is critical: if two servers have a tool called search, Claude will pick one seemingly at random.

When building your own host application (a custom MCP client), you have more control. Here is a multi-server registry with explicit tool routing:

multi-server-registry.ts — explicit routingimport { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

interface ServerEntry {
  name: string;
  client: Client;
  tools: Set<string>;
  priority: number;  // higher = preferred when tools overlap
}

class MultiServerRegistry {
  private servers: ServerEntry[] = [];
  private toolIndex = new Map<string, ServerEntry>();  // toolName → server

  async addServer(name: string, command: string, args: string[], priority = 0) {
    const transport = new StdioClientTransport({ command, args });
    const client = new Client({ name: 'orchestrator', version: '1.0.0' });
    await client.connect(transport);

    const { tools } = await client.listTools();
    const toolNames = new Set(tools.map(t => t.name));
    const entry: ServerEntry = { name, client, tools: toolNames, priority };

    // Register tools, respecting priority on conflict
    for (const toolName of toolNames) {
      const existing = this.toolIndex.get(toolName);
      if (!existing || priority > existing.priority) {
        if (existing) {
          console.warn(`[registry] Tool "${toolName}" overridden: ${existing.name} → ${name}`);
        }
        this.toolIndex.set(toolName, entry);
      }
    }

    this.servers.push(entry);
    console.log(`[registry] Registered ${name} with ${toolNames.size} tools`);
  }

  async callTool(toolName: string, args: Record<string, unknown>) {
    const server = this.toolIndex.get(toolName);
    if (!server) throw new Error(`Unknown tool: ${toolName}`);
    return server.client.callTool({ name: toolName, arguments: args });
  }

  listAllTools() {
    return [...this.toolIndex.entries()].map(([name, server]) => ({
      name,
      server: server.name
    }));
  }

  async shutdown() {
    await Promise.all(this.servers.map(s => s.client.close()));
  }
}

// Usage
const registry = new MultiServerRegistry();
await registry.addServer('github', 'npx', ['-y', '@mcp/server-github'], 10);
await registry.addServer('gitlab', 'npx', ['-y', 'mcp-server-gitlab'], 5);
// github wins on any overlapping tools (priority 10 > 5)

const result = await registry.callTool('github_search_repos', { query: 'mcp server' });

Per-project server configuration

Claude Code (the CLI) supports per-project configuration via .claude/settings.json in the project root. This means a Python project can have different servers than a TypeScript project:

.claude/settings.json — project-scoped MCP config{
  "mcpServers": {
    "postgres": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-postgres"],
      "env": {
        "POSTGRES_CONNECTION_STRING": "postgresql://localhost/myapp_dev"
      }
    },
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "./src"],
      "env": {}
    }
  }
}

Is Your Server Ready for the Ecosystem?

Before publishing or sharing your MCP server, run through this checklist. A discoverable, well-behaved server increases adoption, reduces support issues, and works correctly across different host applications.

Unique tool names with service prefix All tools named servicename_action_noun. No generic names like search or create.
Rich tool descriptions Every tool has a multi-sentence description covering: what it does, when to use it, and what it returns.
Zod schema with .describe() Every parameter has a .describe() call explaining the expected format and valid values.
Fail-fast env validation Missing required env vars cause immediate process.exit(1) with a clear error, not a silent runtime failure.
Errors use isError: true Tool failures return { content: [...], isError: true } rather than throwing exceptions.
SIGTERM handler for clean shutdown Listening for process.on('SIGTERM', ...) to close connections gracefully.
Logs to stderr, not stdout All console.logconsole.error. stdout is reserved for MCP JSON-RPC; any noise there breaks the protocol.
Shebang + correct bin field Entry point has #!/usr/bin/env node and package.json has "bin" pointing to it.
Health resource exposed server://health resource returns version, uptime, and tool list as JSON.
README with config snippet README shows the exact claude_desktop_config.json block users need to copy-paste.

Beyond the checklist, consider these publishing steps:

1
Build and test locally
Run npm run build && node dist/index.js and verify it starts without errors. Test with the MCP Inspector: npx @modelcontextprotocol/inspector dist/index.js.
2
Add mcp-server keyword
In package.json, add "keywords": ["mcp", "mcp-server", "your-domain"]. This makes your package discoverable via npm search.
3
Publish to npm
Run npm login && npm publish --access public. Scoped packages (@yourname/mcp-server-x) require --access public on first publish.
4
Submit to community registries
Open a PR to punkpeye/awesome-mcp-servers and submit to mcp.so via their submission form. Include your npm install command and env requirements.
5
Monitor and version
Use semantic versioning. Breaking changes (renamed tools, removed parameters) require a major version bump. Minor additions are minor bumps. Pin your SDK dependency to a minor range: "@modelcontextprotocol/sdk": "^1.0.0".
🔮
Day 15 preview: Advanced Tool Patterns — input validation strategies beyond Zod basics, tool annotations (readOnlyHint, destructiveHint, idempotentHint), pagination patterns for large result sets, and tool result content types (text, image, embedded resources).
Section Quiz
5 questions · instant feedback · Level 2 checkpoint
1. Which field in claude_desktop_config.json is used for remote Streamable HTTP servers?
2. When Claude Desktop spawns a stdio MCP server, which environment does the child process inherit?
3. Which naming convention best avoids tool collisions when multiple MCP servers are loaded?
4. What is the purpose of the -y flag in "args": ["-y", "@scope/mcp-server-x"] when using npx?
5. Which notification does a server send to tell connected clients that its tool list has changed?
out of 5 correct —