Skip to main content

06 - Shared Packages: Core, EventBus and Database

The 6 shared packages of Tech-MCP: core for utilities, event-bus for async communication, database for SQLite persistence.

What do you think about this article?

At a Glance

The 6 shared packages of Tech-MCP: core for utilities, event-bus for async communication, database for SQLite persistence.

Introduction: The 6 Shared Packages of Tech-MCP

In a monorepo with 22 MCP servers, code duplication would be a devastating problem. Each server would need its own factory, its own logger, its own database management, multiplying maintenance complexity by 22. The architectural solution adopted by the Tech-MCP project is shared packages: 6 internal libraries that centralize all cross-cutting logic in a single point, reusable by every server.

These packages are not simple utilities: they represent the fundamental infrastructure upon which every single server is built. The factory creates servers in 4 lines of code, the EventBus enables collaboration between servers with 29 typed events, the database provides SQLite persistence with automatic migrations, the testing package allows in-process testing without network, the CLI manages servers from the terminal, and the Client Manager orchestrates synchronous server-to-server communication.

The 6 Shared Packages

Package Responsibility Key Dependencies
@mcp-suite/core Server factory, configuration, logger, errors, types @modelcontextprotocol/sdk, zod
@mcp-suite/event-bus 29 typed events, LocalEventBus, pattern matching micromatch
@mcp-suite/database createDatabase, WAL mode, incremental migrations better-sqlite3
@mcp-suite/testing TestHarness, MockEventBus, InMemoryTransport @modelcontextprotocol/sdk
@mcp-suite/cli list, start, status commands for server management commander
@mcp-suite/client-manager Client pool, 3 transports, server-to-server communication @modelcontextprotocol/sdk

@mcp-suite/core: The Heart of the Suite

The @mcp-suite/core package is the foundation upon which every server is built. It provides five critical modules: the server creation factory, the configuration system with Zod validation, a structured logger that writes to stderr, a typed error hierarchy, and the domain types shared across all 22 servers.


packages/core/
+-- package.json
+-- tsconfig.json
+-- src/
    +-- index.ts          # Re-export of all modules
    +-- server-factory.ts # createMcpServer, startServer, McpSuiteServer
    +-- config.ts         # loadConfig, ServerConfigSchema
    +-- logger.ts         # Structured logger on stderr
    +-- errors.ts         # Typed error hierarchy
    +-- types.ts          # Shared domain types
    

CreateServerOptions and McpSuiteServer

At the core of @mcp-suite/core is the pair of interfaces that define how a server is created and what it represents once instantiated. CreateServerOptions describes the input options, while McpSuiteServer represents the central contract of the architecture: every server implements it, every tool registration function receives it.


export interface CreateServerOptions {
  name: string;             // Unique server name (e.g., 'scrum-board')
  version: string;          // Semantic version (e.g., '0.1.0')
  description?: string;     // Human-readable description
  config?: Partial<ServerConfig>;  // Configuration override
  eventBus?: EventBus;      // Optional EventBus for collaboration
}

export interface McpSuiteServer {
  name: string;           // Unique server name
  server: McpServer;      // MCP server instance from official SDK
  config: ServerConfig;   // Configuration loaded and validated with Zod
  logger: Logger;         // Structured logger (writes to stderr)
  eventBus?: EventBus;    // Optional EventBus (undefined if not provided)
  httpServer?: Server;    // HTTP server reference (if HTTP transport active)
}
    

createMcpServer(): The Main Factory

The createMcpServer() function is the factory that every server invokes to create itself. In just a few lines it orchestrates four fundamental operations: loads the configuration from environment variables, creates the logger with the configured level, instantiates the McpServer from the official SDK, and returns the complete McpSuiteServer bundle.


