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 usesnake_caseand 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
querytool should accept aquerystring 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:
textServer 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:
textPrinciple 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
- 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.
- Inventing novel naming patterns — Using
executeSQLinstead ofquery_databaseorgetDatainstead oflist_itemsmakes your tools harder for LLMs to use because they do not match ecosystem patterns. - 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
- 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.
- 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-postgresandserver-email. - 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_nounin 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
// 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 } }
);