Guide · Architecture

MCP server plugins

A plugin architecture for an MCP server lets you break a large tool surface into independently developed, tested, and deployed modules. Each plugin registers a cohesive set of related tools and resources. The MCP server becomes a composition host rather than a monolith. The pattern is straightforward for startup-time plugin loading; it gets more complicated when you want runtime hot-reload, because the MCP protocol's tools/list response is cached by AI clients — changing the tool surface mid-session without a client disconnect can cause clients to call tools that no longer exist or miss tools that were added.

TL;DR

Define a McpPlugin interface with a register(server: McpServer): void method. Discover and load plugins at startup by scanning a directory or reading a config array. Each plugin calls server.tool(), server.resource(), or server.prompt() to register its capabilities. Inject shared dependencies (database pool, config, logger) via constructor or factory function. Avoid runtime hot-reload of tool definitions — the MCP spec requires clients to re-run tools/list after a notifications/tools/list_changed notification, but many clients do not handle this correctly. Instead, deploy new plugin versions with a rolling restart and monitor the schema snapshot in CI to catch unintended changes. AliveMCP detects the restart within 60 seconds via the initialize probe.

Plugin interface and registration contract

Define a minimal interface that every plugin must implement. The only required method is register, which receives the McpServer instance and calls tool/resource/prompt registration methods on it:

// plugin.ts — plugin interface
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

export interface McpPlugin {
  name: string;
  version: string;
  register(server: McpServer, deps: PluginDeps): void;
}

export interface PluginDeps {
  db: Pool;            // pg connection pool
  config: AppConfig;   // typed environment config
  log: Logger;         // structured logger
}

// plugins/documents.ts — example plugin
import { z } from 'zod';
import type { McpPlugin, PluginDeps } from '../plugin.js';

export const documentsPlugin: McpPlugin = {
  name: 'documents',
  version: '1.0.0',

  register(server, deps) {
    server.tool(
      'list_documents',
      'List all documents in the workspace',
      { workspace_id: z.string().uuid() },
      async ({ workspace_id }) => {
        const rows = await deps.db.query(
          'SELECT id, title, updated_at FROM documents WHERE workspace_id = $1 ORDER BY updated_at DESC',
          [workspace_id]
        );
        return { content: [{ type: 'text', text: JSON.stringify(rows.rows) }] };
      }
    );

    server.tool(
      'get_document',
      'Fetch the full content of a document by ID',
      { document_id: z.string().uuid() },
      async ({ document_id }) => {
        const row = await deps.db.query(
          'SELECT id, title, content, updated_at FROM documents WHERE id = $1',
          [document_id]
        );
        if (!row.rows[0]) return { isError: true, content: [{ type: 'text', text: `Document ${document_id} not found` }] };
        return { content: [{ type: 'text', text: JSON.stringify(row.rows[0]) }] };
      }
    );
  }
};

The plugin receives PluginDeps — shared infrastructure like the database pool and config — rather than constructing its own connections. This ensures all plugins share the same connection pool (avoiding pool exhaustion) and the same config source (avoiding per-plugin config drift).

Plugin registry and startup loading

A plugin registry is responsible for discovering plugins, validating them, and calling register on each one. At startup, load all plugins before the server begins accepting connections:

// plugin-registry.ts
import type { McpPlugin, PluginDeps } from './plugin.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

export class PluginRegistry {
  private plugins: McpPlugin[] = [];

  add(plugin: McpPlugin): this {
    if (this.plugins.some(p => p.name === plugin.name)) {
      throw new Error(`Duplicate plugin name: ${plugin.name}`);
    }
    this.plugins.push(plugin);
    return this;
  }

  registerAll(server: McpServer, deps: PluginDeps): void {
    for (const plugin of this.plugins) {
      plugin.register(server, deps);
      deps.log('plugin_registered', { plugin: plugin.name, version: plugin.version });
    }
    deps.log('all_plugins_registered', { count: this.plugins.length });
  }

  names(): string[] {
    return this.plugins.map(p => p.name);
  }
}

// server.ts — wiring the registry at startup
import { PluginRegistry } from './plugin-registry.js';
import { documentsPlugin } from './plugins/documents.js';
import { searchPlugin } from './plugins/search.js';
import { analyticsPlugin } from './plugins/analytics.js';

