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
countTablesandbuildHealthPayloadare exported so tests can assert branches directly without going through the SDK envelope. countTableshas three branches:db === undefined, query succeeds (withrow.cbeing number vs. undefined), andtry/catch.buildHealthPayloadhas 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 fromserver.test.tsL115-177) — linked-pair InMemoryTransport harness.makeRecordingSink(fromserver.test.tsL61-78) — audit event recorder.makeTempDbPath(fromdb-init.test.tsL37-41) — real SQLite for E2E.better-sqlite3new Database(':memory:')— for countTables unit tests.
5.7. Setup / teardown
beforeEach: nothing module-level (pure factory style).afterEach:__resetForTests()fromstartup.tsif a test calledstartup(); otherwise no-op. Close any in-memory DBs explicitly in atry/finallyblock 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:
src/tools/health.ts(new file) — pure module, zero dependencies beyondzod,../modes.js,../server.js.src/server.ts(modified) — 3 localised edits (import, type, bootstrap).src/startup.ts(modified) — 1 localised edit (Phase 2 body).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.