Guide · TypeScript Advanced Patterns

MCP server declaration merging

MCP servers grow. What starts as a 10-tool server for a single team becomes a 60-tool server with plugins from three departments, each adding their own tools, middleware, and context properties. TypeScript's declaration merging — the ability for multiple declarations of the same interface or namespace to contribute properties — is the mechanism that makes these plugin systems type-safe without a monolithic god interface that every plugin has to maintain. This guide covers how to use interface merging for MCP server context types, namespace merging for typed tool registries, and module augmentation to extend third-party types without forking them.

TL;DR

Declare a base McpServerContext interface in your server's core module. Each plugin augments that interface with declare module './context' { interface McpServerContext { myPlugin: MyPluginContext } }. TypeScript merges all the contributions — handlers see a fully typed context with all plugins' properties, and adding a plugin without implementing its context type is a compile error. Use namespace merging for tool registries where each file contributes a typed entry: the registry type is the merge of all files' contributions.

Interface merging for server context

A common pattern in MCP servers with middleware is a request context object passed through the middleware chain. Declaration merging lets each plugin add its own typed properties to that context without touching a central file:

// core/context.ts — the extensible base interface
export interface McpServerContext {
  requestId: string;
  startedAt: number;
  logger: Logger;
}

// auth/plugin.ts — augments the shared context
declare module '../core/context' {
  interface McpServerContext {
    auth: {
      userId:  string;
      orgId:   string;
      scopes:  string[];
      isAdmin: boolean;
    };
  }
}

// rate-limit/plugin.ts — augments the same shared context
declare module '../core/context' {
  interface McpServerContext {
    rateLimit: {
      remaining:  number;
      resetAt:    number;
      tier:       'free' | 'pro' | 'enterprise';
    };
  }
}

// In any tool handler that uses the context — all merged properties are visible:
async function handleListProjects(
  args: { org_id: string },
  ctx: McpServerContext
) {
  // ctx.auth.userId — from auth plugin
  // ctx.rateLimit.tier — from rate-limit plugin
  // ctx.logger — from base context
  // ctx.requestId — from base context
  if (ctx.rateLimit.remaining === 0) {
    return { isError: true, content: [{ type: 'text', text: `Rate limit hit. Resets at ${new Date(ctx.rateLimit.resetAt).toISOString()}` }] };
  }
  const projects = await db.projects.findByOrg(ctx.auth.orgId);
  ctx.logger.info({ event: 'list_projects', userId: ctx.auth.userId, count: projects.length });
  return { content: [{ type: 'text', text: projects.map(p => p.name).join('\n') }] };
}

No central McpServerContext file needs to know about the auth or rate-limit plugins. Each plugin declares its own contribution. TypeScript merges them at compile time. A handler that accesses ctx.auth.userId will fail to compile unless the auth plugin is loaded and its declaration is in scope.

Tool registry via namespace merging

Namespace merging lets each plugin file contribute typed tool definitions to a central registry without importing a list of files. The registry type is the union of all contributions:

// core/registry.ts — base namespace with an empty Tools interface
export namespace ToolRegistry {
  export interface Tools {}
  export type ToolName = keyof Tools;
  export type ToolArgs<T extends ToolName> = Tools[T];
}

// notes/tools.ts — contribute tools from the notes plugin
declare module '../core/registry' {
  namespace ToolRegistry {
    interface Tools {
      create_note:  { title: string; content: string; tags?: string[] };
      delete_note:  { note_id: string; confirm: true };
      search_notes: { query: string; limit?: number };
    }
  }
}

// projects/tools.ts — contribute tools from the projects plugin
declare module '../core/registry' {
  namespace ToolRegistry {
    interface Tools {
      create_project: { name: string; owner_id: string };
      list_projects:  { page?: number; per_page?: number };
      archive_project: { project_id: string };
    }
  }
}

// core/router.ts — use the merged registry for a type-safe dispatch function
function dispatchTool<T extends ToolRegistry.ToolName>(
  name: T,
  args: ToolRegistry.ToolArgs<T>
): Promise<McpToolResult> {
  // name is 'create_note' | 'delete_note' | 'search_notes' | 'create_project' | ...
  // args is correctly typed for the given name
  return handlers[name](args as any);
}