const registry = new PluginRegistry()
  .add(documentsPlugin)
  .add(searchPlugin)
  .add(analyticsPlugin);

const server = new McpServer({ name: 'my-server', version: '1.0.0' });
registry.registerAll(server, deps);

// Now start accepting connections — tools are fully registered
app.listen(3000);

Registering all tools before app.listen means the server never accepts a connection in a partial state. The first tools/list request — including the AliveMCP uptime probe — sees the full, complete tool surface.

Directory-based plugin discovery

For a larger server where the plugin list changes frequently, use filesystem discovery rather than a hardcoded import list. Scan a plugins/ directory at startup and dynamically import every module that exports a default McpPlugin:

// dynamic-loader.ts
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { McpPlugin } from './plugin.js';

const pluginsDir = fileURLToPath(new URL('./plugins', import.meta.url));

export async function loadPlugins(): Promise<McpPlugin[]> {
  const entries = await readdir(pluginsDir, { withFileTypes: true });
  const plugins: McpPlugin[] = [];

  for (const entry of entries) {
    if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
    const mod = await import(join(pluginsDir, entry.name));
    if (!mod.default || typeof mod.default.register !== 'function') {
      console.warn(`Skipping ${entry.name}: no default McpPlugin export`);
      continue;
    }
    plugins.push(mod.default as McpPlugin);
  }

  return plugins;
}

Use this pattern when you want to add a plugin by dropping a compiled .js file into the plugins/ directory and restarting the server — no changes to server.ts required. The trade-off is that the plugin list is determined at runtime rather than compile time, so TypeScript cannot catch a missing plugin. Include a startup check that logs the loaded plugin names, and add an integration test that asserts the expected plugin names are present in the tools/list response.

Why hot-reload is hard in MCP servers

Hot-reloading tool definitions at runtime (without restarting the process) requires sending a notifications/tools/list_changed notification to all active sessions. The MCP spec supports this notification, but in practice many AI clients cache the tool list for the session lifetime and do not re-issue tools/list on notification. A client calling a tool that was removed gets a -32601 method not found error. A client unaware of a newly added tool never discovers it.

Reload strategyClient impactRecommendation
Restart (rolling deploy)Existing sessions end; clients reconnect with fresh tool listRecommended for production
list_changed notificationDepends on client implementation — many ignore itAcceptable for adding optional tools
In-process module swapExisting sessions may call removed tools; schema snapshot breaksAvoid in production

The safest approach for changing the plugin set is a rolling restart: deploy the new image, wait for AliveMCP to confirm the new server responds to initialize, then route traffic to the new pod. If you need to add tools without restarting, add them before starting the server and deploy on the next natural maintenance window instead of hot-swapping mid-session.

Related questions

Can plugins have their own configuration separate from the main server config?

Yes. Pass a namespaced config object to each plugin: deps.config.plugins[plugin.name]. Each plugin reads only its own config section. This keeps the plugin's configuration self-contained and avoids having plugin-specific env vars scattered through the main config type. Validate each plugin's config section at startup with Zod, before calling register, so a missing plugin config fails fast rather than causing a runtime error during the first tool call.

How do I test a plugin in isolation without the full MCP server?

Create a real McpServer instance in your test, call plugin.register(server, mockDeps), and then make direct method calls on the server or use the SDK's in-memory transport to send MCP requests. The in-memory transport (InMemoryTransport in the SDK) lets you test the full JSON-RPC round trip without opening a network socket. This approach is faster than integration tests and catches schema mismatches before they reach the schema snapshot gate in CI.

Should each plugin manage its own database connections?

No. Inject a shared connection pool from PluginDeps. If each plugin creates its own pool, the total connection count is plugins.length × pool_size, which can quickly exhaust PostgreSQL's max_connections. A shared pool also gives you one place to tune pool parameters (min, max, acquire timeout) rather than duplicating them per plugin. See the connection pooling guide for pool sizing under concurrent MCP sessions.

Can a plugin add resources and prompts, not just tools?

Yes. The same McpPlugin interface works for all registration types. A plugin can call server.resource() to register URI-addressed data sources and server.prompt() to register reusable prompt templates, alongside server.tool(). Group related tools, resources, and prompts in a single plugin when they share the same domain model and database tables — keeping them together makes it easier to reason about the plugin's data access patterns and test them as a unit.

Further reading