P0.2.1 — Step 3 Packet

Execution plan from audit (a7305e43) and contract (790443c4). This packet is the single authoritative script for Step 4 implementation. Because P0.2.1 is the first task where the α runtime actually boots, the packet is load-bearing: T0 reviews this document before Sigma authorizes Step 4.

Nothing in Step 4 may land until:

  1. T0 approves the open questions in §9.
  2. Sigma relays “proceed” (or “revise”) via SendMessage.

This is a HALT checkpoint. The worktree and branch stay local until approval.


§0. Single-commit shape (Step 4)

Step 4 will land as ONE commit on feature/p0-2-1-mcp-server:

feat(p0-2-1-mcp-server): MCP server bootstrap + 5-stage α chain + server_ping

The commit touches exactly two paths:

  • src/server.ts (new, ~400 lines estimated)
  • src/__tests__/server.test.ts (new, ~600 lines estimated)

No other file is touched in Step 4. If Step 4 surfaces a need to edit package.json / tsconfig.json / jest.config.ts / .eslintrc.json / .env.example / src/config.ts / src/modes.ts / src/index.ts, the implementer HALTS and escalates to Sigma per contract §3c — do NOT guess-fix.


§1. src/server.ts — authoring recipe

Authored in TypeScript 5.3+ under strict: true + exactOptionalPropertyTypes: true + noUncheckedIndexedAccess: true.

1a. Top-of-file TSDoc block

Matches the voice of src/config.ts and src/modes.ts. Documents:

  • Purpose (α System Core — MCP server bootstrap, 5-stage chain, server_ping).
  • Canonical references (s17 §2+§4, boot.md §1-6, middleware.md Stage 1-5, modes.md, ADR-004, audit doc, contract doc).
  • Consumed-by (future): P0.2.2 DB init, P0.2.3 two-phase startup, P0.2.4 middleware split, P0.3+ domain handlers, P0.7 ζ sink.
  • Donor-bug mitigations explicitly listed: stdio-transport-before-heavy-init, no monkey-patch of process.stdout.write, global handlers installed.

1b. Imports

import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { performance } from 'node:perf_hooks';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { randomUUID } from 'node:crypto';

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { z } from 'zod';

import { config } from './config.js';
import { capabilitiesFor, detectMode, type RuntimeMode } from './modes.js';

Rationale:

  • randomUUID from node:crypto generates the correlationId (stage 3) — zero deps, ambient.
  • performance.now() from node:perf_hooks is monotonic (contract §5a). Required because Date.now() can jump backward on clock sync.
  • readFileSync used once at boot to read package.json#version — synchronous is fine because it runs inside createServer() before any event-loop work (option 1 from contract §3b).
  • fileURLToPath(import.meta.url) resolves the current file’s directory under NodeNext ESM; resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json') locates package.json relative to the compiled dist/server.js OR the ts-jest-transformed src/server.ts. Both resolve to the same real path (parent of dist/ or src/).
  • SDK subpaths are .js because the package exports map routes .js suffixes to the right ESM build under NodeNext.
  • type Transport is type-only so it’s erased at runtime (no import cost).

1c. Internal types (not exported)

/** Thrown by stage 2 when Zod rejects the inbound args. */
class SchemaValidationError extends Error {
  public readonly issues: z.ZodIssue[];
  constructor(issues: z.ZodIssue[]) {
    super('schema validation failed');
    this.name = 'SchemaValidationError';
    this.issues = issues;
  }
}

/** Thrown by `registerColibriTool` for invalid tool names. */
class InvalidToolNameError extends Error {
  constructor(name: string) {
    super(`invalid tool name: ${name}`);
    this.name = 'InvalidToolNameError';
  }
}

Not exported (internal diagnostics only).

1d. Exported types (11 symbols per contract §1a)

export interface ToolEnterEvent {
  readonly tool: string;
  readonly args: unknown;
  readonly timestamp: number;
  readonly correlationId: string;
}

export interface ToolExitEvent {
  readonly tool: string;
  readonly correlationId: string;
  readonly durationMs: number;
  readonly result?: unknown;
  readonly error?: Error;
}

export interface AuditSink {
  enter(event: ToolEnterEvent): Promise<void> | void;
  exit(event: ToolExitEvent): Promise<void> | void;
}

export interface ColibriToolConfig<I extends z.ZodRawShape = z.ZodRawShape> {
  readonly title?: string;
  readonly description?: string;
  readonly inputSchema: z.ZodObject<I>;
  readonly outputSchema?: z.ZodObject<z.ZodRawShape>;
}