export function createMcpServer(options: CreateServerOptions): McpSuiteServer {
  // 1. Load configuration from env + overrides
  const config = loadConfig(options.name, options.config);

  // 2. Create logger with configured level
  const logger = new Logger(options.name, config.logLevel);
  logger.info(`Initializing ${options.name} v${options.version}`);

  // 3. Instantiate McpServer from official SDK
  const server = new McpServer({
    name: options.name,
    version: options.version,
  });

  // 4. Return the complete bundle
  return { server, config, logger, eventBus: options.eventBus };
}
    

Factory Flow


CreateServerOptions
        |
        +--> loadConfig(name, overrides)    --> ServerConfig
        |
        +--> new Logger(name, logLevel)     --> Logger
        |
        +--> new McpServer({name, version}) --> McpServer
        |
        +--> { server, config, logger, eventBus } --> McpSuiteServer
      

startServer(): Auto-Transport Startup

Once the McpSuiteServer is created, the startServer() function launches it by automatically selecting the transport based on configuration. The STDIO transport uses stdin/stdout for JSON-RPC communication, while the HTTP transport uses the Streamable HTTP protocol from the MCP SDK in stateful mode (each session gets a UUID).


export async function startStdioServer(suite: McpSuiteServer): Promise<void> {
  const transport = new StdioServerTransport();
  suite.logger.info('Starting server with STDIO transport');
  await suite.server.connect(transport);
}

export async function startHttpServer(suite: McpSuiteServer): Promise<void> {
  const port = suite.config.port ?? 3000;
  const app = createMcpExpressApp();

  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => randomUUID(),
  });

  await suite.server.connect(transport);

  app.post('/mcp', async (req, res) => {
    await transport.handleRequest(req, res, req.body);
  });
  app.get('/mcp', async (req, res) => {
    await transport.handleRequest(req, res);
  });
  app.get('/health', (_req, res) => {
    res.json({ status: 'ok', server: suite.name });
  });

  suite.httpServer = app.listen(port);
}

export async function startServer(suite: McpSuiteServer): Promise<void> {
  if (suite.config.transport === 'http') {
    await startHttpServer(suite);
  } else {
    await startStdioServer(suite);
  }
}
    

config.ts: Configuration with Zod Validation

The configuration system follows a priority cascade: server-specific environment variable, then global variable, then programmatic override, finally the Zod schema default. The ServerConfigSchema defines valid parameters and their default values.


export const ServerConfigSchema = z.object({
  transport: z.enum(['stdio', 'http']).default('stdio'),
  port: z.number().optional(),
  logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  dataDir: z.string().optional(),
  eventBus: z.object({
    type: z.enum(['local', 'redis']).default('local'),
    redisUrl: z.string().optional(),
  }).default({ type: 'local' }),
});
    

The loadConfig() function converts the server name into environment variable format, allowing granular per-server configurations:


# Global (all servers)
MCP_SUITE_TRANSPORT=http

# Specific to scrum-board
MCP_SUITE_SCRUM_BOARD_LOG_LEVEL=debug
MCP_SUITE_SCRUM_BOARD_TRANSPORT=stdio
    

logger.ts: Structured Logger on stderr

The Logger writes structured JSON logs to stderr. This choice is critical: the MCP protocol uses stdout for JSON-RPC communication, so logs must go to a separate channel. If logs went to stdout, they would corrupt the JSON-RPC stream and the MCP client would not be able to communicate with the server.


export class Logger {
  private level: number;

  constructor(
    private readonly name: string,
    level: LogLevel = 'info',
  ) {
    this.level = LOG_LEVELS[level];
  }

  info(message: string, data?: Record<string, unknown>): void {
    this.log('info', message, data);
  }

  private log(level: LogLevel, message: string, data?: Record<string, unknown>): void {
    if (LOG_LEVELS[level] < this.level) return;

    const entry = {
      timestamp: new Date().toISOString(),
      level,
      server: this.name,
      message,
      ...data,
    };
    process.stderr.write(JSON.stringify(entry) + '\n');
  }
}
    

Why stderr?

The MCP protocol uses stdout exclusively for JSON-RPC messages between client and server. If the logger wrote to stdout, log messages would mix with the protocol, corrupting communication. stderr is the designated channel for diagnostics, maintaining separation between data and logs.

