Introduction

A well-designed MCP server does not just work — it works well with other servers. Designing for the ecosystem means following naming conventions, avoiding side effects, maintaining clear boundaries, and building tools that compose naturally with tools from other servers. This lesson covers the principles that separate a useful server from a great one.

Key Concepts

  • Naming Conventions: Server names follow the server-{domain} pattern. Tool names use snake_case and include a verb: query_database, send_message, create_issue. Resource URIs follow the {scheme}://{authority}/{path} pattern.
  • Composability Principles: Servers that work well together share three traits: no hidden side effects (each tool does exactly what its description says), clear input/output boundaries (tools accept and return well-defined schemas), and statelessness where possible (tool results do not depend on previous invocations).
  • Standardized Tool Interfaces: Across the ecosystem, similar tools should have similar interfaces. A query tool should accept a query string parameter whether it queries PostgreSQL, MySQL, or MongoDB. This consistency lets LLMs transfer knowledge across servers.
  • Community Contributions: The open-source MCP ecosystem thrives on contributions. Design your server to be forkable and extendable. Keep the core small, use dependency injection for external services, and write tests.
  • Spec Evolution: The MCP specification is evolving. New capabilities, transport layers, and primitives are being added. Design your server to be forward-compatible by declaring only the capabilities you use and handling unknown fields gracefully.

Real World Context

A developer connects three MCP servers to Claude: a PostgreSQL server, a Slack server, and a GitHub server. The LLM can seamlessly query the database for recent errors, post a summary to the team's Slack channel, and create a GitHub issue — all in one conversation. This works because each server follows ecosystem conventions: clear tool names, predictable parameter patterns, and no hidden state. If the database server had a tool called do_stuff with undocumented side effects, the LLM would not know how to use it safely.

Deep Dive

Naming Conventions in Practice

Consistent naming makes tools discoverable and predictable:

text
Server Name          Tool Names                Resource URIs
───────────────────  ────────────────────────  ─────────────────────────
server-postgres      query_database            postgres://host/db/table
                     list_tables               postgres://host/db/schema
                     describe_table

server-slack         send_message              slack://workspace/channels
                     list_channels             slack://workspace/users
                     search_messages

server-github        create_issue              github://owner/repo/issues
                     list_pull_requests        github://owner/repo/pulls
                     search_repositories

Every tool name starts with a verb. Every resource URI follows a hierarchical scheme. An LLM encountering these tools for the first time can infer their purpose from the names alone.

Designing for Composability

Servers that compose well follow strict principles:

typescript
// GOOD: Pure function, no side effects beyond stated purpose
server.tool(
  "format_markdown",
  "Convert raw text to formatted Markdown",
  { text: z.string(), style: z.enum(["github", "standard"]) },
  async ({ text, style }) => {
    const formatted = formatMarkdown(text, style);
    return {
      content: [{ type: "text", text: formatted }]
    };
  }
);

// BAD: Hidden side effect — sends analytics in addition to formatting
server.tool(
  "format_markdown",
  "Convert raw text to formatted Markdown",
  { text: z.string() },
  async ({ text }) => {
    const formatted = formatMarkdown(text);
    await sendAnalytics("format_called", { length: text.length }); // hidden!
    return {
      content: [{ type: "text", text: formatted }]
    };
  }
);

The bad example sends analytics as a hidden side effect. The LLM's description says "convert to Markdown" but the tool also phones home. This breaks trust and composability. If a tool has side effects, declare them in the description and use appropriate annotations.

Standardizing Tool Interfaces

When building tools that overlap with common patterns, match the ecosystem's expectations:

typescript
// Standard query pattern — reusable across databases
server.tool(
  "query_database",
  "Execute a read-only query against the database",
  {
    query: z.string().describe("The query to execute"),
    params: z.array(z.string()).optional().describe("Query parameters")
  },
  async ({ query, params }) => {
    // Implementation varies by database, but interface is standard
    const result = await db.query(query, params);
    return {
      content: [{ type: "text", text: JSON.stringify(result.rows) }]
    };
  }
);

An LLM that has used one query_database tool knows how to use any query_database tool. This transferability is a direct benefit of standardized interfaces.

Future-Proofing for Spec Evolution

The MCP specification is actively evolving. Design your server to handle this gracefully:

text
Principle                 Implementation
────────────────────────  ─────────────────────────────────────
Declare used caps only    Do not claim capabilities you do not implement
Ignore unknown fields     Do not crash on unrecognized JSON-RPC fields
Version your tools        Use semver; deprecate instead of removing
Test with multiple hosts  Verify your server works with Claude, Cursor, etc.

A server that declares only tools capability will continue to work even when the spec adds new primitives. A server that crashes on unknown fields will break when hosts send new notification types.

Common Pitfalls

  1. Hidden side effects in tools — If a tool does more than its description says, it breaks the LLM's ability to reason about consequences. Every side effect must be documented in the tool description.
  2. Inventing novel naming patterns — Using executeSQL instead of query_database or getData instead of list_items makes your tools harder for LLMs to use because they do not match ecosystem patterns.
  3. Tight coupling between tools — Tools that require being called in a specific sequence ("you must call init_session before query") break composability. Each tool should be independently invocable.

Best Practices

  1. Design tools for LLM ergonomics — Write tool descriptions as if explaining the tool to a junior developer. Include what the tool does, what inputs it expects, and what it returns. The LLM uses these descriptions to decide when and how to call your tools.
  2. Keep servers focused — One server, one domain. A server that handles both database queries and email sending is trying to do too much. Split them into server-postgres and server-email.
  3. Test with real LLMs — Beyond unit tests, use your server with actual LLM hosts. Watch how the LLM uses your tools and refine descriptions and schemas based on real interactions.

Summary

  • Follow naming conventions: server-{domain} for servers, verb_noun in snake_case for tools, hierarchical URIs for resources.
  • Design tools without hidden side effects — every action must be reflected in the tool description.
  • Standardize tool interfaces across similar domains so LLMs can transfer knowledge between servers.
  • Keep servers focused on a single domain with independently invocable tools.
  • Future-proof by declaring only used capabilities and ignoring unknown fields gracefully.

Code Examples

typescript
// Composable tool: clear name, no side effects, standard interface
server.tool(
  "query_database",
  "Execute a read-only SQL query and return results as JSON",
  {
    query: z.string().describe("SQL query to execute"),
    params: z.array(z.string()).optional().describe("Parameterized values")
  },
  async ({ query, params }) => {
    const result = await db.query(query, params);
    return {
      content: [{ type: "text", text: JSON.stringify(result.rows, null, 2) }]
    };
  },
  { annotations: { readOnlyHint: true, destructiveHint: false } }
);
✓ Completed