P0.2.4 — Health Check Tool — Packet

1. Commit plan

Five commits on feature/p0-2-4-health:

# Commit Files Rationale
1 audit(p0-2-4-health): inventory surface docs/audits/p0-2-4-health-audit.md Step 1 landed.
2 contract(p0-2-4-health): behavioral contract docs/contracts/p0-2-4-health-contract.md Step 2 landed.
3 packet(p0-2-4-health): execution plan docs/packets/p0-2-4-health-packet.md This file.
4 feat(p0-2-4-health): α server_health tool — uptime + db_tables + phase + mode src/tools/health.ts, src/server.ts, src/startup.ts, src/__tests__/tools/health.test.ts One feat commit for the module + wiring + tests — logically indivisible.
5 verify(p0-2-4-health): test evidence docs/verification/p0-2-4-health-verification.md Step 5 lands after npm test / npm run lint / npm run build all green.

2. src/tools/health.ts — skeleton

/**
 * Colibri — Phase 0 server_health MCP tool (α System Core).
 *
 * Single-tool module. Exports `registerHealthTool(ctx)` which registers
 * `server_health` with the P0.2.1 5-stage α middleware chain. The tool
 * returns a 6-field snapshot of runtime state: status, version, uptime_ms,
 * db_tables, phase, mode.
 *
 * Canonical references:
 *   - docs/guides/implementation/task-breakdown.md § P0.2.4
 *   - docs/audits/p0-2-4-health-audit.md
 *   - docs/contracts/p0-2-4-health-contract.md
 *   - docs/packets/p0-2-4-health-packet.md
 *
 * Consumed by: bootstrap() in src/server.ts, which calls registerHealthTool
 * after registering server_ping and before start().
 */

import { z } from 'zod';

import { RUNTIME_MODES } from '../modes.js';
import { registerColibriTool, type ColibriServerContext } from '../server.js';

/** SQL query — counts user tables, excluding SQLite internals. */
const COUNT_TABLES_SQL =
  "SELECT COUNT(*) AS c FROM sqlite_master " +
  "WHERE type = 'table' AND name NOT LIKE 'sqlite_%'";

/** Zod input schema — empty object. */
const inputSchema = z.object({});

/** Zod output schema — pinned to the 6-field envelope. */
const outputSchema = z.object({
  status: z.literal('ok'),
  version: z.string().min(1),
  uptime_ms: z.number().int().nonnegative(),
  db_tables: z.number().int().nonnegative(),
  phase: z.enum(['phase1', 'phase2']),
  mode: z.enum(RUNTIME_MODES),
});

/**
 * Count user tables in the DB. Returns 0 if the DB is undefined or the
 * query throws (closed handle, locked WAL, etc.). Never throws.
 */
export function countTables(db: ColibriServerContext['db']): number {
  if (db === undefined) {
    return 0;
  }
  try {
    const row = db.prepare(COUNT_TABLES_SQL).get() as { c?: number } | undefined;
    return typeof row?.c === 'number' ? row.c : 0;
  } catch {
    return 0;
  }
}

/** Build the health payload from the current ctx state. Pure. */
export function buildHealthPayload(
  ctx: ColibriServerContext,
): z.infer<typeof outputSchema> {
  return {
    status: 'ok',
    version: ctx.version,
    uptime_ms: Math.floor(ctx.nowMs() - ctx.bootStartMs),
    db_tables: countTables(ctx.db),
    phase: ctx.phase ?? 'phase1',
    mode: ctx.mode,
  };
}

/**
 * Register `server_health` with the 5-stage α middleware chain. Must be
 * called before `start(ctx)`.
 */
export function registerHealthTool(ctx: ColibriServerContext): void {
  registerColibriTool(
    ctx,
    'server_health',
    {
      title: 'server_health',
      description:
        'Runtime health probe — returns status, version, uptime_ms, db_tables, phase, mode.',
      inputSchema,
      outputSchema,
    },
    () => buildHealthPayload(ctx),
  );
}

Notes:

  • Both countTables and buildHealthPayload are exported so tests can assert branches directly without going through the SDK envelope.
  • countTables has three branches: db === undefined, query succeeds (with row.c being number vs. undefined), and try/catch.
  • buildHealthPayload has one branch: ctx.phase ?? 'phase1'.
  • No top-level side effects. No dynamic imports.

3. src/server.ts — diff

3.1. Add import (top of file, with other imports)

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

3.2. Extend ColibriServerContext

Add two optional mutable fields at the end of the interface body:

   /** @internal true once global handlers installed — idempotence guard. */
   _globalHandlersInstalled: boolean;