export interface CreateServerOptions {
  readonly auditSink?: AuditSink;
  readonly transport?: Transport;
  readonly version?: string;
  readonly mode?: RuntimeMode;
  readonly nowMs?: () => number;
  readonly bootStartMs?: number;
  readonly logger?: (...args: unknown[]) => void;
  readonly installGlobalHandlers?: boolean;  // R-5 mitigation; default = true
  readonly readPackageJson?: () => string;   // R-4 mitigation; default = fs.readFileSync
  readonly startupTimeoutMs?: number;         // override config.COLIBRI_STARTUP_TIMEOUT_MS for tests
}

export interface ColibriServerContext {
  readonly server: McpServer;
  readonly transport: Transport;
  readonly auditSink: AuditSink;
  readonly version: string;
  readonly mode: RuntimeMode;
  readonly nowMs: () => number;
  readonly bootStartMs: number;
  readonly logger: (...args: unknown[]) => void;
  readonly startupTimeoutMs: number;
  /** @internal per-tool mutex map — consumers MUST NOT touch. */
  readonly _toolLocks: Map<string, Promise<void>>;
  /** @internal registered tool names, for duplicate-registration detection. */
  readonly _registeredToolNames: Set<string>;
  /** @internal true once global handlers installed — idempotence. */
  _globalHandlersInstalled: boolean;
}

All readonly at the type level. The two _-prefixed fields are marked @internal via TSDoc and are mutable-as-a-Set/Map (contents mutate, binding does not).

1e. createServer factory

export function createServer(options: CreateServerOptions = {}): ColibriServerContext {
  const nowMs = options.nowMs ?? (() => performance.now());
  const bootStartMs = options.bootStartMs ?? nowMs();
  const logger = options.logger ?? console.error;
  const readPackageJson = options.readPackageJson ?? (() => {
    const here = dirname(fileURLToPath(import.meta.url));
    const pkgPath = resolve(here, '..', 'package.json');
    return readFileSync(pkgPath, 'utf8');
  });

  const version = options.version ?? (() => {
    try {
      const parsed = JSON.parse(readPackageJson()) as { version?: unknown };
      if (typeof parsed.version !== 'string' || parsed.version.length === 0) {
        throw new Error('package.json has no version string');
      }
      return parsed.version;
    } catch (e) {
      throw new Error(
        `Failed to read package.json version: ${e instanceof Error ? e.message : String(e)}`,
      );
    }
  })();

  const mode = options.mode ?? detectMode(process.env);
  const auditSink = options.auditSink ?? createNoOpAuditSink();
  const transport = options.transport ?? new StdioServerTransport();
  const startupTimeoutMs = options.startupTimeoutMs ?? config.COLIBRI_STARTUP_TIMEOUT_MS;

  const server = new McpServer({ name: 'colibri', version });

  // Capability read — currently advisory only (pure-mutex tool-lock per contract §2).
  // Kept here to surface the dependency on src/modes.ts for future P0.2.3 / P0.4.2 wiring.
  void capabilitiesFor(mode);

  return {
    server,
    transport,
    auditSink,
    version,
    mode,
    nowMs,
    bootStartMs,
    logger,
    startupTimeoutMs,
    _toolLocks: new Map(),
    _registeredToolNames: new Set(),
    _globalHandlersInstalled: false,
  };
}
  • NO transport.connect() call here (contract §3c).
  • NO process.on(...) call here (contract §3c).
  • installGlobalHandlers lives on options but is only READ in start(), not here.
  • void capabilitiesFor(mode) intentionally references the import so the linker doesn’t tree-shake it out AND so the packet’s documentation of the mode → capability dependency is compiled-in.

1f. createNoOpAuditSink

export function createNoOpAuditSink(): AuditSink {
  return Object.freeze({
    enter(): void { /* no-op */ },
    exit(): void { /* no-op */ },
  });
}

No caching — each call returns a fresh frozen object. Tests MUST NOT assume referential identity.

1g. registerColibriTool

const TOOL_NAME_RE = /^[a-z_][a-z0-9_]*$/;

