Guide · Architecture

MCP server dependency injection

An MCP server registers tools at startup and handles requests from then on. Every tool that touches a database, an external API, or a cache needs access to shared infrastructure — a connection pool, a config object, a logger. The naive approach is to import those resources at module scope: one const db = new Pool(…) at the top of tools.ts. That works until you have multiple tool files all opening their own pools (N×pool_size connections), until you run tests that can't override the real database, and until you add multi-tenancy and discover that module-scope config is global state shared across all tenants. Dependency injection solves all three problems: infrastructure is created once, passed in, and replaceable in tests.

TL;DR

Create all shared infrastructure in a single createDeps() async factory, pass the result to tool-registration functions as a Deps typed object, and define interfaces for every external resource so tests can inject stubs. Never import a database pool or HTTP client at module scope inside a tool file — import the interface type only. Register tools before calling app.listen so the server never accepts connections in a partial state. AliveMCP's probe confirms the server is up after createDeps() completes — a startup that hangs in Pool.connect() will show up as a probe timeout on the first initialize check.

The module-scope singleton problem

The most common MCP server structure creates infrastructure at module scope:

// tools/search.ts — common but problematic
import { Pool } from 'pg';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

const db = new Pool({ connectionString: process.env.DATABASE_URL }); // opens pool on import

export function registerSearchTools(server: McpServer) {
  server.tool('search_records', { query: z.string() }, async ({ query }) => {
    const result = await db.query('SELECT ...', [query]);
    return { content: [{ type: 'text', text: JSON.stringify(result.rows) }] };
  });
}

This causes three problems. First, if you also have tools/analytics.ts and tools/admin.ts each creating their own pool, you open three pools of twenty connections each — sixty database connections for one MCP server process. Second, tests that import search.ts immediately open a real database connection, even if the test only exercises the tool's argument validation. Third, if you add multi-tenant support, module-scope variables are shared across all tenant sessions — injecting a per-tenant configuration becomes impossible without refactoring every tool file.

The fix is to create infrastructure once and pass it as an argument.

The Deps factory pattern

Define a Deps interface that lists every shared resource, then create a createDeps() async function that allocates them all in one place:

// deps.ts
import { Pool } from 'pg';
import { Redis } from 'ioredis';

export interface Logger {
  info(event: string, fields?: Record<string, unknown>): void;
  error(event: string, fields?: Record<string, unknown>): void;
}

export interface Deps {
  db: Pool;
  cache: Redis;
  logger: Logger;
  config: AppConfig;
}

export async function createDeps(): Promise<Deps> {
  const config = loadConfig(); // throws on missing required env vars

  const db = new Pool({
    connectionString: config.databaseUrl,
    max: 20,
    idleTimeoutMillis: 30_000,
  });

  // Verify the pool can connect before the server accepts traffic
  await db.query('SELECT 1');

  const cache = new Redis(config.redisUrl, { lazyConnect: false });
  await cache.ping();

  const logger = createStructuredLogger();

  return { db, cache, logger, config };
}

The await db.query('SELECT 1') at startup is deliberate: if the database is unreachable, the process exits before binding to a port. This is better than accepting MCP connections and failing on the first tool call. AliveMCP's protocol probe (which sends an initialize request) only fires after the port is open — a hung createDeps() will time out at the TCP level and show up as a probe failure, which is the correct signal.

Tool-registration functions receive Deps as a parameter and close over it:

// tools/search.ts — deps injected, no module-scope infrastructure
import { z } from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Deps } from '../deps.js';

export function registerSearchTools(server: McpServer, deps: Deps) {
  server.tool('search_records', { query: z.string(), limit: z.number().int().min(1).max(100).default(20) }, async ({ query, limit }) => {
    const result = await deps.db.query(
      'SELECT id, title, snippet FROM records WHERE to_tsvector(\'english\', content) @@ plainto_tsquery($1) LIMIT $2',
      [query, limit]
    );
    deps.logger.info('tool_call', { tool: 'search_records', rows: result.rowCount });
    return { content: [{ type: 'text', text: JSON.stringify(result.rows) }] };
  });
}

The server entry point creates deps once and passes them to all registration functions:

// server.ts
import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createDeps } from './deps.js';
import { registerSearchTools } from './tools/search.js';
import { registerAnalyticsTools } from './tools/analytics.js';
import { registerAdminTools } from './tools/admin.js';

async function main() {
  const deps = await createDeps(); // one pool, one cache, one logger

  const app = express();
  app.use(express.json());

  app.post('/mcp', async (req, res) => {
    const server = new McpServer({ name: 'my-server', version: '1.0.0' });
    registerSearchTools(server, deps);
    registerAnalyticsTools(server, deps);
    registerAdminTools(server, deps);

    const transport = new StreamableHTTPServerTransport({ sessionIdHeader: 'mcp-session-id' });
    await server.connect(transport);
    await transport.handleRequest(req, res);
  });

  app.listen(3000, () => deps.logger.info('server_started', { port: 3000 }));
}

main().catch(err => { console.error(err); process.exit(1); });

Three tool files, one connection pool, twenty database connections total instead of sixty.

Interface-based injection for testability

The Deps interface is the seam for testing. In tests you construct a Deps object from lightweight stubs instead of real infrastructure:

// test/helpers/test-deps.ts
import type { Deps, Logger } from '../../deps.js';