+
+  /** P0.2.4: SQLite handle, populated by startup.ts Phase 2. */
+  db?: Database.Database;
+
+  /** P0.2.4: current startup phase ("phase1" transport-only, "phase2" DB-live). */
+  phase?: 'phase1' | 'phase2';
 }

Plus the Database type import at the top:

-import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
+import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
+import type Database from 'better-sqlite3';

3.3. Extend bootstrap()

 export async function bootstrap(
   options: BootstrapOptions = {},
 ): Promise<ColibriServerContext> {
   const exit = options.exit ?? process.exit.bind(process);
   const ctx = createServer(options.createOptions ?? {});
   try {
+    ctx.phase = 'phase1';
     registerColibriTool(
       ctx,
       'server_ping',
       { ... },
       (): { ... } => ({ ... }),
     );
+    registerHealthTool(ctx);
     await start(ctx);
     return ctx;
   } catch (err) {

The ctx.phase = 'phase1' assignment is before registerColibriTool so that a hypothetical stage-3 audit hook (future P0.7 wiring) could observe the phase during registration. In practice, registerColibriTool is synchronous and does not call any sink — the ordering is belt-and- braces.

4. src/startup.ts — diff

4.1. Phase 2 body

   try {
     const db = initDbFn(dbPath);
+    ctx.db = db;
+    ctx.phase = 'phase2';
     const elapsedMs = Math.floor(nowMs() - phase1StartMs);
     logger(`[Startup] Complete in ${elapsedMs}ms`);
     return { ctx, db, elapsedMs };
   } catch (err) {

No other edits to startup.ts. Existing tests continue to pass because the two new writes happen to an object they already pass around; the assertions that bootstrap produces a well-formed ctx remain valid.

5. src/__tests__/tools/health.test.ts — test matrix

Target ≥ 12 tests covering every branch. The plan:

5.1. Unit tests (direct handler invocation, no SDK)

# Name Branch
1 buildHealthPayload returns status:ok success envelope
2 buildHealthPayload returns version from ctx version passthrough
3 buildHealthPayload returns mode from ctx mode passthrough
4 buildHealthPayload returns uptime_ms as Math.floor(nowMs - bootStartMs) uptime math
5 buildHealthPayload returns phase="phase1" when ctx.phase undefined phase default branch
6 buildHealthPayload returns phase="phase1" when ctx.phase = "phase1" phase pass-through (phase1)
7 buildHealthPayload returns phase="phase2" when ctx.phase = "phase2" phase pass-through (phase2)
8 buildHealthPayload returns db_tables=0 when ctx.db undefined countTables undefined branch
9 countTables returns N matching in-memory DB table count countTables query-success branch
10 countTables excludes sqlite_* internal tables query filter evidence
11 countTables returns 0 when db.prepare throws countTables catch branch
12 countTables returns 0 when row.c is not a number defensive cast branch

5.2. Integration tests (full SDK chain via InMemoryTransport)

# Name Branch
13 server_health returns { ok: true, data: HealthPayload } on empty input envelope
14 server_health emits stage-3 enter + stage-5 exit events observability
15 server_health responds in < 100ms SLA
16 server_health rejects non-object args with INVALID_PARAMS stage-2 rejection

5.3. End-to-end via bootstrap

# Name Branch
17 bootstrap registers server_health alongside server_ping registration path
18 bootstrap sets ctx.phase = "phase1" before start phase init
19 server_health returns phase="phase1" after bootstrap() only post-bootstrap state

5.4. Startup Phase 2 integration

# Name Branch
20 startup Phase 2 sets ctx.db and ctx.phase="phase2" startup seam
21 startup Phase 2 failure leaves ctx.phase="phase1" and ctx.db undefined failure preserves Phase 1 state
22 server_health returns db_tables = migration-created count after Phase 2 db_tables E2E

5.5. Mode matrix

# Name Branch
23 server_health returns mode="READONLY" when ctx.mode="READONLY" mode enum — READONLY
24 server_health returns mode="TEST" when ctx.mode="TEST" mode enum — TEST
25 server_health returns mode="MINIMAL" when ctx.mode="MINIMAL" mode enum — MINIMAL
26 server_health returns mode="FULL" when ctx.mode="FULL" mode enum — FULL

Total: 26 tests. Branch coverage target: 100% on src/tools/health.ts. Server + startup edits are covered by their existing test suites plus tests 17-22 here.

5.6. Fixtures

Reuse the established patterns:

  • makeLinkedPair (adapted from server.test.ts L115-177) — linked-pair InMemoryTransport harness.
  • makeRecordingSink (from server.test.ts L61-78) — audit event recorder.
  • makeTempDbPath (from db-init.test.ts L37-41) — real SQLite for E2E.
  • better-sqlite3 new Database(':memory:') — for countTables unit tests.

5.7. Setup / teardown

  • beforeEach: nothing module-level (pure factory style).
  • afterEach: __resetForTests() from startup.ts if a test called startup(); otherwise no-op. Close any in-memory DBs explicitly in a try/finally block per test.

6. Risks and mitigations

# Risk Likelihood Impact Mitigation
R-1 Rebase conflict on src/server.ts with P0.6.2 / P0.7.2 Medium Low P0.2.4 edits are: (a) 1-line import add, (b) 4-line context field append, (c) 2-line bootstrap change. All within bootstrap() after server_ping registration. Parallel tasks are unlikely to touch the same block. Rebase is trivial.
R-2 Database type import adds a runtime dep where there was none None None import type is elided at compile time. No runtime cost.
R-3 Optional mutable fields break TS strict checks Low Low Testing under tsconfig.json strict mode. Any resulting error surfaces at build, not runtime.
R-4 better-sqlite3 in-memory DB fails on Windows path quirks Low Medium :memory: is special — no path resolution. All tests use either :memory: or os.tmpdir() (proven pattern from db-init.test.ts).
R-5 Response time test is flaky on CI (slow worker) Medium Low Pin ceiling at 100 ms (spec). On typical CI this runs in 1-10 ms with wide margin. If a worker stalls briefly, we retry once — Jest jest.retryTimes(1) for that single test if needed. Plan: ship without retry first; if flakes appear on a CI run, add the retry.
R-6 Cross-worktree leak (Wave C P0.7.1 saw one) Low High Pre-clean at Step 1 confirmed clean. Every git status between commits confirms no leak introduction.
R-7 startup.ts Phase 2 edit lands in a file touched by no other Wave D task None None No other Wave D task (P0.3.2, P0.6.2, P0.7.2) edits startup.ts. Confirmed by re-reading the task-breakdown spec.
R-8 bootstrap() ctx.phase = 'phase1' assignment surfaces as a TS error because the field starts as optional Low Low Writing to an optional field is valid — the write narrows the runtime type but the TS type stays string \| undefined. No issue.

7. Build / test commands

# Pre-flight (verify clean state after Steps 1-3 land):
git status

# Install deps (cached if unchanged):
npm ci

# Run all tests + coverage:
npm test -- --coverage

# Lint:
npm run lint

# Type-check + emit:
npm run build

All four must pass. Coverage gate: 100% stmt / 100% branch / 100% func / 100% line on src/tools/health.ts. Existing files stay at their existing coverage (server.ts 98.2% stmt / 92.6% branch — 2 uncovered are IIFE subprocess guards, unchanged by P0.2.4).

8. Deviation log

Per Sigma lock for Wave D:

# Deviation from task spec Reason
1 src/__tests__/tools/health.test.ts (not tests/tools/health.test.ts) Repo convention since Wave A; mirrors src/__tests__/ for all prior P0 tests.
2 Tool name server_health (not server/health) TOOL_NAME_RE = /^[a-z_][a-z0-9_]*$/ in src/server.ts:261 rejects slashes. Snake_case is the Phase 0 locked convention (server_ping precedent).
3 Registration in bootstrap() after server_ping Only integration point in P0.2.1’s scaffolding. Adding it elsewhere would require either dynamic registration post-connect (MCP SDK forbids) or a separate bootstrap path (out of scope).
4 ColibriServerContext.db? field Phase 0 had no DB-plumbing seam; this field is the minimum surface to make the handle reachable without a module-level singleton dependency on getDb().
5 ColibriServerContext.phase? field Same rationale — phase state was not represented anywhere in the ctx before P0.2.4.

No other deviations from the contract.

9. Handoff to Step 4

Step 4 creates the four new/modified files in this order:

  1. src/tools/health.ts (new file) — pure module, zero dependencies beyond zod, ../modes.js, ../server.js.
  2. src/server.ts (modified) — 3 localised edits (import, type, bootstrap).
  3. src/startup.ts (modified) — 1 localised edit (Phase 2 body).
  4. src/__tests__/tools/health.test.ts (new file) — 26 tests per §5.

After Step 4, Step 5 runs the gate commands from §7 and records the evidence in docs/verification/p0-2-4-health-verification.md.


Back to top

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

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