errors.ts: Typed Error Hierarchy

The package defines a typed error hierarchy that allows servers to handle different failure types uniformly. Each error has a machine-readable code and optional details for debugging.

Error Hierarchy

Class Code Usage
McpSuiteError - Base class for all errors
ConfigError CONFIG_ERROR Invalid or missing configuration
ConnectionError CONNECTION_ERROR Database or service connection problems
ToolExecutionError TOOL_EXECUTION_ERROR Error during MCP tool execution
NotFoundError NOT_FOUND Requested resource not found
ValidationError VALIDATION_ERROR Invalid user input

import { NotFoundError, ToolExecutionError } from '@mcp-suite/core';

// In a store
const sprint = db.prepare('SELECT * FROM sprints WHERE id = ?').get(id);
if (!sprint) {
  throw new NotFoundError('Sprint', String(id));
  // Message: "Sprint with id '42' not found"
}

// In a tool handler
try {
  const result = store.createSprint(input);
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
} catch (error) {
  if (error instanceof NotFoundError) {
    return { content: [{ type: 'text', text: error.message }], isError: true };
  }
  throw new ToolExecutionError('Failed to create sprint', error);
}
    

types.ts: Shared Domain Types

The types.ts module defines over 30 domain types shared across servers, organized by functional area. This ensures all servers use the same data structures for common concepts like tasks, sprints, metrics, and tool results.


export interface ToolSuccess<T = unknown> {
  success: true;
  data: T;
  metadata?: Record<string, unknown>;
}

export interface ToolError {
  success: false;
  error: string;
  code: string;
  details?: unknown;
}

export type ToolResult<T = unknown> = ToolSuccess<T> | ToolError;
    

Domain Types Organization

Domain Key Types Used By
Code & Git FileReference, GitCommitInfo, CodeIssue code-review, codebase-knowledge
Project Management TaskStatus, UserStory, SprintInfo scrum-board, agile-metrics
Economics BudgetInfo, BudgetCategory, CostEntry project-economics
CI/CD PipelineStatus, PipelineRun, PipelineStage cicd-monitor
Database TableInfo, ColumnInfo, IndexInfo db-schema-explorer

The index.ts file re-exports everything in an organized way, allowing servers to import any component from a single entry point:


import {
  createMcpServer,
  type McpSuiteServer,
  type EventBus,
  Logger,
  NotFoundError
} from '@mcp-suite/core';
    

@mcp-suite/event-bus: Inter-Server Collaboration with Typed Events

The @mcp-suite/event-bus package implements a typed event system for asynchronous inter-server collaboration. It defines 29 events organized by domain, a generic EventBus interface, and a local implementation based on Node.js EventEmitter with pattern matching support via the micromatch library.


packages/event-bus/
+-- package.json
+-- tsconfig.json
+-- src/
    +-- index.ts      # Re-export of public modules
    +-- bus.ts         # EventBus interface, handler types
    +-- events.ts      # EventMap with all 29 typed events
    +-- local-bus.ts   # LocalEventBus (in-process implementation)
    

EventBus Interface

The EventBus interface defines the contract for any event bus implementation. It exposes four methods: publish to emit typed events, subscribe to listen to a specific event, subscribePattern to listen to events matching a wildcard pattern, and clear to remove all subscriptions.


export interface EventBus {
  publish<E extends EventName>(
    event: E, payload: EventPayload<E>
  ): Promise<void>;

  subscribe<E extends EventName>(
    event: E, handler: EventHandler<E>
  ): () => void;

  subscribePattern(
    pattern: string, handler: PatternHandler
  ): () => void;

  clear(): void;
}

// Handler for specific event (typed payload)
export type EventHandler<E extends EventName> = (
  payload: EventPayload<E>
) => void | Promise<void>;

// Handler for pattern (event name + generic payload)
export type PatternHandler = (
  event: string, payload: unknown
) => void | Promise<void>;
    