export function registerColibriTool<I extends z.ZodRawShape>(
  ctx: ColibriServerContext,
  name: string,
  config: ColibriToolConfig<I>,
  handler: (args: z.infer<z.ZodObject<I>>) => Promise<unknown> | unknown,
): void {
  if (!TOOL_NAME_RE.test(name)) {
    throw new InvalidToolNameError(name);
  }
  if (!(config.inputSchema instanceof z.ZodObject)) {
    throw new Error('inputSchema must be a Zod object');
  }
  if (ctx._registeredToolNames.has(name)) {
    throw new Error(`tool already registered: ${name}`);
  }
  ctx._registeredToolNames.add(name);

  // Compose 5-stage chain as a single wrapped callback for the SDK.
  const wrappedHandler = async (rawArgs: unknown): Promise<{
    content: Array<{ type: 'text'; text: string }>;
    structuredContent?: unknown;
    isError?: boolean;
  }> => {
    return await runWithToolLock(ctx, name, async () => {
      // Stage 2: schema-validate
      const parsed = config.inputSchema.safeParse(rawArgs);
      if (!parsed.success) {
        // Record via audit sink as a best-effort (exit event with error), then map to envelope.
        const err = new SchemaValidationError(parsed.error.issues);
        await ctx.auditSink.exit({
          tool: name,
          correlationId: randomUUID(),
          durationMs: 0,
          error: err,
        });
        return {
          content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: 'INVALID_PARAMS', message: err.message, details: { issues: parsed.error.issues } } }) }],
          structuredContent: { ok: false, error: { code: 'INVALID_PARAMS', message: err.message, details: { issues: parsed.error.issues } } },
          isError: true,
        };
      }

      const typedArgs = parsed.data as z.infer<z.ZodObject<I>>;
      const correlationId = randomUUID();
      const enterTs = ctx.nowMs();
      let result: unknown;
      let error: Error | undefined;

      try {
        // Stage 3: audit-enter
        await ctx.auditSink.enter({ tool: name, args: typedArgs, timestamp: enterTs, correlationId });

        // Stage 4: dispatch
        result = await handler(typedArgs);

        return {
          content: [{ type: 'text', text: JSON.stringify({ ok: true, data: result }) }],
          structuredContent: { ok: true, data: result },
        };
      } catch (e) {
        error = e instanceof Error ? e : new Error(String(e));
        const code = error instanceof SchemaValidationError ? 'INVALID_PARAMS' : 'HANDLER_ERROR';
        return {
          content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code, message: error.message } }) }],
          structuredContent: { ok: false, error: { code, message: error.message } },
          isError: true,
        };
      } finally {
        // Stage 5: audit-exit (always runs)
        try {
          await ctx.auditSink.exit({
            tool: name,
            correlationId,
            durationMs: Math.floor(ctx.nowMs() - enterTs),
            ...(result !== undefined ? { result } : {}),
            ...(error !== undefined ? { error } : {}),
          });
        } catch (sinkErr) {
          // Contract §4c + §14 Q-2: log, don't re-throw. Preserves the original error.
          ctx.logger('[colibri] audit-exit sink failed:', sinkErr);
        }
      }
    });
  };

  ctx.server.registerTool(
    name,
    {
      title: config.title ?? name,
      description: config.description,
      inputSchema: config.inputSchema.shape,
    },
    wrappedHandler as never, // See R-2 in contract §13; SDK generics resolve at call site
  );
}

async function runWithToolLock<T>(
  ctx: ColibriServerContext,
  name: string,
  fn: () => Promise<T>,
): Promise<T> {
  // Stage 1: tool-lock — pure per-tool mutex per contract §2.
  const prev = ctx._toolLocks.get(name);
  let release!: () => void;
  const current = new Promise<void>((r) => { release = r; });
  ctx._toolLocks.set(name, current);
  try {
    if (prev !== undefined) { await prev; }
    return await fn();
  } finally {
    release();
    // Only clear the lock if it's still ours (prevents a race with the next caller).
    if (ctx._toolLocks.get(name) === current) {
      ctx._toolLocks.delete(name);
    }
  }
}

Key details:

  • The SDK expects the handler’s return shape to match CallToolResult ({ content: Array<...>, structuredContent?, isError? }). We put our { ok, data } / { ok, error } envelope BOTH into content[0].text (stringified JSON — for clients that only read content) AND into structuredContent (for clients that support structured results).
  • InvalidToolNameError is local (not exported — s17 §5 “A tool without a schema cannot be registered” is enforced by our helper, not by the SDK).
  • Schema-validation failure PRODUCES an error envelope but does NOT throw — that way the SDK’s own isError: true wire semantics are hit. Stage 3 is bypassed (no enter event); stage 5’s exit is still recorded with just the error + durationMs: 0. This matches middleware.md Stage 2 (“a call that never validated never enters the decision trail”).
  • Handler exceptions (stage 4) are caught and turned into an error envelope. Stage 5 sees error, not result.
  • runWithToolLock uses a local release closure and a per-call current Promise — standard JS mutex pattern. Tests T-17 observe serialization.

1h. start