export function createTestDeps(overrides: Partial<Deps> = {}): Deps {
  const logger: Logger = {
    info: () => {},   // silent in tests
    error: console.error,
  };

  return {
    db: overrides.db ?? createInMemoryDb(),   // SQLite in-memory via better-sqlite3
    cache: overrides.cache ?? new Map() as any, // or ioredis-mock
    logger,
    config: testConfig(),
    ...overrides,
  };
}

// test/search.test.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { registerSearchTools } from '../../tools/search.js';
import { createTestDeps } from '../helpers/test-deps.js';

test('search_records returns rows matching query', async () => {
  const deps = createTestDeps();
  // seed test data into deps.db ...

  const server = new McpServer({ name: 'test', version: '0.0.0' });
  registerSearchTools(server, deps);

  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  await server.connect(serverTransport);

  const client = new Client({ name: 'test-client', version: '0.0.0' }, { capabilities: {} });
  await client.connect(clientTransport);

  const result = await client.callTool({ name: 'search_records', arguments: { query: 'typescript' } });
  const rows = JSON.parse((result.content[0] as any).text);
  expect(rows.length).toBeGreaterThan(0);
});

The integration testing guide covers InMemoryTransport in depth. For the purposes of DI: the key point is that registerSearchTools takes a Deps object, so tests can provide a Deps object with a real SQLite in-memory database and a no-op logger without touching the production code at all.

Integrating with the plugin pattern

If you use the plugin architecture, Deps is the PluginDeps object. Each plugin receives the same shared infrastructure via register(server, deps):

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

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

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: Deps): void {
    for (const plugin of this.plugins) {
      plugin.register(server, deps);
    }
  }
}

// server.ts
const deps = await createDeps();

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

app.post('/mcp', async (req, res) => {
  const server = new McpServer({ name: 'my-server', version: '1.0.0' });
  registry.registerAll(server, deps); // deps flows to all plugins
  // ...
});

This wires infrastructure exactly once — createDeps() at startup — and distributes it to every plugin without any global variable or module-scope singleton.

Lazy and conditional dependencies

Some dependencies are expensive to initialize or only needed by a subset of tools. Two patterns handle this: lazy factories and optional deps fields.

// Lazy factory — only initialized on first use
export interface Deps {
  db: Pool;
  logger: Logger;
  getEmbeddingClient: () => EmbeddingClient; // factory, not instance
}

export async function createDeps(): Promise<Deps> {
  const db = new Pool(…);
  const logger = createStructuredLogger();

  let _embeddingClient: EmbeddingClient | null = null;

  return {
    db,
    logger,
    getEmbeddingClient: () => {
      if (!_embeddingClient) _embeddingClient = new EmbeddingClient(process.env.OPENAI_API_KEY!);
      return _embeddingClient;
    },
  };
}

// Conditional deps — present only when feature is enabled
export interface Deps {
  db: Pool;
  logger: Logger;
  messageQueue: BullMQ | null; // null when QUEUE_URL not set
}

Lazy factories avoid paying the initialization cost for capabilities the current request won't use. Nullable deps make feature flags explicit at the type level — tool handlers that require a message queue check if (!deps.messageQueue) return { content: [{ type: 'text', text: 'Queue not configured' }], isError: true } rather than throwing an uncaught exception.

Graceful shutdown with injected resources

Because all resources are held in the deps object rather than scattered across module scope, graceful shutdown is straightforward:

// server.ts — shutdown handler
async function shutdown(deps: Deps, server: http.Server) {
  isShuttingDown = true;
  server.close();                      // stop accepting new connections
  await new Promise(r => setTimeout(r, DRAIN_TIMEOUT_MS)); // drain active sessions
  await deps.db.end();                 // close pool
  await deps.cache.quit();             // close Redis
  deps.logger.info('server_stopped');
  process.exit(0);
}

process.on('SIGTERM', () => shutdown(deps, httpServer));

AliveMCP's probe detects isShuttingDown state through the /healthz endpoint you expose alongside the MCP transport. A server mid-drain returns { "status": "shutting_down" }, which AliveMCP records as a planned outage rather than an unexpected failure — keeping your uptime percentage accurate during deploys.

Related questions

Should I use a DI container library like InversifyJS or tsyringe?

Not for most MCP servers. A TypeScript interface, a factory function, and function parameters give you the same inversion of control as a DI container for a fraction of the complexity. Container libraries are designed for applications with hundreds of injectable classes and deep dependency graphs; an MCP server's dependency graph is usually shallow — config → pool → tools. Add a container only if you find yourself writing a lot of boilerplate to wire up nested dependencies.

Can I inject per-request dependencies in addition to shared deps?

Yes. The pattern is to create a "request context" object inside the route handler and pass it alongside the shared deps. For example, registerSearchTools(server, deps, { tenantId, requestId }). This is how you achieve per-tenant tool behaviour (tenant-specific DB schema, feature flags) without module-scope state. See the multi-tenant guide for the session-keyed context pattern.

How do I handle deps that require async teardown in tests?

Use your test framework's afterEach or afterAll hook to call the teardown function. If you expose a teardown() function from createDeps() (or from createTestDeps()), you can call it at the end of each test suite. For SQLite in-memory databases, teardown is simply dropping the in-memory database instance — it's garbage-collected when the reference goes out of scope.

Does AliveMCP probe my /mcp endpoint differently if I use DI?

No. AliveMCP sends a standard MCP initialize + tools/list probe — it can't see how your server is wired internally. What it can observe is that your server responds correctly after startup. If createDeps() fails (e.g., the database is unreachable), the process never binds to the port, and AliveMCP's probe never gets a TCP connection — which correctly shows as an outage.

Further reading