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.

Implemented Node 20 · TypeScript 5.6 Streamable HTTP MCP · SDK 1.10 Octokit + GitHub App ioredis · p-limit(10) OpenTelemetry

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).

AMTP Pipeline (future)
Temporal · Repo Crawler activity
MCP JSON-RPC over HTTP POST :8090
amtp-github-mcp  (apps/github-mcp)
per-request
McpServer + StreamableHTTPServerTransport
tools/repo-tree.tsrepo.tree tools/repo-read-file.tsrepo.read_file
shared singletons
Octokit (App auth, retry, throttling) ioredis (lazyConnect, bounded offline queue) p-limit(10) — every Octokit call passes through OpenTelemetry SDK (NodeSDK)
HTTPS (App JWT → install)
GitHub
REST API — Trees, Contents, Commits
RESP TCP :6379
Valkey
mcp:tree · mcp:blob · mcp:null_gitignore
OTLP HTTP :4318
OTel Collector
amtp-obs
Service topology — apps/github-mcp/src/server.ts, src/lib/github.ts, src/lib/cache.ts, src/lib/otel.ts.

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
}
apps/github-mcp/src/lib/schema.ts

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.

blob
L2 — platform-excludes
.git/**, node_modules/**, *.png
→ excluded “platform”
L1 — .gitignore chain
Most-specific scope wins · ancestor walk: prefixes("a/b/c") · in-subtree .gitignore files · test() preserves !negation
→ excluded “gitignore”
User layer
Caller’s ignore_patterns[]
→ excluded “user”
L3 — size gate
size > 200 KB · bypassed when force=true
→ excluded “size”
kept
apps/github-mcp/src/lib/pipeline.ts § classify

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.mdreadme.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,
})
Cache key includes the resolved commit SHA (so branch moves invalidate naturally) and a SHA-256 of the canonical argument set (so different filter configurations do not collide).

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
}
apps/github-mcp/src/lib/schema.ts § RepoReadFileOutput

Streaming strategy #

  1. Issue GET /repos/{owner}/{repo}/contents/{encodedPath} with Accept: application/vnd.github.raw, an AbortController signal, parseSuccessResponseBody: false, and a custom request.fetch that pass-throughs to globalThis.fetch — bypassing Octokit's eager ArrayBuffer materialization while preserving the auth header injected upstream.
  2. Read the body as an async iterable of Uint8Array chunks. Track bytesRead; once it exceeds max_bytes, set truncated=true, controller.abort(), and break the for await — which disposes the reader.
  3. Slice the buffer to exactly max_bytes before handing it to detectBinary. Reading one byte past max_bytes distinguishes “file fits” from “file is truncated”; the slice restores the contract that the binary detector sees no padding.

total_bytes resolution #

Binary detection & UTF-8 trim #

Implemented in src/lib/binary-detect.ts:

  1. Step 0 — empty buffer short-circuits to { isBinary: false, decoded: "" }.
  2. Step 1 — NUL-byte scan within the first 8192 bytes. Any 0x00 in that window is sufficient evidence to reject as binary.
  3. 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.
    • 110xxxxx needs 1 continuation; 1110xxxx needs 2; 11110xxx needs 3.
    • If continuations present < required, strip the incomplete tail.
  4. Step 3 — Strict TextDecoder (fatal: true, ignoreBOM: false). Success returns { isBinary: false, decoded }.
  5. 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:

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:

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:

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);
});
Per-request transport — src/server.ts

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)
apps/github-mcp/package.json § scripts

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
apps/github-mcp/Dockerfile — four named stages: 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"}
Same commands the deploy job runs; see .github/workflows/github-mcp-ci-cd.yml.

GitHub App private-key rotation #

  1. 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.
  2. 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 via secrets/).
  3. Update the GitHub Actions secret GH_APP_PRIVATE_KEY_B64 in Settings → Environments → dev with the new base64-encoded PEM.
  4. docker compose up -d --force-recreate github-mcp on the dev host (or re-run the deploy workflow).
  5. Verify the deploy probe passes (curl -sf http://localhost:8090/healthz) and that mcp_github_retries_total is not climbing.
  6. 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" }, /* … */ });
src/lib/read-file-fetch.ts — per-segment 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
  }
}
src/lib/read-file-fetch.ts — iterate 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:

  1. Construct a fresh McpServer + StreamableHTTPServerTransport pair per HTTP request. Register the tools on the per-request server. Dispose both in res.on('close').
  2. Drain and JSON-parse the raw body in readBody(); pass the parsed body as the third argument of transport.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
File map for apps/github-mcp/. Directory ordering mirrors the runtime call graph: index → server → tools → lib.