export async function start(ctx: ColibriServerContext): Promise<void> {
  installGlobalHandlersIfNeeded(ctx);
  ctx.logger('[colibri] starting in mode=', ctx.mode, 'version=', ctx.version);

  await Promise.race([
    ctx.server.connect(ctx.transport),
    new Promise<never>((_, reject) => {
      setTimeout(() => {
        reject(new Error(`startup timeout exceeded (${ctx.startupTimeoutMs}ms)`));
      }, ctx.startupTimeoutMs).unref();
    }),
  ]);

  ctx.logger('[colibri] ready');
}

function installGlobalHandlersIfNeeded(ctx: ColibriServerContext): void {
  if (ctx._globalHandlersInstalled) { return; }
  ctx._globalHandlersInstalled = true;
  process.on('unhandledRejection', (reason) => {
    ctx.logger('[colibri] unhandledRejection:', reason);
    process.exit(1);
  });
  process.on('uncaughtException', (err) => {
    ctx.logger('[colibri] uncaughtException:', err);
    process.exit(1);
  });
}

The .unref() call on setTimeout prevents the timeout from keeping the event loop alive after connect() resolves.

1i. stop

export async function stop(ctx: ColibriServerContext): Promise<void> {
  await ctx.server.close();
}

1j. server_ping registration + main

function registerPing(ctx: ColibriServerContext): void {
  registerColibriTool(
    ctx,
    'server_ping',
    {
      title: 'server_ping',
      description: 'Health probe — returns server version, mode, and uptime.',
      inputSchema: z.object({}),
    },
    async () => ({
      version: ctx.version,
      mode: ctx.mode,
      uptime_ms: Math.floor(ctx.nowMs() - ctx.bootStartMs),
    }),
  );
}

async function main(): Promise<void> {
  const ctx = createServer();
  try {
    registerPing(ctx);
    await start(ctx);
  } catch (err) {
    ctx.logger('[colibri] fatal:', err);
    process.exit(1);
  }
}

// Entry-point IIFE — runs only when invoked as a script, not when imported.
const invokedAsScript = (() => {
  const arg1 = process.argv[1];
  if (arg1 === undefined) { return false; }
  return import.meta.url === pathToFileURL(arg1).href;
})();
if (invokedAsScript) {
  await main();
}

Note: registerPing is a module-private function, not exported. Tests that want to register server_ping in isolation MUST re-implement the registration (via registerColibriTool). This is deliberate — the handler closes over ctx, and exporting registerPing(ctx) is a convenience that adds surface area without being needed by any other caller.

1k. Export list (final check against contract §1a)

Exports in declaration order:

  1. AuditSink (interface) — §1d
  2. ToolEnterEvent (interface) — §1d
  3. ToolExitEvent (interface) — §1d
  4. ColibriToolConfig (interface) — §1d
  5. ColibriServerContext (interface) — §1d
  6. CreateServerOptions (interface) — §1d (added via contract §13 R-4+R-5; contract §1a listed 5 but via packet expansion to cover risks)
  7. createServer (function) — §1e
  8. registerColibriTool (function) — §1g
  9. start (function) — §1h
  10. stop (function) — §1i
  11. createNoOpAuditSink (function) — §1f

Note on delta from contract §1a: contract listed 11 symbols; packet list has 11 symbols but with one swap — CreateServerOptions is in the packet because it’s referenced by createServer and the ergonomic options?: CreateServerOptions signature requires the interface to be public. The contract count is honored.

main, registerPing, runWithToolLock, installGlobalHandlersIfNeeded, SchemaValidationError, InvalidToolNameError, TOOL_NAME_RE are all module-private.


§2. src/__tests__/server.test.ts — test recipe

2a. Top-of-file TSDoc

Matches the voice of src/__tests__/modes.test.ts:

  • States that tests exercise the pure factories (createServer, registerColibriTool, start, stop) with injected dependencies.
  • Uses SDK’s InMemoryTransport.createLinkedPair() to simulate a real MCP client-server handshake in-memory (contract §8c).
  • For the one test that needs an eager detectMode failure (T-26), uses spawnSync + tsx subprocess (matches P0.1.4’s pattern for eager side-effects).
  • Does NOT use jest.isolateModulesAsync (broken under ts-jest ESM + zod v3).

2b. Imports

import { spawnSync } from 'node:child_process';
import { PassThrough } from 'node:stream';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { z } from 'zod';

import {
  createNoOpAuditSink,
  createServer,
  registerColibriTool,
  start,
  stop,
  type AuditSink,
  type ColibriServerContext,
  type ToolEnterEvent,
  type ToolExitEvent,
} from '../server.js';