EventMap: The 29 Events Organized by Domain

The EventMap is a TypeScript interface that maps each event name to its typed payload. Events follow the domain:action convention and cover 11 functional domains, from code review to retrospectives, from CI/CD to standup.

Event Domain Map

Domain Pattern Event Count Examples
Code & Git code:* 3 commit-analyzed, review-completed, dependency-alert
CI/CD cicd:* 2 pipeline-completed, build-failed
Scrum scrum:* 4 sprint-started, sprint-completed, task-updated, story-completed
Time Tracking time:* 2 entry-logged, timesheet-generated
Database db:* 2 schema-changed, index-suggestion
Testing test:* 2 generated, coverage-report
Docs docs:* 2 api-updated, stale-detected
Performance perf:* 2 bottleneck-found, profile-completed
Retrospectives retro:* 2 action-item-created, completed
Economics economics:* 2 budget-alert, cost-updated
Standup standup:* 1 report-generated

Derived types from the EventMap enable strong compile-time typing:


// Name of any valid event (union of 29 literal strings)
export type EventName = keyof EventMap;

// Typed payload for a specific event
export type EventPayload<E extends EventName> = EventMap[E];
// EventPayload<'scrum:sprint-started'>
//   => { sprintId: string; name: string; startDate: string; endDate: string }
    

LocalEventBus: In-Process Implementation

The LocalEventBus is the default implementation based on Node.js EventEmitter. When an event is published, the bus first emits for direct subscribers, then checks all pattern subscribers using micromatch for wildcard matching.


export class LocalEventBus implements EventBus {
  private emitter = new EventEmitter();
  private patternSubs: PatternSubscription[] = [];

  constructor() {
    this.emitter.setMaxListeners(100);
  }

  async publish<E extends EventName>(
    event: E, payload: EventPayload<E>
  ): Promise<void> {
    // 1. Emit for direct subscribers
    this.emitter.emit(event, payload);

    // 2. Check pattern subscribers
    for (const sub of this.patternSubs) {
      if (micromatch.isMatch(event, sub.pattern)) {
        try {
          await sub.handler(event, payload);
        } catch {
          // Errors caught silently
        }
      }
    }
  }

  subscribe<E extends EventName>(
    event: E, handler: EventHandler<E>
  ): () => void {
    this.emitter.on(event, handler);
    return () => { this.emitter.off(event, handler); };
  }

  subscribePattern(pattern: string, handler: PatternHandler): () => void {
    const sub = { pattern, handler };
    this.patternSubs.push(sub);
    return () => {
      const index = this.patternSubs.indexOf(sub);
      if (index >= 0) this.patternSubs.splice(index, 1);
    };
  }

  clear(): void {
    this.emitter.removeAllListeners();
    this.patternSubs = [];
  }
}
    

Fire-and-Forget Pattern and Wildcards

Tools publish events using the optional chaining operator (?.) to handle the case where the EventBus is not present. This fire-and-forget pattern ensures the tool never waits for handler completion, and handler errors never propagate to the calling tool.


// In the tool handler: fire-and-forget
eventBus?.publish('scrum:sprint-started', {
  sprintId: String(sprint.id),
  name: sprint.name,
  startDate: sprint.startDate,
  endDate: sprint.endDate,
});
    

The micromatch library supports advanced wildcard patterns for subscriptions:

Supported Wildcard Patterns

Pattern Matches Example
scrum:* All scrum events scrum:sprint-started, scrum:task-updated
*:*-completed All completion events scrum:sprint-completed, retro:completed
*:*-alert All alerts code:dependency-alert, economics:budget-alert
{scrum,time}:* Scrum or time events scrum:task-updated, time:entry-logged

// Subscribe to all scrum domain events
eventBus.subscribePattern('scrum:*', (eventName, payload) => {
  console.log(`Scrum event: ${eventName}`, payload);
});

// Subscribe to all completion events
eventBus.subscribePattern('*:*-completed', (eventName, payload) => {
  console.log(`Completion: ${eventName}`, payload);
});

