GitHub MCP Server
amtp-github-mcp is the first
implemented tool of the
MCP Abstraction Layer. It
exposes two MCP tools
— repo.tree and repo.read_file —
over the Streamable HTTP transport, authenticates as a
GitHub App, caches in
Valkey, and emits structured OpenTelemetry traces and
metrics. Stage 1
of the AMTP pipeline (Repo Crawler) is its sole consumer.
Overview #
| Property | Value |
|---|---|
| Source path | apps/github-mcp/ |
| Compose service name | github-mcp |
| MCP server name / version |
amtp-github-mcp · 1.0.0
|
| Transport |
Streamable HTTP —
@modelcontextprotocol/sdk ^1.10.2
|
| Host port / container port | 8090 / 8090 |
| Tools exposed | repo.tree, repo.read_file |
| Auth strategy |
GitHub App —
@octokit/auth-app; PEM mounted at
/run/secrets/github_app_key
|
| Cache backend |
Valkey 8.0 (shared service); ioredis singleton with
lazyConnect
|
| Concurrency cap |
p-limit(10) across every Octokit call
|
| Health endpoint |
GET /healthz (alias /health);
asymmetric memo (1 s success, 0 s failure)
|
| Container image |
Built from apps/github-mcp/Dockerfile; runs as
unprivileged node user
|
| OTel service name | amtp-github-mcp |
The server is stateless at the MCP layer: one
McpServer instance and one
StreamableHTTPServerTransport are constructed
per HTTP request, then disposed when the response closes.
Per-request isolation is mandatory for the Streamable HTTP transport
— see
Stateless MCP Transport Contract.
Service Topology #
The diagram below shows the runtime data paths. All inbound MCP
traffic terminates at port 8090 on the
amtp_net bridge; outbound traffic fans out to GitHub
(Octokit), Valkey (ioredis), and the OTel Collector (OTLP HTTP).
(apps/github-mcp)
tools/repo-tree.ts —
repo.tree
tools/repo-read-file.ts —
repo.read_file
Tool: repo.tree
#
Returns the filtered file tree for a GitHub repository subtree at a given ref. Source: apps/github-mcp/src/tools/repo-tree.ts.
Input schema #
| Field | Type | Constraints | Default |
|---|---|---|---|
repo |
string |
Trimmed, lower-cased, max 140 chars; pattern
^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$
|
— |
ref |
string |
1–255 chars; colon prohibited (would collide with
Trees-API {sha}:{path} separator)
|
— |
path |
string |
Max 4096 chars; backslashes prohibited; no
. / .. segments
|
"" (repo root) |
recursive |
boolean | — | true |
ignore_patterns |
string[] |
Max 100 patterns; each ≤ 1024 chars; gitignore syntax via
ignore npm package
|
[] |
force |
boolean |
When true, the size gate (L3)
is bypassed. Layers 1 and 2 are unaffected.
|
false |
Validation is performed by
src/lib/schema.ts § RepoTreeInputSchema using Zod.
force=true opts files larger than the 200 KB size
threshold back into the tree but does not negate
platform or gitignore exclusions.
Output schema #
interface RepoTreeOutput {
repo: string;
ref: string;
resolved_sha: string; // commit SHA the ref pointed to at call time
path: string; // normalized subtree root
file_tree: FileTreeEntry[]; // kept blobs (sorted by code-point order)
excluded: ExcludedEntry[]; // dropped blobs (sorted by code-point order)
truncated: boolean; // forwarded from Git Trees API
cache_hit: boolean;
}
interface FileTreeEntry {
path: string; // repo-rooted path
size: number; // blob byte size (0 for unknown)
sha: string; // 40-char Git blob SHA
}
interface ExcludedEntry {
path: string;
reason: "gitignore" | "platform" | "size" | "user";
size?: number;
pattern?: string; // populated for gitignore matches
}
Four-layer filter pipeline #
Every blob in the Trees-API response passes through four layers in a
fixed order. The first layer to exclude a blob terminates evaluation;
the matched layer is recorded in
ExcludedEntry.reason.
.git/**, node_modules/**,
*.png …
“platform”
prefixes("a/b/c") · in-subtree
.gitignore files ·
test() preserves !negation
“gitignore”
ignore_patterns[]
“user”
force=true
“size”
Deterministic code-point sort #
Both file_tree and excluded are sorted by
path using an allocation-free code-point comparator (pipeline.ts § compareCodePoints).
The comparator deliberately does not use
Intl.Collator — that conflates case (README.md
≡ readme.md), which is wrong for case-sensitive
filesystems. Spread-based variants ([...a]) allocate two
arrays per comparison; on a 100 k-entry sort that is ~3.2 M
short-lived arrays triggering GC storms.
Cache key #
amtp:mcp:tree:{repo}:{resolved_sha}:{sha256(canonical_args)}
canonical_args = JSON.stringify({
norm_path, // post-normalizePath()
recursive,
ignore_patterns, // exact array (order preserved)
force,
})
Event-loop yielding #
The pipeline yields to the event loop every
FILTER_YIELD_INTERVAL entries (default 5000;
clamped to [100, 50000]) so MCP lifecycle messages,
OTel heartbeats, and /healthz probes stay responsive
during 100 k-entry tree traversals. The peak heap is sampled at
every yield and emitted on the span as
tree.peak_memory_bytes.
Tool: repo.read_file
#
Returns the UTF-8 content of a single file from a GitHub repository,
truncated to max_bytes. Source:
apps/github-mcp/src/tools/repo-read-file.ts +
src/lib/read-file-fetch.ts +
src/lib/binary-detect.ts.
Input schema #
| Field | Type | Constraints | Default |
|---|---|---|---|
repo |
string | Same as repo.tree |
— |
ref |
string | Same as repo.tree |
— |
path |
string |
Required; 1–4096 chars; backslashes and
./.. segments prohibited
|
— |
max_bytes |
integer | 1 ≤ n ≤ 1_048_576 (1 MiB cap) |
65_536 (64 KiB) |
Output schema #
interface RepoReadFileOutput {
content: string; // UTF-8 decoded; never includes a partial trailing rune
truncated: boolean; // true when total file size exceeded max_bytes
total_bytes: number; // -1 sentinel when the metadata fallback failed
}
Streaming strategy #
-
Issue
GET /repos/{owner}/{repo}/contents/{encodedPath}withAccept: application/vnd.github.raw, anAbortControllersignal,parseSuccessResponseBody: false, and a customrequest.fetchthat pass-throughs toglobalThis.fetch— bypassing Octokit's eager ArrayBuffer materialization while preserving the auth header injected upstream. -
Read the body as an async iterable of
Uint8Arraychunks. TrackbytesRead; once it exceedsmax_bytes, settruncated=true,controller.abort(), andbreakthefor await— which disposes the reader. -
Slice the buffer to exactly
max_bytesbefore handing it todetectBinary. Reading one byte pastmax_bytesdistinguishes “file fits” from “file is truncated”; the slice restores the contract that the binary detector sees no padding.
total_bytes resolution
#
-
Complete read —
total_bytes = bytesRead(the whole file fit withinmax_bytes). -
Truncated read — issue an unconditional
second request to the JSON Contents API
(
application/vnd.github+json) and readdata.size. Path-is-directory or any error returns-1; themcp_read_file_meta_fallback_failures_totalcounter is incremented and aread_file_metadata_fallback_failedwarn-log is emitted. Files larger than 1 MiB return403/422on the JSON endpoint — the-1sentinel is the documented contract here.
Binary detection & UTF-8 trim #
Implemented in src/lib/binary-detect.ts:
-
Step 0 — empty buffer short-circuits to
{ isBinary: false, decoded: "" }. -
Step 1 — NUL-byte scan within the first
8192bytes. Any0x00in that window is sufficient evidence to reject as binary. -
Step 2 — UTF-8 trailing-tail trim (only when
truncated=true). Walks the final 1–4 bytes and strips an incomplete leader using a bitmask classification:0xxxxxxx(ASCII) → complete; no trim.-
10xxxxxx→ continuation byte; keep scanning back. -
110xxxxxneeds 1 continuation;1110xxxxneeds 2;11110xxxneeds 3. - If continuations present < required, strip the incomplete tail.
-
Step 3 — Strict
TextDecoder(fatal: true,ignoreBOM: false). Success returns{ isBinary: false, decoded }. - Step 4 — Single-byte retry safety net up to three strips. Catches edge cases the bitmask missed (e.g. malformed surrogate halves). If still failing, classify as binary.
Binary rejections raise BinaryFileError carrying
total_bytes and the first 4 bytes as
magic_hex for diagnostics.
Error contract #
On failure the MCP response sets isError: true and the
text content is a JSON-encoded
{ error: { code, message, … } } object. Codes:
| Code | Mapped from | Notes |
|---|---|---|
INVALID_INPUT |
Zod ZodError |
Schema validation failure |
NOT_FOUND |
HTTP 404 from GitHub | Repo, ref, or path missing |
RATE_LIMITED |
HTTP 403 | Throttling plugin already retried up to 3× on primary RL before bubbling |
NOT_A_FILE |
HTTP 422 | Path resolves to a directory or submodule |
BINARY_FILE |
Custom BinaryFileError |
Includes total_bytes and
magic_hex in the payload
|
UPSTREAM_ERROR |
Any other HTTP error with a status | 5xx, network |
INTERNAL |
Anything else | Unknown / programmer error |
Authentication #
The server authenticates as a GitHub App installation, never as a user
PAT. Octokit’s createAppAuth strategy negotiates an
installation access token on first use and refreshes it transparently
before expiry. The PEM private key is mounted as a Docker secret to
keep it out of process arguments and environment variables.
| Variable / file | Purpose | Source |
|---|---|---|
GITHUB_APP_ID |
Numeric ID of the GitHub App |
.env in dev, environment in compose
|
GITHUB_APP_INSTALLATION_ID |
Numeric ID of the App installation on the target org | Same |
/run/secrets/github_app_key |
PEM private key (PKCS#1 RSA). Validated for
BEGIN and PRIVATE KEY
markers at startup.
|
Compose secrets.github_app_key.file →
./secrets/github_app_key.pem
|
GITHUB_APP_PRIVATE_KEY_PATH |
Override for the PEM mount path; only set in tests. | Optional env var |
The PEM is loaded once at boot by
loadGitHubAppKey (src/lib/secrets.ts) and
fed straight into buildOctokit
(src/lib/github.ts). The Octokit client is constructed
exactly once in src/index.ts; no module-level
lazy construction, so repeated imports never trigger PEM reads.
Caching (Valkey) #
Three independent Valkey namespaces back the server. All keys, TTLs, and operational rules are mirrored in infra/valkey/NAMESPACES.md; the table below is the authoritative runtime contract.
| Namespace | TTL | Purpose | Key shape |
|---|---|---|---|
amtp:mcp:tree:* |
600 s | Final repo.tree result blobs |
amtp:mcp:tree:{repo}:{resolved_sha}:{sha256(args)}
|
amtp:mcp:blob:* |
3600 s |
.gitignore blob contents — global, no repo
segment, keyed by Git blob SHA so any repo at any ref benefits
from the cache
|
amtp:mcp:blob:{blob_sha} |
amtp:mcp:null_gitignore:* |
600 s |
Negative sentinel for an absent ancestor
.gitignore. Skips the
repos/contents 404 round-trip on subsequent calls
within the TTL window.
|
amtp:mcp:null_gitignore:{repo}:{resolved_sha}:{prefix}
|
Degrade-to-uncached contract #
Every Valkey op is wrapped in
withCacheTimeout (src/lib/cache.ts) with a
1.5 s deadline and a synchronous-rejection trap.
All four failure surfaces collapse to the
CACHE_UNAVAILABLE sentinel:
-
read timeout → metric
mcp_cache_op_timeouts_total{op="read"}+ uncached path. -
read rejection → metric
mcp_cache_op_errors_total{op="read"}+ uncached path (no log; reads are noisy under partition). -
write timeout → same metric scoped to
op="write"+ structured warn logcache_write_dropped(result still served correctly, cache simply not seeded). -
write rejection → metric
mcp_cache_op_errors_total{op="write"}+ structured warn logcache_write_error.
A counter-bounded offline queue (PENDING_LIMIT = 1000
in-flight cache ops) sits in front of the timeout wrapper. ioredis v5
does not expose maxCommandQueueSize; the equivalent
semantics are enforced here so that a sustained Valkey outage cannot
OOM the process. Ops beyond the limit are rejected immediately and
increment mcp_cache_op_errors_total.
Rate Limiting & Resiliency #
A shared p-limit(10) pool
(src/lib/rate-limit.ts) wraps every Octokit invocation
across the entire process — including the
repos.getCommit call for ref resolution, every
git/trees fetch, every
repos/contents ancestor probe, the
repo.read_file raw stream, and the metadata fallback. The
active+pending count is exposed as the
mcp_github_rate_limit_queue_depth gauge.
On top of the local pool, two Octokit plugins manage GitHub-side rate limiting:
-
@octokit/plugin-throttling— on primary RL hits, retry up to 3× and incrementmcp_github_retries_total{reason="primary_rate_limit"}; on secondary RL hits, always retry and increment{reason="secondary_rate_limit"}. -
@octokit/plugin-retry—doNotRetry: [400, 401, 403, 404, 422]. 5xx and network errors retry per plugin defaults; each retry incrementsmcp_github_retries_total{reason="server_error"}.
Every Octokit response triggers an
after("request") hook that records
x-ratelimit-remaining on the
mcp_github_rate_limit_remaining observable gauge
(src/lib/github.ts).
Healthcheck #
GET /healthz (alias /health) is served by
src/lib/health.ts. It performs an asymmetric memo:
-
Success is cached for 1 s.
High-frequency probe bursts (Docker compose-wait, K8s liveness, CI
smoke) do not amplify into Valkey
PINGtraffic. -
Failure is never cached. Each probe issues a fresh
PINGwith a 500 ms timeout, so a Valkey crash is detected within milliseconds — not within the success-memo window.
On success the response is
{ "status": "ok", "valkey": "up" }. The compose-defined
healthcheck uses
wget -qO- http://127.0.0.1:8090/healthz with
interval: 10s, timeout: 3s,
retries: 5, start_period: 15s.
Telemetry #
OpenTelemetry is initialized synchronously in
src/lib/otel.ts before any other import —
a strict ordering requirement so that the OTel API interceptors are in
place when other modules acquire tracers and meters. The default OTLP
HTTP endpoint (http://otel-collector:4318 in compose) is
honored via OTEL_EXPORTER_OTLP_ENDPOINT; metrics export
every 15 s.
Metrics #
| Instrument | Type | Labels | Description |
|---|---|---|---|
mcp_tool_calls_total |
counter | tool · outcome |
Per-tool invocation count by outcome (ok / error) |
mcp_tree_entries_excluded_total |
counter | layer |
Entries excluded by each pipeline layer (gitignore / platform / user / size) |
mcp_tree_cache_hits_total |
counter | — | Tree result cache hits |
mcp_tree_cache_misses_total |
counter | — | Tree result cache misses |
mcp_blob_cache_hits_total |
counter | — | Gitignore blob cache hits |
mcp_blob_cache_misses_total |
counter | — | Gitignore blob cache misses |
mcp_tree_null_gitignore_hits_total |
counter | — |
Negative-cache short-circuits for absent
.gitignore ancestors
|
mcp_github_api_calls_total |
counter | endpoint |
GitHub API calls grouped by route segment
(repos/commits, git/trees,
git/blobs, repos/contents,
repos/contents.raw,
repos/contents.meta)
|
mcp_github_retries_total |
counter | reason |
Octokit retries (primary_rate_limit /
secondary_rate_limit / server_error)
|
mcp_cache_op_timeouts_total |
counter | op |
Valkey op timeouts (read / write)
|
mcp_cache_op_errors_total |
counter | op |
Valkey op synchronous rejections |
mcp_read_file_truncations_total |
counter | — |
repo.read_file calls that returned truncated
content
|
mcp_read_file_binary_rejections_total |
counter | — | Calls rejected by binary detection |
mcp_read_file_meta_fallback_failures_total
|
counter | — |
Calls where the metadata fallback failed and
total_bytes=-1 was returned
|
mcp_read_file_bytes |
histogram | direction |
Byte sizes for arg payloads and response payloads (direction
∈ {args, response})
|
mcp_github_rate_limit_remaining |
obs. gauge | — |
Last observed
x-ratelimit-remaining from a successful Octokit
response
|
mcp_github_rate_limit_queue_depth |
obs. gauge | — | Live p-limit active+pending count |
Spans #
| Span name | Tool | Key attributes |
|---|---|---|
mcp.tool.repo_tree |
repo.tree |
repo.full_name, repo.ref,
repo.resolved_sha,
tree.total_entries, tree.kept,
tree.excluded.{gitignore,platform,user,size},
tree.truncated, cache.hit,
github.api_calls,
tree_fetch_duration_ms,
tree_processing_duration_ms,
tree.yield_count,
tree.yield_interval,
tree.peak_memory_bytes
|
mcp.tool.repo_read_file |
repo.read_file |
repo.full_name, repo.ref,
file.path, file.total_bytes,
file.returned_bytes, file.truncated,
github.api_calls, fetch_duration_ms,
mcp.args.bytes,
mcp.response.bytes
|
Both spans use SpanStatusCode.OK on success and
SpanStatusCode.ERROR with the error message on failure
(src/lib/telemetry.ts).
Stateless MCP Transport Contract #
The Streamable HTTP transport multiplexes JSON-RPC over a single HTTP
request / response pair. The AMTP server runs in
stateless mode: every inbound request constructs a
fresh McpServer +
StreamableHTTPServerTransport pair, registers both tools,
services the request, and disposes both on
res.on('close').
// src/server.ts (excerpt)
const httpServer = createServer(async (req, res) => {
if (req.url === "/healthz" || req.url === "/health") {
await healthzHandler(req, res);
return;
}
const body = await readBody(req); // raw stream → JSON.parse → unknown
// Stateless mode: one McpServer + transport per request.
const mcpServer = new McpServer({ name: "amtp-github-mcp", version: "1.0.0" });
registerRepoTreeTool(mcpServer, octokit);
registerRepoReadFileTool(mcpServer, octokit);
const transport = new StreamableHTTPServerTransport({});
res.on("close", () => {
void transport.close();
void mcpServer.close();
});
await mcpServer.connect(transport);
await transport.handleRequest(req, res, body);
});
readBody() drains the raw request stream, decodes UTF-8,
and JSON.parse’s exactly once. The transport
receives the parsed body via the third argument of
handleRequest(); it does not re-read the stream. This
ordering matters — see
Bug 3 in the shake-down log.
Configuration Reference #
| Variable | Default | Description |
|---|---|---|
GITHUB_APP_ID |
— | GitHub App numeric ID. Required. |
GITHUB_APP_INSTALLATION_ID |
— | App installation numeric ID on the target org. Required. |
GITHUB_APP_PRIVATE_KEY_PATH |
/run/secrets/github_app_key |
Override for the PEM mount point. Used in tests; production should keep the default. |
VALKEY_HOST |
localhost |
Valkey hostname (compose default: valkey). |
VALKEY_PORT |
6379 |
Valkey TCP port. |
VALKEY_PASSWORD |
— | Valkey requirepass value. Required. |
MCP_HTTP_PORT |
8090 |
HTTP port the MCP server binds to (inside the container). |
OTEL_EXPORTER_OTLP_ENDPOINT |
— (compose default
http://otel-collector:4318)
|
OTLP HTTP endpoint for traces and metrics. Standard OTel SDK variable. |
OTEL_SERVICE_NAME |
amtp-github-mcp |
Logical service name attached to every span / metric resource. |
FILTER_YIELD_INTERVAL |
5000 |
Pipeline yield-to-event-loop cadence (entries). Clamped to
[100, 50000]; non-finite values fall back to
5000.
|
NODE_ENV |
production (in container) |
Standard Node.js mode flag. |
Operations Runbook #
Local development #
cd apps/github-mcp
npm install
npm run typecheck # tsc --noEmit
npm test # node --test (schema, pipeline, gitignore,
# normalize, read-file-schema, binary-detect,
# read-file-fetch-stream)
Container build #
# Test stage (typecheck + unit tests inside Docker — used by CI validate):
docker build --target test -t amtp-github-mcp-test apps/github-mcp
# Production image (multi-stage; deps → build → production):
docker compose -f docker-compose.yml build github-mcp
deps, test, build,
production.
Deploy & smoke #
docker compose -f docker-compose.yml up -d --build --wait github-mcp
curl -sf http://localhost:8090/healthz
# Expected: {"status":"ok","valkey":"up"}
GitHub App private-key rotation #
- In the GitHub App settings (Settings → Developer settings → GitHub Apps → <app> → Private keys), generate a new key. Keep the old key active until the rollout completes.
-
Replace secrets/github_app_key.pem on the runner host
with the new PEM (
chmod 644 secrets/github_app_key.pem; the file is gitignored viasecrets/). -
Update the GitHub Actions secret
GH_APP_PRIVATE_KEY_B64in Settings → Environments → dev with the new base64-encoded PEM. -
docker compose up -d --force-recreate github-mcpon the dev host (or re-run the deploy workflow). -
Verify the deploy probe passes (
curl -sf http://localhost:8090/healthz) and thatmcp_github_retries_totalis not climbing. - Once stable, revoke the old key in the GitHub App settings.
Bugs Found During Shake-down #
Three substantive bugs were caught and fixed before the first green deploy. They are recorded here so the next maintainer does not reintroduce them when refactoring.
Bug 1 — Octokit RFC 6570 templates encoded / as
%2F
#
Symptom.
repo.read_file called against any non-root path returned
404 from GitHub even though the path existed.
Root cause.
Calls of the form
octokit.request("GET /repos/{owner}/{repo}/contents/{path}", { path
})
let Octokit’s RFC 6570 URL template expansion encode every
unsafe character —
including the path separator. A path like
src/lib/index.ts became
src%2Flib%2Findex.ts, which GitHub’s router
rejects.
Fix.
Build the URL by hand with explicit per-segment encoding, mirroring
the pattern already used by fetchTree:
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
const rawUrl = `GET /repos/${owner}/${repo}/contents/${encodedPath}`;
await octokit.request(rawUrl, { ref, headers: { accept: "application/vnd.github.raw" }, /* … */ });
encodeURIComponent preserves the slashes that the
GitHub router needs as literal separators.
Bug 2 —
parseSuccessResponseBody: false returns a
ReadableStream, not a Response
#
Symptom.
Earlier code attempted to read
response.data.body (treating data as a
Response) and crashed with
“Cannot read properties of undefined (reading
‘getReader’)”.
Root cause.
Under
request: { parseSuccessResponseBody: false } in
@octokit/request v9, the request library hands the
underlying response body straight back as response.data.
With Node 20’s undici fetch, that value is a
ReadableStream<Uint8Array>; it is also
async-iterable. There is no surrounding Response
wrapper.
Fix.
Treat response.data as the iterator directly:
for await (const chunk of response.data) {
chunks.push(chunk);
bytesRead += chunk.byteLength;
if (bytesRead > maxBytes) {
truncated = true;
controller.abort();
break; // for-await disposes the iterator/reader automatically
}
}
response.data directly; do not look for
.body.
Bug 3 — Streamable HTTP transport must be per-request #
Symptom.
A shared, long-lived
StreamableHTTPServerTransport instance produced
interleaved or empty responses under concurrent requests, and
tools/list calls intermittently returned the previous
request’s payload.
Root cause.
StreamableHTTPServerTransport is request-scoped by
design. Sharing a single instance across HTTP requests interleaves
JSON-RPC frames on the same writable stream, and the SDK does not
guard against multiplexing. Additionally, the transport assumes the
inbound body has been parsed exactly once before
handleRequest() is called — otherwise the raw
stream is consumed twice.
Fix. Two changes, both in src/server.ts:
-
Construct a fresh
McpServer+StreamableHTTPServerTransportpair per HTTP request. Register the tools on the per-request server. Dispose both inres.on('close'). -
Drain and JSON-parse the raw body in
readBody(); pass the parsed body as the third argument oftransport.handleRequest(req, res, body).
File Map #
apps/github-mcp/
├─ Dockerfile four-stage build: deps → test → build → production
├─ package.json Node 20 ESM; pinned dep versions
├─ tsconfig.json strict TypeScript, NodeNext module resolution
├─ test/ node --test suites (schema, pipeline,
│ gitignore, normalize, read-file-schema,
│ binary-detect, read-file-fetch-stream)
└─ src/
├─ index.ts entry; OTel-init-first; buildOctokit → startServer
├─ server.ts HTTP server; per-request McpServer + transport;
│ /healthz dispatch; readBody (drain + JSON.parse)
├─ tools/
│ ├─ repo-tree.ts repo.tree handler (cache → fetch → pipeline →
│ │ sort → cache-write → telemetry)
│ └─ repo-read-file.ts repo.read_file handler (stream → bin-detect →
│ UTF-8 decode → telemetry); error mapping
└─ lib/
├─ schema.ts Zod input schemas; output TS types;
│ BinaryFileError; READ_FILE_MAX_BYTES_*
├─ secrets.ts loadGitHubAppKey() — PEM read + shape validate
├─ github.ts buildOctokit() singleton; fetchTree(); resolveRef();
│ rate-limit-remaining hook
├─ rate-limit.ts shared p-limit(10) + queue-depth getter
├─ cache.ts ioredis singleton; withCacheTimeout (1.5 s);
│ TTL = { tree:600, blob:3600, nullGitignore:600 };
│ get/set helpers per namespace
├─ pipeline.ts applyPipeline() (L2→L1→user→L3); compareCodePoints;
│ sortByPath; FILTER_YIELD_INTERVAL clamping
├─ platform-excludes.ts frozen Layer 2 patterns (.git, node_modules, *.png,
│ *.pem, lock files, …); singleton Ignore matcher
├─ gitignore.ts ancestor walk, in-subtree loader, scope chain,
│ L1 verdict via .test() (preserves !negation)
├─ ignore-factory.ts CJS-compatible cast for the `ignore` package
├─ normalize.ts normalizePath() + ancestorPrefixes()
├─ size-filter.ts SIZE_THRESHOLD_BYTES = 200 KB; isSizeExcluded
├─ read-file-fetch.ts streaming raw fetch; AbortController;
│ parseSuccessResponseBody:false;
│ metadata fallback for total_bytes
├─ binary-detect.ts NUL scan + UTF-8 trailing-tail trim + retry net
├─ health.ts /healthz handler (asymmetric memo)
├─ otel.ts NodeSDK init; OTLP HTTP exporters
├─ telemetry.ts all counters/histograms/gauges + span helpers
└─ logger.ts pino factory; structured JSON logs
index → server → tools → lib.