2c. Test structure — 6 describe blocks covering contract §8

  1. describe('createServer', ...) — T-1 through T-7 (contract §8a).
  2. describe('registerColibriTool', ...) — T-8 through T-11 (contract §8b).
  3. describe('middleware chain (InMemoryTransport)', ...) — T-12 through T-18 (contract §8c).
  4. describe('start / stop', ...) — T-19 through T-23 (contract §8d).
  5. describe('donor-bug regressions', ...) — T-24 through T-26 (contract §8e).
  6. describe('AuditSink interface', ...) — light sanity checks on createNoOpAuditSink (no contract §, but adds 2 cheap tests for coverage).

2d. Test-level fixtures

  • Custom audit sink: a helper makeRecordingSink() that returns { sink: AuditSink, events: Array<{ kind, payload }> }. Every enter and exit push into events. Used by T-14, T-15, T-16, T-17, T-18.

    function makeRecordingSink(): {
      sink: AuditSink;
      events: Array<{ kind: 'enter' | 'exit'; payload: ToolEnterEvent | ToolExitEvent }>;
    } {
      const events: Array<{ kind: 'enter' | 'exit'; payload: ToolEnterEvent | ToolExitEvent }> = [];
      const sink: AuditSink = {
        enter(e) { events.push({ kind: 'enter', payload: e }); },
        exit(e)  { events.push({ kind: 'exit',  payload: e }); },
      };
      return { sink, events };
    }
    
  • Linked-pair harness: a helper makeLinkedPair() returns { serverCtx, client, cleanup } with both transports wired and the client connected. Every test in describe 3 uses this.

    async function makeLinkedPair(extraOpts: Partial<CreateServerOptions> = {}): Promise<{
      ctx: ColibriServerContext;
      client: Client;
      cleanup: () => Promise<void>;
    }> {
      const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
      const ctx = createServer({
        transport: serverTransport,
        mode: 'FULL',
        installGlobalHandlers: false,  // test mode (contract §13 R-5)
        nowMs: (() => { let n = 1000; return () => (n += 1); })(),
        ...extraOpts,
      });
      registerColibriTool(ctx, 'server_ping', {
        title: 'server_ping',
        inputSchema: z.object({}),
      }, async () => ({
        version: ctx.version,
        mode: ctx.mode,
        uptime_ms: Math.floor(ctx.nowMs() - ctx.bootStartMs),
      }));
      const client = new Client({ name: 'test-client', version: '0.0.0' }, { capabilities: {} });
      await Promise.all([
        ctx.server.connect(serverTransport),
        client.connect(clientTransport),
      ]);
      return {
        ctx,
        client,
        cleanup: async () => {
          await client.close();
          await stop(ctx);
        },
      };
    }
    

    Note: install_global_handlers: false prevents the test suite from polluting Jest’s own handlers (contract §13 R-5).

2e. Specific test implementations (sketches)

All 26 test sketches are in the contract §8; packet locks the invocation details.

  • T-1 (createServer() reads package.json): read package.json from the worktree, assert version matches.
  • T-6 (createServer() with env-missing COLIBRI_* should still work because config is already loaded): calls createServer({ mode: 'FULL' }) which bypasses detectMode and config stays cached. No subprocess needed.
  • T-12 to T-18: use makeLinkedPair() + client.callTool({ name: 'server_ping', arguments: {} }). Assert on the structured response and on the events captured by recording.
  • T-17 concurrency: await Promise.all([client.callTool(...), client.callTool(...)]); inspect events for ordering. Expected pattern: either [enter1, exit1, enter2, exit2] OR [enter2, exit2, enter1, exit1] — NEVER [enter1, enter2, exit1, exit2] (which would mean stages overlap).
  • T-18 schema-validation: register a fresh tool with a non-empty schema, then call it with empty args. Assert isError: true + structuredContent.ok === false + exactly one exit event with error set and zero enter events.
  • T-19 start() resolves: call start() with the linked-pair server transport, assert it resolves within the default startupTimeoutMs (30000ms — tests use a shorter override).
  • T-20 start() timeout: createServer({ transport: new PassThroughTransport() /* never resolves */, startupTimeoutMs: 50 }); assert start() rejects with /startup timeout/.
  • T-21 global handlers installed: count process.listenerCount('unhandledRejection') before and after a start() that has installGlobalHandlers: true.
  • T-22 start() idempotence: call start() then call it again on the same ctx; listener count does not increase.
  • T-24 process.stdout.write untouched: capture const before = process.stdout.write.bind(process.stdout); call createServer(); assert process.stdout.write === before.
  • T-25 no HTTP / WebSocket imports: read src/server.ts source via readFileSync; assert /from '@modelcontextprotocol\/sdk\/server\/(sse|streamableHttp|websocket|express)'/ does not match.
  • T-26 detectMode throws on AMS_MODE: spawnSync subprocess per the P0.1.4 pattern — a tsx run of import('<...>/server.ts').then(m => { try { m.createServer(); console.log('ok'); } catch (e) { console.error(e.message); process.exit(1); } }) with { AMS_MODE: 'full', NODE_ENV: 'test' }; assert status !== 0 and stderr matches /legacy AMS_MODE/.

