Server startup and deferred loading
The default stdio server keeps an unused session small by postponing the MCP protocol library and tool implementation imports in its long lived process. Initialization and discovery work before a tool is called. The first request that needs the full SDK loads the normal server and continues the same session. Tool names, input schemas and results keep their existing contracts.
Startup and first use
python -m hooks.mcp uses stdio unless MCP_TRANSPORT selects a network mode. Its startup path has three stages:
- Generate the tool catalogue in a short lived child process using the current Python interpreter and installed source. The child builds each category from its real registrations and serializes its schemas, then exits. Its tool allowlist is cleared so the catalogue contains every registered tool.
- In the parent, apply the configured category and tool filters. Answer
initialize,notifications/initialized,ping,tools/list,resources/list,resources/templates/listandprompts/listwithout retaining the SDK or hook dispatcher imports. The resource and prompt lists are empty. Discovery list requests follow initialization. - On another request, including
tools/call, load the SDK and build the full server. Replay the handshake and triggering request into it, suppress the duplicate initialization response, and continue reading the same connection. Later requests stay on this full server path.
This defers the full server as a whole; it does not promise a separate import boundary for every individual tool. Request parsing and error handling also fall through to the SDK when the lightweight path cannot handle an input. A client can therefore trigger loading without a successful tool call.
The catalogue is cached within a process. A new server process generates its own catalogue. Startup still has a temporary child process memory and CPU cost; the saving is in the unused parent’s retained imports. A fixed resident memory size is not guaranteed. Once the SDK loads, it remains loaded for that process.
Configuration examples
No new setting enables deferred loading: it is automatic for stdio. The same filters apply to discovery and to the full server after handoff.
| Variable | Default | Effect |
|---|---|---|
MCP_TRANSPORT | stdio | Deferred parent loading applies to stdio. sse and streamable-http build the normal server immediately. |
MCP_CATEGORIES | all | Comma separated categories: channels, conditions, enforcement, scheduler. |
ALLOWED_TOOLS | unset | Comma separated tool names within the selected categories. An unset or blank value permits all selected tools; matching ignores case. |
Run a channels only stdio server:
MCP_TRANSPORT=stdio MCP_CATEGORIES=channels python -m hooks.mcp
Restrict that server to one read operation:
MCP_TRANSPORT=stdio MCP_CATEGORIES=channels \
ALLOWED_TOOLS=channel_list python -m hooks.mcp
These commands wait for MCP input on stdin. In normal use, the harness starts and connects to the server registered by agentihooks init. Put per session stdio filters in the harness’s server environment configuration and reconnect or restart that server to apply them. Changing the environment after discovery does not update an already running connection’s advertised catalogue. For shared network setup and restart commands, see MCP Transport. Network mode shares one configured category set among its clients and does not use this deferred stdio path.
Protocol usage
An MCP client sends one JSON object per line. This exchange initializes the connection, lists tools and calls the read only channel_list tool:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"startup-example","version":"1.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"channel_list","arguments":{}}}
Keep stdin open while reading responses. Initialization and listing produce responses for request ids 1 and 2; the notification has no response. The call at id 3 enters the full SDK path and returns the tool’s normal result, without an extra response for id 1. Listing alone advertises tools without calling their implementations. The tools available depend on the configured filters; use the returned schemas for calls rather than assuming a fixed tool count.
Limits and troubleshooting
| Observation | Check or action |
|---|---|
| Startup exits before initialization | Read stderr. Catalogue generation must complete successfully within its thirty second timeout; a dependency, registration or child process failure prevents startup. Use the intended installed Python environment. |
| Some tools are absent | Check MCP_CATEGORIES and ALLOWED_TOOLS together. An allowlist cannot add tools from an excluded category. Reconnect after changing configuration. |
| No tools are advertised | Unknown categories warn on stderr and are skipped. A selection producing no tools emits a zero tools warning; correct the categories or allowlist. |
| Memory rises on the first call | Expected: the full SDK and tool registrations now load. The optimization targets processes that remain on initialization and discovery. |
| Memory rises before a tool call | Check the client’s request sequence. Unsupported lightweight requests and malformed inputs can enter the SDK path; catalogue generation also uses a temporary child. |
| A network server loads the SDK at startup | Expected for sse and streamable-http; compare equivalent transports when investigating memory. |
Measured idle memory
The Doctor’s read only measurement after deferred startup was applied found mean private resident memory of 7.75 MiB per unused tool server, compared with 37.86 MiB before, a reduction of about 79.5%. These are historical process cohorts, not a fixed memory budget for a session.
The original investigation on 18 October 2026 at 06:46:56 UTC counted 29 tool server processes: 1144.12 MiB resident (RSS), 917.65 MiB proportional (PSS), and 310.21 MiB swap in total. Fresh processes held about 43 MiB private resident. Those fleet totals describe the initial problem; the comparison below uses the filtered processes with no matched AgentiHooks tool call.
| Census time (UTC) | Eligible processes | Mean private resident |
|---|---|---|
| 18 October 2026, 15:04:34 | 16 started after 06:46:56 that day, no matched call | 37.86 MiB |
| 19 October 2026, 07:32:10 | 17 started after application, no matched call | 7.75 MiB |
The master recorded application when the main checkout first carried the change, at 19 October 2026, 05:25:38 UTC. The after census occurred over two hours later. Of its 27 processes, eight started before application and two newer processes had matched calls; both groups were excluded from the idle mean. The 17 included processes all started after application. A merge time alone does not establish when new local processes began using the change.
Reading the measurement
Private resident memory is (Private_Clean + Private_Dirty) / 1024 MiB from Linux smaps_rollup. RSS includes shared resident pages; PSS apportions shared pages; swap is reported separately. Resident memory can fall when pages move to swap, so retain swap alongside resident metrics when comparing runs. This observation does not establish a reduction in total session memory or whole-host swap.
The archived memory_census.py selected Python processes running hooks.mcp as a module or import bootstrap, read memory and inferred owners from process ancestry. after_filter.py joined live process start times to that census and searched retained Claude and Codex profile-home transcripts for tool calls. Processes that exited before filtering were omitted: the before census had 17 processes, while its filtered result retained 16.
Here, “no matched call” means no call was associated with the inferred owner in the transcripts the filter inspected. Unknown owners remained eligible; this is not proof that every included process never received a call. A request that falls through to the SDK can also load the backend without a successful tool call. Preserve request history and owner coverage when interpreting a new census. The current catalogue child adds temporary startup memory, while the reported saving concerns the retained parent process.
Read only inspection
For a known tool server PID on Linux, read its start time and memory without calling a tool or restarting the process. Replace 12345 with that PID:
tool_pid=12345
date -u '+%Y-%m-%dT%H:%M:%SZ'
TZ=UTC ps -p "$tool_pid" -o pid=,lstart=
awk '/^(Rss|Pss|Private_Clean|Private_Dirty|Swap):/ { print }' \
"/proc/$tool_pid/smaps_rollup"
awk '/^Private_(Clean|Dirty):/ { private_kib += $2 }
END { printf "private_resident_mib=%.2f\n", private_kib / 1024 }' \
"/proc/$tool_pid/smaps_rollup"
If the process exits or permissions deny access, record the missing reading instead of treating it as zero. Keep the census time, process count, transport, configured filters, process start times, owner matches and call history with the results. For a repeat of the delivery measurement, record the application time, wait at least one hour, and compare only processes started after it with no observed call. Use the same selection and metric before and after; keep older processes and callers separate. Discovery alone stays lightweight, but a diagnostic tools/call moves the measured process to the full server path.
The source measurement scripts are incident evidence, not installed CLI commands. The measurement record preserves their method, cohort counts, application boundary and limitations. The original investigation records the fleet census and import costs. Configuration and first-use examples above describe how to use deferred startup; changing category filters is not required to enable it.
Delivery evidence
Pull request 4431 introduced deferred stdio startup. Its red protocol run observed the hook dispatcher imported after startup and listing. The green protocol run passed all eighteen protocol cases, including calls to the thirteen tools registered at that delivery. Current categories can contain more tools; the catalogue is generated from their registrations.