Adding a new plugin that doesn't contribute its tool names to the registry means the dispatch function doesn't know the tool exists — the compiler won't let you call dispatchTool('my_new_tool', ...) until you add the declaration.

Module augmentation for third-party types

When using the MCP SDK's McpServer class directly, you may need to attach per-request metadata that the SDK doesn't expose natively. Module augmentation lets you extend the SDK's types without patching or forking:

// Augmenting the Express Request type is a common pattern —
// the same approach applies to any module that exports a class or interface.

// types/session.d.ts
declare module 'express-serve-static-core' {
  interface Request {
    mcpSession?: {
      clientId:    string;
      clientName:  string;
      protocolVersion: string;
    };
  }
}

// For the MCP SDK: augmenting tool call context
// (hypothetical — the exact augmentation path depends on SDK version)
declare module '@modelcontextprotocol/sdk/server/mcp.js' {
  interface CallToolRequest {
    // Add a custom field that middleware populates
    _authContext?: {
      userId: string;
      orgId:  string;
    };
  }
}

// After the augmentation, TypeScript knows _authContext exists on CallToolRequest
// and middleware that sets it is type-safe.

Plugin contract enforcement with interface merging

Declaration merging also enables plugin contract patterns where a plugin must implement a specific interface to be considered "registered." Using a mapped type over the registry ensures missing implementations are compile errors:

// core/plugins.ts — registry of loaded plugins
export interface RegisteredPlugins {}

// Type: every registered plugin must implement the Plugin interface
type AllPlugins = {
  [K in keyof RegisteredPlugins]: Plugin
};

// Plugin contract
interface Plugin {
  name:    string;
  version: string;
  setup(server: McpServer): Promise<void>;
  teardown?(): Promise<void>;
  healthCheck?(): Promise<{ ok: boolean; details?: string }>;
}

// auth/plugin-registration.ts
import { Plugin } from '../core/plugins';

export const AuthPlugin: Plugin = {
  name: 'auth',
  version: '1.2.0',
  async setup(server) {
    server.use(authMiddleware);
  },
  async healthCheck() {
    const ok = await tokenStore.ping();
    return { ok, details: ok ? undefined : 'Token store unreachable' };
  },
};

// Registering the plugin
declare module '../core/plugins' {
  interface RegisteredPlugins {
    auth: typeof AuthPlugin;  // compile error if AuthPlugin doesn't implement Plugin
  }
}

// At startup: iterate all registered plugins
async function startServer(server: McpServer, plugins: AllPlugins) {
  for (const plugin of Object.values(plugins)) {
    await plugin.setup(server);
  }
  // TypeScript knows every value in plugins implements Plugin
}

Declaration merging for feature flags

Feature flags that change tool availability at runtime can be typed with declaration merging — each feature module declares which tools it adds, and the compiler knows which tools are available when the flag is on:

// feature-flags.ts — base interface
export interface FeatureFlags {
  betaSearchEnabled:  boolean;
  enterpriseSsoEnabled: boolean;
}

// Declaration merged from each module:
// search/feature.ts
declare module '../feature-flags' {
  interface FeatureFlags {
    betaSearchEnabled: boolean;  // already declared above — TypeScript merges
  }
}

// Use at registration:
function registerFeatureTools(server: McpServer, flags: FeatureFlags) {
  if (flags.betaSearchEnabled) {
    // Register search tools — TypeScript narrows flags.betaSearchEnabled to true here
    registerSearchTools(server);
  }
}

Monitoring plugin-based MCP servers

Plugin architectures increase the surface area of your MCP server: each plugin has its own dependencies (databases, external APIs, cache layers), any of which can fail independently. A plugin health check interface — like the one above — gives you a consistent contract for checking plugin status.

But plugin-local health checks can't see network-level failures, cross-plugin interaction bugs, or failures that only appear when tools are called with real inputs. AliveMCP probes your MCP server endpoint every 60 seconds using the full protocol handshake, calling tools to detect failures invisible to transport and plugin-level health checks.

Further reading