2f. Expected coverage outcome

Running npm test -- --coverage with the new files MUST report:

  • src/server.ts: statements 100%, functions 100%, lines 100%, branches ≥ 90% (target 100%; the main() guard IIFE and the fileURLToPath(process.argv[1] ?? '') branch may contribute uncovered-branch lines; if so, §3b specifies mitigation).
  • src/config.ts: unchanged (100% baseline).
  • src/modes.ts: unchanged (100% baseline).
  • src/index.ts: unchanged.
  • Total tests: 15 (config) + 24 (modes) + 1 (smoke) + 26 (server) = 66.

§3. Coverage strategy — branches / edge cases

3a. readPackageJson failure branch (T-bonus)

The branch where JSON.parse throws or .version is missing needs a test. Strategy: use CreateServerOptions.readPackageJson to inject a faulty reader:

it('throws a helpful error when package.json is unreadable', () => {
  expect(() => createServer({ readPackageJson: () => '' })).toThrow(/Failed to read package.json version/);
});
it('throws when package.json has no version field', () => {
  expect(() => createServer({ readPackageJson: () => '{}' })).toThrow(/Failed to read package.json version/);
});

These are T-bonus (contract §8a T-1 is just the happy-path); they’re added to the describe('createServer', ...) block for full branch coverage.

3b. main() IIFE coverage

The IIFE guarded by invokedAsScript runs only when node dist/server.js is executed, never during Jest. Two options:

  • Option A: add /* istanbul ignore next */ to the if (invokedAsScript) { await main(); } lines. Reduces enforced coverage but matches the reality that this block is integration-tested via the live entry path.
  • Option B: include an integration test that runs spawnSync('node', ['--import', 'tsx', 'src/server.ts']) with a 500ms timeout, asserts stderr contains [colibri] starting or the timeout fires.

Packet decision: Option A. Rationale: the IIFE is 3 lines, the helper functions inside main() (createServer, registerPing, start) are fully covered via T-1..T-23, and an integration test would require real stdio handshake support that Jest can’t provide without a significant harness.

3c. registerPing coverage

registerPing is called only by main() which is istanbul ignore next. To cover registerPing, tests T-12..T-18 register server_ping directly in makeLinkedPair() — that registration exercises the same registration code-path (though not through registerPing() specifically). To cover registerPing itself, export it or add a /* istanbul ignore next */. Packet decision: inline registerPing directly into main() as an IIFE-ish local call (no separate named function) so it disappears from the function-count → no separate function to cover.

Updated main():

async function main(): Promise<void> {
  const ctx = createServer();
  try {
    registerColibriTool(ctx, 'server_ping', {
      title: 'server_ping',
      description: 'Health probe — returns server version, mode, and uptime.',
      inputSchema: z.object({}),
    }, async () => ({
      version: ctx.version,
      mode: ctx.mode,
      uptime_ms: Math.floor(ctx.nowMs() - ctx.bootStartMs),
    }));
    await start(ctx);
  } catch (err) {
    ctx.logger('[colibri] fatal:', err);
    process.exit(1);
  }
}

Whole main() block gets istanbul ignore next.

3d. SchemaValidationError unused-code check

The class is created but our wrappedHandler prefers returning error envelopes over throwing. SchemaValidationError is referenced by type (error instanceof SchemaValidationError) in the catch block — this makes it reachable for coverage. The schema-path in stage 2 constructs it but NEVER throws it — it records via audit and returns envelope. Confirm packet §1g matches this; if a later tweak throws it, coverage is automatic.


§4. Acceptance criteria (mirrors contract §12)

Step 4 is complete when all of the following hold from inside the worktree at E:/AMS/.worktrees/claude/p0-2-1-mcp-server/:

  1. npm run lint exits 0.
  2. npm run build exits 0 with no output beyond tsc’s silent completion.
  3. npm test exits 0 with test count 66 (15 config + 24 modes + 1 smoke + 26 server).
  4. Coverage summary shows src/server.ts at 100% statements / 100% functions / 100% lines / ≥90% branches.
  5. Coverage for src/config.ts + src/modes.ts unchanged.
  6. No file outside the packet §0 allow-list appears in git status.
  7. git diff --stat shows exactly two new files (src/server.ts + src/__tests__/server.test.ts).