// Cancel a subscription
const unsubscribe = eventBus.subscribe('scrum:task-updated', handler);
unsubscribe(); // removes the listener
    

@mcp-suite/database: SQLite Persistence with WAL and Migrations

The @mcp-suite/database package provides an abstraction for creating and managing SQLite databases. It offers two main features: a factory for creating database connections (createDatabase) and an incremental migration system (runMigrations). It uses better-sqlite3 as a high-performance synchronous native driver.


packages/database/
+-- package.json
+-- tsconfig.json
+-- src/
    +-- index.ts          # Re-export of public modules
    +-- connection.ts     # createDatabase(), DatabaseOptions
    +-- migrations.ts     # runMigrations(), Migration
    

createDatabase(): The Database Factory

The createDatabase function automatically handles directory creation, WAL mode configuration, and foreign key enablement. It also supports in-memory mode for testing.


export interface DatabaseOptions {
  serverName: string;     // Server name (becomes .db filename)
  dataDir?: string;       // Custom directory (default: ~/.mcp-suite/data/)
  inMemory?: boolean;     // If true, in-memory database (for tests)
}

export function createDatabase(options: DatabaseOptions): Database.Database {
  if (options.inMemory) {
    return new Database(':memory:');
  }

  const dataDir = options.dataDir || DEFAULT_DATA_DIR;
  if (!existsSync(dataDir)) {
    mkdirSync(dataDir, { recursive: true });
  }

  const dbPath = join(dataDir, `${options.serverName}.db`);
  const db = new Database(dbPath);

  // Optimal SQLite configuration
  db.pragma('journal_mode = WAL');     // Write-Ahead Logging
  db.pragma('foreign_keys = ON');      // Referential integrity

  return db;
}
    

Why WAL Mode?

Write-Ahead Logging (WAL) is a SQLite journal mode that offers significant advantages: concurrent reads without locks, non-blocking writes, better I/O performance, and automatic crash recovery. Readers read from the main file while the writer writes to the WAL file, without mutual interference. Periodically a checkpoint synchronizes the WAL into the main file.

The default database directory structure is ~/.mcp-suite/data/, with one .db file per server that needs persistence:


~/.mcp-suite/data/
+-- scrum-board.db              # Sprints, stories, tasks
+-- scrum-board.db-wal          # WAL file (Write-Ahead Log)
+-- scrum-board.db-shm          # Shared memory for WAL
+-- standup-notes.db            # Standup notes
+-- time-tracking.db            # Time entries
+-- snippet-manager.db          # Code snippets
+-- project-economics.db        # Budget, costs
+-- retrospective-manager.db    # Retrospectives
+-- environment-manager.db      # Environment variables
    

Incremental Migration System

The migration system allows servers to evolve their database schema incrementally and safely. Each migration has a progressive version number, a human-readable description, and the SQL to execute. The _migrations table tracks already-applied migrations.


export interface Migration {
  version: number;        // Progressive number
  description: string;    // Human-readable description
  up: string;             // SQL to execute
}

export function runMigrations(db: Database.Database, migrations: Migration[]): void {
  // 1. Create _migrations table if not exists
  db.exec(`
    CREATE TABLE IF NOT EXISTS _migrations (
      version INTEGER PRIMARY KEY,
      description TEXT NOT NULL,
      applied_at TEXT NOT NULL DEFAULT (datetime('now'))
    )
  `);

  // 2. Read already-applied migrations
  const applied = db
    .prepare('SELECT version FROM _migrations ORDER BY version')
    .all() as Array<{ version: number }>;
  const appliedVersions = new Set(applied.map((m) => m.version));

  // 3. Sort and apply only new migrations
  const sorted = [...migrations].sort((a, b) => a.version - b.version);
  const insertMigration = db.prepare(
    'INSERT INTO _migrations (version, description) VALUES (?, ?)',
  );

  for (const migration of sorted) {
    if (appliedVersions.has(migration.version)) continue;
    db.exec(migration.up);
    insertMigration.run(migration.version, migration.description);
  }
}
    

