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:
- T0 approves the open questions in §9.
- 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:
randomUUIDfromnode:cryptogenerates thecorrelationId(stage 3) — zero deps, ambient.performance.now()fromnode:perf_hooksis monotonic (contract §5a). Required becauseDate.now()can jump backward on clock sync.readFileSyncused once at boot to readpackage.json#version— synchronous is fine because it runs insidecreateServer()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')locatespackage.jsonrelative to the compileddist/server.jsOR the ts-jest-transformedsrc/server.ts. Both resolve to the same real path (parent ofdist/orsrc/).- SDK subpaths are
.jsbecause the packageexportsmap routes.jssuffixes to the right ESM build under NodeNext. type Transportis 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). installGlobalHandlerslives on options but is only READ instart(), 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 intocontent[0].text(stringified JSON — for clients that only readcontent) AND intostructuredContent(for clients that support structured results). InvalidToolNameErroris 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: truewire semantics are hit. Stage 3 is bypassed (noenterevent); stage 5’sexitis 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, notresult. runWithToolLockuses a localreleaseclosure and a per-callcurrentPromise — 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:
AuditSink(interface) — §1dToolEnterEvent(interface) — §1dToolExitEvent(interface) — §1dColibriToolConfig(interface) — §1dColibriServerContext(interface) — §1dCreateServerOptions(interface) — §1d (added via contract §13 R-4+R-5; contract §1a listed 5 but via packet expansion to cover risks)createServer(function) — §1eregisterColibriTool(function) — §1gstart(function) — §1hstop(function) — §1icreateNoOpAuditSink(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
detectModefailure (T-26), usesspawnSync+tsxsubprocess (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
describe('createServer', ...)— T-1 through T-7 (contract §8a).describe('registerColibriTool', ...)— T-8 through T-11 (contract §8b).describe('middleware chain (InMemoryTransport)', ...)— T-12 through T-18 (contract §8c).describe('start / stop', ...)— T-19 through T-23 (contract §8d).describe('donor-bug regressions', ...)— T-24 through T-26 (contract §8e).describe('AuditSink interface', ...)— light sanity checks oncreateNoOpAuditSink(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 }> }. Everyenterandexitpush intoevents. 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 indescribe 3uses 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: falseprevents 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()readspackage.json): readpackage.jsonfrom the worktree, assertversionmatches. - T-6 (
createServer()with env-missingCOLIBRI_*should still work because config is already loaded): callscreateServer({ mode: 'FULL' })which bypassesdetectModeandconfigstays 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 byrecording. - T-17 concurrency:
await Promise.all([client.callTool(...), client.callTool(...)]); inspecteventsfor 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 oneexitevent witherrorset and zeroenterevents. - T-19
start()resolves: callstart()with the linked-pair server transport, assert it resolves within the defaultstartupTimeoutMs(30000ms — tests use a shorter override). - T-20
start()timeout:createServer({ transport: new PassThroughTransport() /* never resolves */, startupTimeoutMs: 50 }); assertstart()rejects with/startup timeout/. - T-21 global handlers installed: count
process.listenerCount('unhandledRejection')before and after astart()that hasinstallGlobalHandlers: true. - T-22
start()idempotence: callstart()then call it again on the same ctx; listener count does not increase. - T-24
process.stdout.writeuntouched: captureconst before = process.stdout.write.bind(process.stdout); callcreateServer(); assertprocess.stdout.write === before. - T-25 no HTTP / WebSocket imports: read
src/server.tssource viareadFileSync; assert/from '@modelcontextprotocol\/sdk\/server\/(sse|streamableHttp|websocket|express)'/does not match. - T-26
detectModethrows onAMS_MODE:spawnSyncsubprocess per the P0.1.4 pattern — atsxrun ofimport('<...>/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' }; assertstatus !== 0andstderrmatches/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%; themain()guard IIFE and thefileURLToPath(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 theif (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] startingor 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/:
npm run lintexits 0.npm run buildexits 0 with no output beyondtsc’s silent completion.npm testexits 0 with test count 66 (15 config + 24 modes + 1 smoke + 26 server).- Coverage summary shows
src/server.tsat 100% statements / 100% functions / 100% lines / ≥90% branches. - Coverage for
src/config.ts+src/modes.tsunchanged. - No file outside the packet §0 allow-list appears in
git status. git diff --statshows 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:
- Author
src/server.tsper §1. Save. - Author
src/__tests__/server.test.tsper §2. Save. - Run
npm run lint— fix any issue before proceeding. - Run
npm test— observe coverage + pass count. Fix any issue before proceeding. - Run
npm run build— must exit 0. git add src/server.ts src/__tests__/server.test.ts.git commit -m "feat(p0-2-1-mcp-server): MCP server bootstrap + 5-stage α chain + server_ping".- 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 compileddist/server.jsand ts-jest-transformedsrc/server.tsare both one level belowpackage.json. - R-2 SDK
registerTooltype ergonomics. Mitigation:wrappedHandler as neverbridge at the SDK call site (§1g). This is a Type assertion, notany, 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.readPackageJsonoption lets tests inject faulty readers (§3a). - R-5 Global handlers & Jest. Mitigation:
CreateServerOptions.installGlobalHandlersdefaulttrue, tests set it tofalse. - R-6 Tool-lock race on final release. The
release()+ctx._toolLocks.get(name) === currentcheck (§1g) prevents a stale entry from lingering when the next caller already set a newcurrent. Added to packet §1g implementation. - R-7 CallToolResult shape mismatch. The SDK may validate the
contentfield 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-onlyshows exactlysrc/server.ts+src/__tests__/server.test.ts+ no other paths.npm run lintexit 0 recaptured.npm testexit 0 recaptured with 66 tests passing; coverage forsrc/server.ts100/100/100/≥90 recaptured.npm run buildexit 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_pingvsserver/ping. LOCKED toserver_pingper 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.installGlobalHandlersoption withfalsein 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(committeda7305e43)docs/contracts/p0-2-1-mcp-server-contract.md(committed790443c4)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.ymlsrc/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_infowas struck andserver_shutdowndeferred; the Phase 0 shipped surface is 14 tools, not 19. src/middleware/*.tsfile split (P0.2.4)- Real ζ
AuditSinkimplementation (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.