§5. Sub-phases within Step 4

No sub-agent dispatch — this is a single-implementer task. Step 4 proceeds LINEARLY:

  1. Author src/server.ts per §1. Save.
  2. Author src/__tests__/server.test.ts per §2. Save.
  3. Run npm run lint — fix any issue before proceeding.
  4. Run npm test — observe coverage + pass count. Fix any issue before proceeding.
  5. Run npm run build — must exit 0.
  6. git add src/server.ts src/__tests__/server.test.ts.
  7. git commit -m "feat(p0-2-1-mcp-server): MCP server bootstrap + 5-stage α chain + server_ping".
  8. Report commit SHA + coverage % + pass count to Step 5 verify pass.

Any Step 4 item that fails the gate HALTS the task: the implementer does NOT amend the commit, does NOT force-push, does NOT scope-creep. Follow-up commits on the same branch are the only allowed recovery path.


§6. Risks (mitigations recap)

  • R-1 package.json resolution under ESM + ts-jest. Mitigation: §1e uses fileURLToPath(import.meta.url) + resolve(..., '..', 'package.json'). Works under both paths because compiled dist/server.js and ts-jest-transformed src/server.ts are both one level below package.json.
  • R-2 SDK registerTool type ergonomics. Mitigation: wrappedHandler as never bridge at the SDK call site (§1g). This is a Type assertion, not any, so lint flags only the one line where we consciously accept the SDK’s generic inference limitations. A narrower cast may be possible but is left as a follow-up.
  • R-3 In-memory transport availability. Confirmed: InMemoryTransport.createLinkedPair() exists (node_modules inspection in audit §3g). No fallback needed.
  • R-4 Coverage threshold for readPackageJson. Mitigation: CreateServerOptions.readPackageJson option lets tests inject faulty readers (§3a).
  • R-5 Global handlers & Jest. Mitigation: CreateServerOptions.installGlobalHandlers default true, tests set it to false.
  • R-6 Tool-lock race on final release. The release() + ctx._toolLocks.get(name) === current check (§1g) prevents a stale entry from lingering when the next caller already set a new current. Added to packet §1g implementation.
  • R-7 CallToolResult shape mismatch. The SDK may validate the content field structure. Packet §1g returns [{ type: 'text', text: JSON.stringify(...) }] which is the documented minimal content array. If the SDK rejects anything, implementer halts and escalates.

§7. Rollback strategy

Single-commit Step 4 means rollback = git reset --hard on the feature branch BEFORE any push. After a hypothetical push, rollback = git revert with a new commit. No force-push, no branch deletion. The 5-step chain (audit + contract + packet + implement + verify) means each step is its own commit; a partial rollback (drop Step 4, keep Steps 1-3 for a later attempt) is possible without touching shared history.


§8. Gate (runs at end of Step 4, before Step 5 begins)

The Step 4 commit is not considered landed until:

  • git log -1 --name-only shows exactly src/server.ts + src/__tests__/server.test.ts + no other paths.
  • npm run lint exit 0 recaptured.
  • npm test exit 0 recaptured with 66 tests passing; coverage for src/server.ts 100/100/100/≥90 recaptured.
  • npm run build exit 0 recaptured.
  • Commit SHA recorded in Step 5 verification doc.

§9. OPEN QUESTIONS for T0 (the packet gate’s reason to exist)

The following design choices require T0 sign-off before Step 4 may begin. Sigma collects the answers via SendMessage and relays “proceed” or “revise” with specific corrections.

Q-1 — Tool-lock stage semantics (contract §2)

Proposed reading: Stage 1 tool-lock is pure per-tool mutex, matching s17 §4 + middleware.md. Capability-gating (admitted tools per mode from modes.md) is a separate concern; in P0.2.1 server_ping is admitted by all four modes so no gating logic is exercised. Capability-gate logic lands in a LATER task (either P0.4.2 γ lifecycle, or P0.2.3 two-phase startup), which fixes modes.md’s wording.

What T0 must decide: accept pure-mutex reading → packet proceeds as written; OR direct capability-gate reading → contract + packet must be amended to add a requires field to ColibriToolConfig and reject at stage 1 with ToolNotAdmittedError. This adds ~30 lines of code and ~3 tests.

Q-2 — AuditSink exit-failure handling (contract §4c + §14 Q-2)

Proposed reading: If auditSink.exit() throws, log via ctx.logger and continue. In P0.2.1 the no-op sink cannot throw, so this is theoretical. P0.7 ζ sink may revisit.

What T0 must decide: accept log-and-continue → packet proceeds; OR direct hard-stop (matches middleware.md Stage 5 verbatim) → wrappedHandler re-throws the sink error, which overrides the original handler error. This is ~5 lines of change.