How Servers Use the Database

Each server that needs persistence defines its own migrations and applies them in its Store constructor:


// In servers/scrum-board/src/services/scrum-store.ts

const migrations: Migration[] = [
  {
    version: 1,
    description: 'Create sprints, stories, and tasks tables',
    up: `
      CREATE TABLE IF NOT EXISTS sprints (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        startDate TEXT NOT NULL,
        endDate TEXT NOT NULL,
        goals TEXT NOT NULL DEFAULT '[]',
        status TEXT NOT NULL DEFAULT 'planning',
        createdAt TEXT NOT NULL DEFAULT (datetime('now'))
      );

      CREATE TABLE IF NOT EXISTS stories (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        storyPoints INTEGER NOT NULL DEFAULT 0,
        status TEXT NOT NULL DEFAULT 'todo',
        sprintId INTEGER REFERENCES sprints(id),
        createdAt TEXT NOT NULL DEFAULT (datetime('now'))
      );
    `,
  },
];

export class ScrumStore {
  private db: Database.Database;

  constructor(options?: { inMemory?: boolean }) {
    this.db = createDatabase({
      serverName: 'scrum-board',
      inMemory: options?.inMemory,
    });
    runMigrations(this.db, migrations);
  }
}
    

@mcp-suite/testing: TestHarness and MockEventBus

The @mcp-suite/testing package provides utilities for testing MCP servers in isolation, without requiring a real client or network connection. It offers two main components: a TestHarness based on the MCP SDK's InMemoryTransport and a MockEventBus for verifying event emission in tests.

TestHarness: In-Process Testing

The TestHarness creates a client-server pair connected in memory using InMemoryTransport.createLinkedPair(). The test client can call tools, list resources, and verify results exactly like a real client, but without external processes, network ports, or STDIO files.


export interface TestHarness {
  client: Client;              // MCP client connected to the server
  close: () => Promise<void>;  // Function to close the connection
}

export async function createTestHarness(server: McpServer): Promise<TestHarness> {
  // 1. Create a pair of linked in-memory transports
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

  // 2. Create a test client
  const client = new Client({
    name: 'test-client',
    version: '1.0.0',
  });

  // 3. Connect server and client to their respective transports
  await server.connect(serverTransport);
  await client.connect(clientTransport);

  // 4. Return client and cleanup function
  return {
    client,
    close: async () => {
      await client.close();
      await server.close();
    },
  };
}
    

TestHarness Architecture


+-------------------------------------------------------+
|                    Test Process                        |
|                                                       |
|  +------------+    InMemoryTransport   +------------+ |
|  |  Client    |<--------------------->|  Server    | |
|  |  (test)    |    (bidirectional      |  (MCP)     | |
|  |            |     in-memory link)    |            | |
|  |  callTool  |                        |  tools     | |
|  |  listTools |                        |  resources | |
|  +------------+                        +------------+ |
|                                                       |
|  No external processes                                |
|  No network ports                                     |
|  No STDIO files                                       |
+-------------------------------------------------------+
      

MockEventBus: Event Verification in Tests

The MockEventBus implements the EventBus interface, recording all published events to enable assertions in tests. It offers utility methods such as wasPublished() and getPublishedEvents().


export class MockEventBus implements EventBus {
  public published: PublishedEvent[] = [];

  async publish<E extends EventName>(
    event: E, payload: EventPayload<E>
  ): Promise<void> {
    this.published.push({ event, payload, timestamp: new Date() });
    // Also executes registered handlers (for testing subscribers)
  }

  // Utility methods for assertions
  wasPublished(eventName: string): boolean {
    return this.published.some((e) => e.event === eventName);
  }

  getPublishedEvents(eventName?: string): PublishedEvent[] {
    if (eventName) {
      return this.published.filter((e) => e.event === eventName);
    }
    return this.published;
  }

  clear(): void {
    this.published = [];
  }
}
    

Complete Test Example with Vitest

Here is a complete test that uses both the TestHarness and MockEventBus to verify sprint creation and the corresponding event emission:


import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTestHarness, type TestHarness } from '@mcp-suite/testing';
import { MockEventBus } from '@mcp-suite/testing';

describe('Scrum Board Server', () => {
  let harness: TestHarness;
  let eventBus: MockEventBus;

  beforeAll(async () => {
    eventBus = new MockEventBus();
    const suite = createScrumBoardServer(eventBus);
    harness = await createTestHarness(suite.server);
  });

  afterAll(async () => {
    await harness.close();
  });

  it('should create a sprint', async () => {
    const result = await harness.client.callTool({
      name: 'create-sprint',
      arguments: {
        name: 'Sprint 1',
        startDate: '2025-01-13',
        endDate: '2025-01-24',
        goals: ['Complete authentication'],
      },
    });

    expect(result.content).toBeDefined();
    expect(result.isError).toBeUndefined();
  });

  it('should emit sprint-started event', async () => {
    expect(eventBus.wasPublished('scrum:sprint-started')).toBe(true);

    const events = eventBus.getPublishedEvents('scrum:sprint-started');
    expect(events).toHaveLength(1);
    expect(events[0].payload).toMatchObject({
      name: 'Sprint 1',
      startDate: '2025-01-13',
    });
  });

  it('should list tools', async () => {
    const tools = await harness.client.listTools();
    const toolNames = tools.tools.map(t => t.name);
    expect(toolNames).toContain('create-sprint');
    expect(toolNames).toContain('sprint-board');
  });
});
    

@mcp-suite/cli: Server Management from the Terminal

The @mcp-suite/cli package provides a command-line interface built with Commander.js for managing MCP Suite servers. It offers three commands: list to enumerate all available servers, start to launch a specific server, and status to check which servers are built.


# List all available servers
npx @mcp-suite/cli list

# Start a server with STDIO transport (default)
npx @mcp-suite/cli start scrum-board

# Start a server with HTTP transport
npx @mcp-suite/cli start scrum-board --transport http

# Check build status
npx @mcp-suite/cli status
    

The CLI scans the servers/ directory to automatically discover all available servers and verifies the presence of the compiled dist/index.js file before starting a server. The start command launches the Node.js process as a subprocess, forwarding stdin/stdout/stderr and setting the appropriate environment variables.


npx @mcp-suite/cli status

MCP Suite Status:
  Total servers: 22
  Built: 20
  Not built: 2

  Built servers:
    + agile-metrics
    + api-documentation
    + scrum-board
    ...

  Not built:
    x docker-compose
    x performance-profiler
    

@mcp-suite/client-manager: Client Pool for Server-to-Server Communication

The @mcp-suite/client-manager package manages a pool of MCP clients for synchronous server-to-server communication. It allows a server to call tools exposed by other servers as if they were local functions, completely abstracting transport details. Unlike the EventBus (fire-and-forget, asynchronous), the Client Manager provides synchronous request/response communication.

ServerRegistryEntry and McpClientManager

The Client Manager works with a registry + lazy connection + pool pattern: target servers are registered, connections are created only on first use, and are reused across subsequent calls.


export interface ServerRegistryEntry {
  name: string;                     // Unique target server name
  transport: 'stdio' | 'http' | 'in-memory';  // Transport type
  command?: string;   // Command for STDIO (e.g., 'node')
  args?: string[];    // Arguments for STDIO
  url?: string;       // URL for HTTP (e.g., 'http://localhost:3018/mcp')
  env?: Record<string, string>;  // Environment variables for STDIO
}

class McpClientManager {
  // Registration
  register(entry: ServerRegistryEntry): void;
  registerMany(entries: ServerRegistryEntry[]): void;

  // Connection (lazy)
  getClient(serverName: string): Promise<Client>;

  // RPC calls
  callTool(serverName: string, toolName: string,
           args?: Record<string, unknown>): Promise<unknown>;
  readResource(serverName: string, uri: string): Promise<unknown>;