Q-3 — Version source (contract §14 Q-3 + §1e)

Proposed reading: Read package.json at runtime via readFileSync + path resolution relative to import.meta.url. Pro: single source of truth. Con: one-time filesystem hit.

What T0 must decide: accept runtime read → packet proceeds; OR direct bake-as-const → requires a build step (tsc doesn’t support --define; would need a prebuild script or src/version.ts generated by CI). Non-trivial additional work.

Q-4 — AuditSink interface field names (contract §5c)

Proposed reading: { enter, exit } method names; fields tool, args, timestamp, correlationId, durationMs, result, error.

What T0 must decide: accept as-is → packet proceeds; OR direct rename → P0.7 must honor the rename, which may be slightly painful but not architecturally blocking.

Q-5 — Test file location (contract §10 + audit §2e)

Proposed reading: src/__tests__/server.test.ts (consistent with P0.1.2, P0.1.4, P0.4.1). Overrides task-breakdown text tests/server.test.ts.

What T0 must decide: accept → packet proceeds. Rejecting this would require migrating THREE existing test files + updating jest.config, tsconfig, and ESLint — significant scope addition.

Q-6 — src/index.ts disposition

Proposed reading: Leave as export {}; placeholder. package.json#main and #bin point at dist/server.js; index is vestigial but harmless. Delete as a standalone housekeeping task.

What T0 must decide: accept → packet proceeds; OR delete now (no change to scope; +1 file touched in the commit).

Q-7 — Coverage carve-out for main()

Proposed reading: /* istanbul ignore next */ on the main() block. Rationale: the block is 5 lines, invoked only when node dist/server.js runs, and its constituent calls (createServer, registerColibriTool, start) are each fully unit-tested.

What T0 must decide: accept → 100/100/100/≥90 coverage target holds; OR require integration test that spawnSyncs the script — adds complexity + Jest harness for real stdio.


§10. Open questions ANSWERED (no T0 decision required)

These were surfaced in audit / contract but the packet has enough information to lock them:

  • Tool name server_ping vs server/ping. LOCKED to server_ping per ADR-004 + S17 (authorities) overriding task-breakdown + task-prompt (drafts).
  • InMemoryTransport vs PassThrough streams for tests. LOCKED to InMemoryTransport (confirmed in SDK; createLinkedPair() is the supported pattern).
  • Global handler test pollution. LOCKED: CreateServerOptions.installGlobalHandlers option with false in test harness.
  • Stage 5 audit-exit firing on schema failure. LOCKED per middleware.md: stage 3 does NOT fire on schema failure, but stage 5 DOES (records the error). This matches “exit always runs”.

§11. Files touched (exhaustive)

New files (Step 4):

  • src/server.ts (~400 lines, TypeScript strict mode)
  • src/__tests__/server.test.ts (~600 lines, Jest + ts-jest ESM)

Files touched in packet review phase (this document + preceding):

  • docs/audits/p0-2-1-mcp-server-audit.md (committed a7305e43)
  • docs/contracts/p0-2-1-mcp-server-contract.md (committed 790443c4)
  • docs/packets/p0-2-1-mcp-server-packet.md (this file — to be committed at packet step)

Files NOT touched:

  • package.json, tsconfig.json, jest.config.ts, .eslintrc.json, .env.example, .github/workflows/ci.yml
  • src/config.ts, src/modes.ts, src/index.ts (read-only)
  • Any test file other than the new one

§12. Out-of-scope — recap (matches contract §11)

P0.2.1 does NOT ship:

  • SQLite DB open / migration (P0.2.2)
  • Two-phase startup split (P0.2.3)
  • Additional tools: server_health, server_info, server_shutdown, task_*, thought_*, merkle_*, skill_list (P0.2.4+, P0.3+, P0.6+, P0.7+, P0.8+). R75 Wave H update: server_info was struck and server_shutdown deferred; the Phase 0 shipped surface is 14 tools, not 19.
  • src/middleware/*.ts file split (P0.2.4)
  • Real ζ AuditSink implementation (P0.7)
  • HTTP / WebSocket transport (NEVER in Phase 0 per s17 §2)
  • δ multi-model routing (Phase 1.5 per ADR-005)
  • Rate limiting / ACL / circuit breaker / retry (per middleware.md appendix)
  • Capability-gating at tool-lock (pending Q-1 T0 decision)

P0.2.1 Step 3 Packet — 2026-04-16 — branch feature/p0-2-1-mcp-server. Audit: a7305e43. Contract: 790443c4. HALT: awaiting T0 review via Sigma before Step 4.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.