  // Lifecycle management
  disconnect(serverName: string): Promise<void>;
  disconnectAll(): Promise<void>;

  // Query
  getRegisteredServers(): string[];
  isConnected(serverName: string): boolean;
}
    

The 3 Supported Transports

The Client Manager supports three transport types, each optimal for a different scenario:

Client Manager Transports

Transport Scenario Details
HTTP Servers in separate processes or machines Uses MCP SDK's Streamable HTTP
STDIO Servers as subprocesses Communicates via process stdin/stdout
InMemory Tests and in-process scenarios Uses InMemoryTransport.createLinkedPair()

// HTTP Transport
clientManager.register({
  name: 'scrum-board',
  transport: 'http',
  url: 'http://localhost:3018/mcp',
});

// STDIO Transport
clientManager.register({
  name: 'scrum-board',
  transport: 'stdio',
  command: 'node',
  args: ['servers/scrum-board/dist/index.js'],
  env: { MCP_SUITE_TRANSPORT: 'stdio' },
});

// InMemory Transport (for tests)
const [clientTransport, serverTransport] = McpClientManager.createInMemoryPair();
await targetServer.connect(serverTransport);
await clientManager.connectInMemoryWithTransport('target-server', clientTransport);
    

Lazy Connection and Graceful Degradation

The Client Manager creates clients lazily: the connection is established only on the first callTool() call. Subsequent calls reuse the client from the cache. If a target server is unreachable, calling tools use the graceful degradation pattern: they continue to work without the cross-server enrichment.


// Complete example: usage in a server
const clientManager = new McpClientManager();
clientManager.registerMany([
  {
    name: 'scrum-board',
    transport: 'http',
    url: process.env.MCP_SUITE_SCRUM_BOARD_URL || 'http://localhost:3018/mcp',
  },
  {
    name: 'time-tracking',
    transport: 'http',
    url: process.env.MCP_SUITE_TIME_TRACKING_URL || 'http://localhost:3022/mcp',
  },
]);

// Cross-server call with graceful degradation
if (enrichFromExternal && clientManager) {
  try {
    const result = await clientManager.callTool('scrum-board', 'get-sprint',
      { sprintId: 1 });
    // use the result to enrich data
  } catch (error) {
    logger.warn('Cross-server call failed, continuing without enrichment');
  }
}

// Cleanup at the end
await clientManager.disconnectAll();
    

Summary: How the Packages Collaborate

The 6 shared packages form a layered architecture where every server can be created, configured, tested, and orchestrated with just a few lines of code:

MCP Server Lifecycle

  1. Creation: createMcpServer() from @mcp-suite/core creates the server with configuration, logger, and EventBus
  2. Persistence: createDatabase() from @mcp-suite/database creates the SQLite database with WAL mode
  3. Migrations: runMigrations() applies the incremental schema
  4. Events: LocalEventBus from @mcp-suite/event-bus enables fire-and-forget collaboration
  5. Cross-server: McpClientManager from @mcp-suite/client-manager enables synchronous RPC calls
  6. Startup: startServer() launches with the appropriate transport
  7. Testing: createTestHarness() from @mcp-suite/testing enables in-process testing
  8. Management: @mcp-suite/cli manages servers from the terminal

Conclusions

The shared packages represent the architectural core of Tech-MCP. Without them, each server would need to reimplement the factory, logger, database management, event system, and cross-server communication -- multiplying complexity by 22. Thanks to this architecture, creating a new server is a matter of a few lines: just import from @mcp-suite/core, define migrations with @mcp-suite/database, and register the tools.

In the next article, we will move from shared packages to concrete servers, exploring the productivity servers: code-review for code analysis, dependency-manager for dependency management, and project-scaffolding for automatic scaffolding of new projects. We will see how these servers leverage the shared package infrastructure to offer advanced functionality with minimal code.

The complete code for all shared packages is available in the Tech-MCP GitHub repository.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects