{
  "markdown": "# @genvidtech/mcp-utils\n\nShared utilities for building MCP servers: concurrency control, file-change tracking, text pagination, path and filesystem helpers, MCP response and error helpers, tool annotations, optimistic file watching, and project-config loading.\n\n## Installation\n\n```sh\nnpm install @genvidtech/mcp-utils\n```\n\n`zod` is a **peer dependency** (`^3.23.0`) — only required if you use `loadProjectConfig`. Install it alongside this package:\n\n```sh\nnpm install zod\n```\n\n```ts\nimport {\n  ReadWriteLock, ExpectedChanges, paginateText,\n  walkFiles, resolveWithin, resolveRootFolder, escapeRegExp, toPosixPath,\n  mcpError, withMcpErrors, bufferingLogger, paginatedContent, mcpContent,\n  READ_ONLY, REGENERATE, MUTATE, NON_IDEMPOTENT_READ,\n  OptimisticWatcher, loadProjectConfig, isMcpError,\n  ObservedState, contentFingerprint,\n} from \"@genvidtech/mcp-utils\";\n```\n\n## Utilities\n\nEach utility is independent — import only what you need. Grouped here the same way as the per-utility list in [`CLAUDE.md`](CLAUDE.md).\n\n**Concurrency & state**\n\n- [`ReadWriteLock`](#readwritelock) — promise-based, write-preferring read-write lock\n- [`ExpectedChanges`](#expectedchanges) — suppress self-triggered file-watcher events\n- [`OptimisticWatcher`](#optimisticwatcher) — classify watch events as self-writes vs. external\n- [`ObservedState`](#observedstate) — bounded path → content-fingerprint ledger\n- [`TxToken`](#txtoken) — encode/decode/compare a project-scoped transaction counter for the wire\n\n**Filesystem & path**\n\n- [`walkFiles`](#walkfiles) — recursive walk returning only regular files\n- [`resolveWithin`](#resolvewithin) — lexical path-traversal guard\n- [`resolveRootFolder`](#resolverootfolder) — resolve exactly one project root by precedence; ambiguity is an error\n- [`resolveRootFolders`](#resolverootfolders) — resolve one or more project root candidates by precedence; ambiguity is data\n- [`loadProjectConfig` / `isMcpError`](#loadprojectconfig--ismcperror) — read, merge, and validate a project config\n\n**Strings**\n\n- [`escapeRegExp` / `toPosixPath`](#escaperegexp--toposixpath) — regex escaping and path separator normalization\n\n**MCP responses, errors & annotations**\n\n- [`mcpError` / `withMcpErrors`](#mcperror--withmcperrors) — turn a thrown value into a `CallToolResult`\n- [`mcpContent`](#mcpcontent) — success-path counterpart to `mcpError`\n- [`paginatedContent`](#paginatedcontent) — paginated text as a `CallToolResult`\n- [`paginateText`](#paginatetext) — line-based pagination\n- [Tool annotation presets](#tool-annotation-presets) — `READ_ONLY`, `REGENERATE`, `MUTATE`, `NON_IDEMPOTENT_READ`\n- [`exposeDocs`](#exposedocs) — serve a package's Markdown docs (flat or nested) and `README.md` as MCP resources\n\n**Shared types**\n\n- [`bufferingLogger`](#bufferinglogger) — a `Logger` that buffers lines in memory\n- [`Logger` type](#logger-type) — the minimal logging interface used across utilities\n\n### ReadWriteLock\n\nA promise-based, write-preferring read-write lock. Multiple concurrent readers are allowed; writers get exclusive access. Pending writes are serviced before queued reads to prevent write starvation.\n\n```ts\nconst lock = new ReadWriteLock();\n\n// Multiple readers can run concurrently\nconst result = await lock.read(async () => {\n  return readSharedState();\n});\n\n// Writers get exclusive access; queued reads wait until all writes drain\nawait lock.write(async () => {\n  mutateSharedState();\n});\n```\n\n### ExpectedChanges\n\nTracks file paths that an MCP write tool is about to modify so that a file watcher can suppress the self-triggered change event. Entries auto-expire after a configurable TTL (default: 5000 ms) to prevent stale suppression if a write fails or the watcher event is delayed.\n\n```ts\nconst expected = new ExpectedChanges(5000); // ttlMs optional, default 5000\n\n// Register before writing\nexpected.add(\"/path/to/file.json\");\ntry {\n  await fs.writeFile(\"/path/to/file.json\", newContent);\n} finally {\n  expected.remove(\"/path/to/file.json\"); // clean up if watcher fires before expiry\n}\n\n// In your file watcher callback:\nif (expected.consume(changedPath)) {\n  return; // suppress — we triggered this change ourselves\n}\nhandleExternalChange(changedPath);\n```\n\n`consume()` returns `true` and removes the entry if the path was registered and has not expired. Call `purgeExpired()` periodically to clean up entries from writes whose watcher events never fired.\n\n### paginateText\n\nPaginates large text content by line using a 1-based `offset` and `limit`. A trailing newline does not count as an extra line.\n\n```ts\nimport { paginateText } from \"@genvidtech/mcp-utils\";\n\nconst result = paginateText(\"a\\nb\\nc\\n\", { offset: 2, limit: 1 });\n// {\n//   text: \"b\",\n//   totalLines: 3,\n//   offset: 2,\n//   limit: 1,\n//   hasMore: true,\n// }\n```\n\n**PaginationOptions**\n\n| Field | Type | Default | Description |\n|-------|------|---------|-------------|\n| `offset` | `number` | `1` | 1-based start line |\n| `limit` | `number` | all lines | Maximum lines to return |\n\n**PaginatedResult**\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `text` | `string` | The requested slice of text |\n| `returnedLines` | `number` | Number of lines actually returned (`0` for an out-of-range page) |\n| `totalLines` | `number` | Total line count of the input |\n| `offset` | `number` | Actual offset used |\n| `limit` | `number` | Actual limit used |\n| `hasMore` | `boolean` | True if lines remain after this page |\n\n### Logger type\n\nA minimal logger interface used by MCP server utilities:\n\n```ts\nimport type { Logger } from \"@genvidtech/mcp-utils\";\n\nfunction setup(log: Logger) {\n  log(\"server started\");\n}\n```\n\n### walkFiles\n\nRecursively walks a directory and returns the absolute paths of all files whose path satisfies `match`. If the directory does not exist the function returns `[]` without throwing; other I/O errors (e.g. `EACCES`) from reading a directory are re-thrown. Symlinked directories are not followed — only entries for which `entry.isDirectory()` returns `true` are recursed into, which also means a symlink cycle is never entered.\n\n**Every returned path is a regular file**, so you can read any element of the result without a further check. Entries that are not regular files are never returned, even when their *name* satisfies `match`:\n\n| Entry | Returned? |\n|---|---|\n| regular file | yes |\n| directory | no (recursed into instead) |\n| symlink → regular file | yes — reading it succeeds |\n| symlink → directory (incl. Windows junctions) | no |\n| broken symlink, symlink cycle, socket, device | no |\n\nOrdinary entries are classified from the directory listing alone; only the leftovers (symlinks and special entries) cost one resolving `stat`, and only when they already matched `match`. An entry whose `stat` fails for any reason is dropped rather than propagated — failing to classify one leaf doesn't abort the walk, whereas failing to enumerate a directory does. See [ADR-0001](wiki/decisions/0001-walkfiles-returns-only-regular-files.md).\n\n```ts\nimport { readFileSync } from \"node:fs\";\nimport { walkFiles } from \"@genvidtech/mcp-utils\";\n\n// String match: suffix / endsWith test\nconst jsonFiles = walkFiles(\"/project/data\", \".json\");\n\n// Predicate match: arbitrary filter\nconst testFiles = walkFiles(\"/project/src\", (p) => p.includes(\".test.\"));\n\n// Every result is readable — no isFile() guard needed\nfor (const f of jsonFiles) JSON.parse(readFileSync(f, \"utf-8\"));\n```\n\nThe optional 3rd and 4th parameters (`readdir`, `stat`) are test seams that default to `fs.readdirSync` / `fs.statSync`; production callers omit both.\n\n### escapeRegExp / toPosixPath\n\nTwo lightweight string helpers.\n\n`escapeRegExp` escapes all regex metacharacters in a string so it can be used as a literal pattern inside `new RegExp(...)`.\n\n`toPosixPath` converts all backslashes to forward slashes, producing a POSIX-style path. No-ops on paths that already use forward slashes.\n\n```ts\nimport { escapeRegExp, toPosixPath } from \"@genvidtech/mcp-utils\";\n\nconst pattern = new RegExp(escapeRegExp(\"file.name[0]\")); // literal match\n\nconst posix = toPosixPath(\"C:\\\\Users\\\\dev\\\\project\"); // \"C:/Users/dev/project\"\n```\n\n### resolveWithin\n\nResolves `rel` against `base` and returns the absolute path only if it stays within `base`; returns `null` otherwise. Use this as a path-traversal guard when accepting user-supplied path **strings**.\n\n- `\"\"` and `\".\"` resolve to `base` itself and are returned.\n- A `rel` that escapes `base` via `..` segments, an absolute path outside `base`, or a cross-drive path on Windows all return `null`.\n- A filename that merely starts with `..` without traversing upward (e.g. `..gitkeep`) stays inside `base` and is returned.\n\n> **Lexical only.** This does no filesystem access and does **not** resolve symlinks — a symlink inside `base` pointing outside it will be accepted. For an on-disk containment guarantee (sandboxing attacker-supplied paths against symlink escapes), `fs.realpath` the result and re-check.\n\n```ts\nimport { resolveWithin } from \"@genvidtech/mcp-utils\";\n\nresolveWithin(\"/project\", \"src/index.ts\"); // \"/project/src/index.ts\"\nresolveWithin(\"/project\", \"../secret\");    // null  — escapes base\nresolveWithin(\"/project\", \"\");             // \"/project\"\n```\n\n### resolveRootFolder\n\nResolves **exactly one** project root directory for an MCP server using a four-level precedence chain — `explicit` > `env` > `discovery` > `cwd` — so bundled servers launched with no CLI arguments don't need to hand-roll this logic. Reach for this over its plural counterpart, [`resolveRootFolders`](#resolverootfolders), when your program needs a single root and treats two or more marker matches as a failure to report — the common case, e.g. a server targeting one project. (`resolveRootFolder` is implemented on top of `resolveRootFolders`; this section documents its narrower, single-root contract.)\n\n```ts\nimport { resolveRootFolder, isMcpError } from \"@genvidtech/mcp-utils\";\n\nconst result = resolveRootFolder({\n  explicit: args.projectDir,        // highest precedence: CLI flag\n  envVar: \"MY_SERVER_PROJECT_DIR\",  // second: environment variable\n  marker: \"project.c3proj\",         // discovery: look for this entry in child dirs\n  searchDepth: 2,                   // how many levels below cwd to search (default: 1)\n});\n\nif (isMcpError(result)) return result; // propagate any error\nconst { path, source } = result;\nif (source === \"cwd\") {\n  console.warn(\"No project root found; using cwd:\", path);\n}\n```\n\n**ResolveRootFolderOpts**\n\n| Field | Type | Default | Description |\n|---|---|---|---|\n| `marker` | `string` | — | Filename or directory name that identifies a project root (e.g. `\"project.c3proj\"`, `\".git\"`). Required; must be non-empty/non-whitespace or an `mcpError` is returned. |\n| `explicit` | `string` | — | Highest-precedence override. Relative values are resolved against `cwd`; absolute values used as-is. **No containment restriction** — a `../sibling` path is permitted. |\n| `envVar` | `string` | — | Name of an environment variable to check when `explicit` is absent. Same resolution rules as `explicit`. |\n| `cwd` | `string` | `process.cwd()` | Starting directory for discovery and the resolution base for relative `explicit`/`envVar` values. |\n| `searchDepth` | `number` | `1` | Maximum depth below `cwd` at which to search for the marker. Depth `1` checks immediate children of `cwd`; depth `0` checks only `cwd` itself. |\n\n**ResolvedRoot**\n\n| Field | Type | Description |\n|---|---|---|\n| `path` | `string` | Absolute path to the resolved project root. |\n| `source` | `\"explicit\" \\| \"env\" \\| \"discovery\" \\| \"cwd\"` | How the root was determined. `\"cwd\"` means no marker was found anywhere — the silent fallback; consumers typically warn on this value. |\n\n**Resolution algorithm**\n\n1. If `opts.explicit` is set and non-blank → return it (resolved to absolute). No containment restriction.\n2. Else if `opts.envVar` is set and the named env var is non-blank → return it (resolved to absolute). No containment restriction.\n3. Else search for a directory that **contains** `opts.marker`:\n   - Check `cwd` itself (depth 0), then scan child directories up to `opts.searchDepth`.\n   - Exactly 1 match → return it with `source: \"discovery\"`.\n   - 0 matches → fall through to step 4.\n   - ≥2 matches → return `mcpError` (ambiguous root). Only `cwd` and its descendants are searched; discovery never escapes the base directory.\n4. Return `cwd` with `source: \"cwd\"` — no marker found anywhere.\n\n**Never throws.** I/O errors from directory scanning are caught: `ENOENT` is treated as \"no entries\"; all other errors (e.g. `EACCES`) are returned as `mcpError`. Use `isMcpError` to narrow the `ResolvedRoot | CallToolResult` return type.\n\n### resolveRootFolders\n\nResolves the project root **candidates** for an MCP server using the same four-level precedence chain — `explicit` > `env` > `discovery` > `cwd` — as [`resolveRootFolder`](#resolverootfolder). Reach for this over the singular when two or more marker matches is a legitimate outcome you intend to act on (e.g. registering every candidate as its own project), so ambiguity comes back as data rather than an error. `resolveRootFolder` is implemented on top of this function, and its own observable output is unchanged.\n\n```ts\nimport { resolveRootFolders, isMcpError } from \"@genvidtech/mcp-utils\";\n\nconst result = resolveRootFolders({\n  marker: \"project.c3proj\",  // discovery: look for this entry in child dirs\n  searchDepth: 2,             // how many levels below cwd to search (default: 1)\n});\n\nif (isMcpError(result)) return result; // propagate any error\nconst { paths, source } = result;\nif (source === \"discovery\" && paths.length > 1) {\n  for (const projectDir of paths) registerProject(projectDir);\n}\n```\n\nTakes the same **ResolveRootFolderOpts** as `resolveRootFolder` — see [its options table](#resolverootfolder) above.\n\n**ResolvedRoots**\n\n| Field | Type | Description |\n|---|---|---|\n| `paths` | `string[]` | Absolute paths to the resolved project root candidate(s). Always non-empty; more than one entry occurs only when `source` is `\"discovery\"` — two or more sibling directories contained the marker. |\n| `source` | `\"explicit\" \\| \"env\" \\| \"discovery\" \\| \"cwd\"` | How the candidates were determined. `\"cwd\"` means no marker was found anywhere — the silent fallback; consumers typically warn on this value. |\n\n**Resolution algorithm**\n\nSame as `resolveRootFolder`'s (above), except step 3 collects every match instead of stopping at \"exactly one\":\n\n1. If `opts.explicit` is set and non-blank → return `{ paths: [it] }` (resolved to absolute). No containment restriction.\n2. Else if `opts.envVar` is set and the named env var is non-blank → return `{ paths: [it] }` (resolved to absolute). No containment restriction.\n3. Else search for directories that **contain** `opts.marker`:\n   - Check `cwd` itself (depth 0), then scan child directories up to `opts.searchDepth`.\n   - 1 or more matches → return `{ paths: matches, source: \"discovery\" }`.\n   - 0 matches → fall through to step 4.\n4. Return `{ paths: [cwd], source: \"cwd\" }` — no marker found anywhere.\n\n**Never throws.** Same I/O error handling as `resolveRootFolder`: `ENOENT` is treated as \"no entries\"; all other errors (e.g. `EACCES`) are returned as `mcpError`. Use `isMcpError` to narrow the `ResolvedRoots | CallToolResult` return type.\n\n### mcpError / withMcpErrors\n\nHelpers that turn thrown errors into `CallToolResult` responses with `isError: true`, so MCP tool handlers can report failures without letting exceptions propagate to the transport layer.\n\n`mcpError(e, extraLines?)` converts a caught value into a `CallToolResult`. `Error` instances use `.message`; everything else is converted with `String(e)`. The second argument is either the legacy `string[]` of `extraLines` (appended to the message, evaluated eagerly) **or** an options object `{ prefix?, extraLines? }`. An opt-in `prefix` is prepended as `` `${prefix} ${message}` `` (single space; pass it without a trailing space, e.g. `\"Error:\"`); the default is no prefix, so existing callers are unaffected.\n\n`withMcpErrors(fn, opts?)` wraps an async handler so any thrown error is caught and returned as `mcpError(...)`. The second argument is either the legacy **thunk** `() => string[]` (called only at catch time — useful for reading mutable state such as a log buffer or transaction counter that may have changed between the call and the throw) **or** an options object `{ extraLines?, onError?, prefix? }`:\n\n- `extraLines: () => string[]` — same catch-time thunk semantics as the legacy form. A thunk that throws degrades to no extra lines (the primary error is still reported); `withMcpErrors` never throws out.\n- `onError: (err) => void | Promise<void>` — a side-effect hook invoked with the caught error **before** it is formatted, and **awaited**. Use it to run cleanup that must happen even on the error path (e.g. bumping an optimistic-concurrency watcher because files were already written before a cancellation). If `onError` itself throws, the thrown value is formatted in place of the original error — `withMcpErrors` still never throws out.\n- `prefix: string` — passed through to `mcpError` (see above).\n\n```ts\nimport { mcpError, withMcpErrors, bufferingLogger } from \"@genvidtech/mcp-utils\";\n\n// Direct conversion of a caught error\ntry {\n  await doWork();\n} catch (err) {\n  return mcpError(err, [\"context: file write failed\"]);\n}\n\n// Opt-in \"Error:\" prefix:\nmcpError(new Error(\"boom\"), { prefix: \"Error:\" });\n// content[0].text === \"Error: boom\"\n\n// Wrap a handler; extraLines thunk reads state at catch time\nconst { log, text } = bufferingLogger();\nconst handler = withMcpErrors(\n  async (args) => {\n    log(\"starting\");\n    await doWork(args);\n    return { content: [{ type: \"text\", text: \"ok\" }] };\n  },\n  () => [text()],  // captures log output accumulated before the throw\n);\n\n// Options form: run a side-effect on the error path, then prefix the message\nconst mutateHandler = withMcpErrors(\n  async (args) => mutateAndRespond(args),\n  {\n    onError: (err) => { if (err instanceof CancelledError) watcher.bump(); },\n    prefix: \"Error:\",\n  },\n);\n```\n\n### bufferingLogger\n\nCreates a logger that captures all log calls in memory instead of writing to stdout. Returns `{ log, text }` where `log` is a `Logger` that buffers each call as a line (multiple arguments joined by a single space via `String()` coercion), and `text()` returns the accumulated lines joined by `\"\\n\"`.\n\n```ts\nimport { bufferingLogger } from \"@genvidtech/mcp-utils\";\n\nconst { log, text } = bufferingLogger();\nlog(\"processed\", 3, \"files\");\nlog(\"done\");\ntext(); // \"processed 3 files\\ndone\"\n```\n\n### paginatedContent\n\nWraps `paginateText` and returns a `CallToolResult` whose single text block combines the page text and a `lines: A-B / total` range footer, joined with a blank line (`\"\\n\\n\"`). The range footer is emitted **only when `offset` or `limit` was supplied** (matching the consumer's `paginatedResponse`); an un-paginated call returns the whole text with no footer. An out-of-range page reports `lines: 0 / total` (no misleading range, no leading blank lines). An optional `footer(r)` callback receives the full `PaginatedResult` and its return value is appended on a new line; the callback always runs.\n\n```ts\nimport { paginatedContent } from \"@genvidtech/mcp-utils\";\n\nconst result = paginatedContent(\"a\\nb\\nc\\n\", { offset: 1, limit: 2 });\n// result.content[0].text === \"a\\nb\\n\\nlines: 1-2 / 3\"\n\n// No offset/limit → no range footer:\npaginatedContent(\"a\\nb\\nc\\n\", {});\n// content[0].text === \"a\\nb\\nc\"\n\n// Out-of-range page → \"lines: 0 / N\":\npaginatedContent(\"a\\nb\\nc\\n\", { offset: 5, limit: 2 });\n// content[0].text === \"lines: 0 / 3\"\n\n// With an optional caller footer:\nconst withFooter = paginatedContent(\n  \"a\\nb\\nc\\n\",\n  { offset: 1, limit: 2 },\n  (r) => `hasMore: ${r.hasMore}`,\n);\n// withFooter.content[0].text === \"a\\nb\\n\\nlines: 1-2 / 3\\nhasMore: true\"\n```\n\n### mcpContent\n\nThe success-path counterpart to `mcpError`. `mcpContent(text, footer?)` builds a `CallToolResult` with a **single** text block from a result plus an optional trailing `footer` line — so a result and its trailing metadata (e.g. `txId: <n>`) ride inside one block instead of the caller hand-rolling a second content block. Unlike `paginatedContent`'s footer callback, `footer` here is a plain string the caller computes (there is no derived result to pass). `text` and `footer` are joined by a single `\"\\n\"`; when `text` is empty only the footer is emitted. No `isError` field is set.\n\n```ts\nimport { mcpContent } from \"@genvidtech/mcp-utils\";\n\nmcpContent(\"wrote 3 files\");\n// content[0].text === \"wrote 3 files\"\n\nmcpContent(\"wrote 3 files\", `txId: ${txId}`);\n// content[0].text === \"wrote 3 files\\ntxId: 7\"\n```\n\n### Tool annotation presets\n\nFour `ToolAnnotations` constants for use when registering MCP tools. Each preset sets `readOnlyHint`, `destructiveHint`, and `idempotentHint` to reflect the tool's expected behavior.\n\n```ts\nimport { READ_ONLY, REGENERATE, MUTATE, NON_IDEMPOTENT_READ } from \"@genvidtech/mcp-utils\";\n\nserver.tool(\"list-files\", schema, READ_ONLY, handler);\nserver.tool(\"write-config\", schema, REGENERATE, handler);\nserver.tool(\"delete-entry\", schema, MUTATE, handler);\nserver.tool(\"consume-event\", schema, NON_IDEMPOTENT_READ, handler);\n```\n\n| Preset | `readOnlyHint` | `destructiveHint` | `idempotentHint` | Use when |\n|---|---|---|---|---|\n| `READ_ONLY` | `true` | `false` | `true` | Reads state, no side effects, safe to repeat |\n| `REGENERATE` | `false` | `false` | `true` | Writes output but repeated calls produce the same result; nothing permanently lost |\n| `MUTATE` | `false` | `true` | `false` | Modifies or deletes data; cannot be trivially undone; result may differ across calls |\n| `NON_IDEMPOTENT_READ` | `true` | `false` | `false` | Reads without modification but each call may return different results (e.g. consuming a queue) |\n\n### exposeDocs\n\nRegisters a consuming package's Markdown documentation as MCP resources, so a client can read the server's own docs. Takes the package directory and resolves the documentation directory and `README.md` beneath it.\n\n```ts\nimport { exposeDocs } from \"@genvidtech/mcp-utils\";\n\n// packageDir is your server package's root — the directory holding docs/ and README.md\nexposeDocs(server, packageDir);\n\n// Or point it at a nested documentation tree\nexposeDocs(server, packageDir, { docsDir: \"wiki\", recursive: true });\n```\n\n| Option | Default | Meaning |\n|---|---|---|\n| `docsDir` | `\"docs\"` | Directory holding the `*.md` files, resolved relative to `packageDir`. |\n| `recursive` | `false` | Descend subdirectories and expose nested documents under path-shaped names. |\n\nTwo resources are registered:\n\n| Resource | URI | Serves |\n|---|---|---|\n| `docs` | `docs:///{+path}` (templated) | `<packageDir>/<docsDir>/<path>.md` |\n| `readme` | `docs:///readme` (static) | `<packageDir>/README.md` |\n\nBoth are returned with `mimeType: \"text/markdown\"`. The `readme` resource is registered **only if `README.md` exists**; the templated `docs` resource is registered unconditionally, even when the documentation directory is absent.\n\nNames are the document's path beneath `docsDir`, always with forward slashes and without the `.md` extension — `wiki/reference/cli.md` is `docs:///reference/cli`. The template uses RFC 6570 reserved expansion (`{+path}`), which matches a name containing no separator just as well, so a flat layout addresses exactly as it did before: `docs/guide.md` remains `docs:///guide`.\n\nBehavior worth knowing before you rely on it:\n\n- **`recursive` governs what is served, not just what is listed.** With it off, a nested name is refused rather than quietly served, so the exposed set matches the advertised one.\n- **The name list is a snapshot.** The directory is walked once, when `exposeDocs` is called. Files added afterwards are still served correctly if requested by name, but won't appear in listings or completions until the server restarts.\n- **The document set is enumerable.** The template supplies a `list` callback, so `resources/list` returns every discovered document alongside the static `docs:///readme`. Argument completion offers the same set.\n- **Only regular files are offered.** The scan runs through [`walkFiles`](#walkfiles), so symlinked directories aren't followed, cycles terminate, and a *directory* named `guide.md` is never mistaken for a document.\n- **`README.md` owns `docs:///readme`.** If your documentation directory also contains a `readme.md`, it is shadowed — the SDK resolves a statically-registered resource before any template — so it is omitted from listings and completions rather than advertised under a URI that reads back as the root `README.md`. With no `README.md` present, `<docsDir>/readme.md` is exposed normally.\n- **An unresolvable name raises `McpError(InvalidParams)`.** Both a name with no matching file and one that escapes the documentation directory surface as a well-formed protocol error, matching what the SDK itself raises for a resource it cannot resolve — not a raw `ENOENT` carrying an absolute host path.\n\nThe read handler passes each name through [`resolveWithin`](#resolvewithin) before opening it. This is defence in depth rather than a fix for a reachable escape: the SDK normalises the requested URI through `new URL()` before matching, which collapses `..` segments, so a traversal is already contained by the time the template sees it. The guard means containment doesn't *depend* on that normalisation. See [ADR-0003](wiki/decisions/0003-exposedocs-path-shaped-resource-names.md).\n\n### OptimisticWatcher\n\nWatches one or more directories and classifies incoming change events as either **self-writes** (suppressed) or **external changes** (forwarded to `onExternalChange` and bumped into `txId`). Built on `ExpectedChanges` for path-level suppression, `ObservedState` for content-level dedup, and `fs.watch({ recursive: true })` by default.\n\n**Three-layer suppression**\n\n- **Layer 1 — synchronous suppress window.** Wrap a write in `suppress(fn)`. While `fn` is executing, every watcher event is silently dropped. The depth counter is always unwound in a `finally` block, so a throw inside `fn` leaves the watcher in a healthy state.\n- **Layer 2 — pre-registered path.** Call `expect(path)` before triggering a write. If the watcher event arrives after the suppress window has closed (an async race on fast filesystems), `ExpectedChanges.consume` still catches and drops it. Both `expect()` and the default watcher key on the **resolved absolute path**, so passing a relative write path (the same one handed to `fs.writeFile`) matches correctly.\n- **Layer 3 — content unchanged since last accounted for.** Some filesystems (observed on Windows) deliver more than one raw `fs.watch` event for a single logical write, so an external overwrite or a self-write can still reach `bump()` twice even after Layers 1 and 2. Layer 3 asks a question with no timing term: does this path's content actually differ from what was last recorded? A duplicate event over unchanged content is suppressed; a genuine change still bumps `txId`. It's backed by an `ObservedState` ledger — a fresh instance by default, or your own via the `observed` option — and Layers 1 and 2 feed it too (`record()` on every suppression), so a path they suppress is also sealed as accounted for. Pass `observed: null` to disable Layer 3 and restore pre-Layer-3 behavior (every non-suppressed event bumps `txId`). Layer 3 fails open: an evicted ledger entry, an unreadable file, or a throwing custom `Fingerprinter` all degrade toward an *extra* bump, never toward staleness. See [ADR-0002](wiki/decisions/0002-observed-state-collapses-duplicate-watch-events.md) for why content hashing is the default and what was rejected instead.\n\n**Cancelled-write idiom**\n\n`suppress` does not call `bump()` automatically. If a write is cancelled before it reaches the filesystem, no watcher event will fire and `txId` will not advance. Call `bump()` explicitly so downstream consumers are still notified that state may have changed:\n\n```ts\nimport { OptimisticWatcher, ExpectedChanges } from \"@genvidtech/mcp-utils\";\n\nconst expected = new ExpectedChanges();\nconst watcher = new OptimisticWatcher({\n  watchDirs: [\"/project/data\"],\n  expected,\n  onExternalChange: (filePath) => invalidateCache(filePath),\n});\nwatcher.start();\n\n// Normal write: suppress window + pre-registered path cover both layers\nasync function writeFile(targetPath: string, content: string) {\n  try {\n    await watcher.suppress(async () => {\n      watcher.expect(targetPath);          // Layer 2 pre-registration\n      await validate(content);             // may throw before any write\n      await fs.writeFile(targetPath, content);\n    });\n  } catch (err) {\n    watcher.bump();  // cancelled write still invalidates caches\n    throw err;\n  }\n}\n\n// Later:\nwatcher.stop();\n```\n\nThe `watcherFactory` option (type `WatcherFactory`) accepts an injectable factory that starts a watcher and returns a `WatchHandle`. The default wraps `fs.watch({ recursive: true })`. Override it in tests to drive events programmatically without touching the filesystem.\n\nThe `observed` option (type `ObservedState | null`) controls Layer 3: omit it and a default `ObservedState` is constructed for you (Layer 3 is **on by default**); pass an instance to reuse a shared or custom-fingerprinted ledger; pass `observed: null` to opt out of Layer 3 entirely.\n\n### ObservedState\n\nA per-path content-fingerprint ledger: tracks whether a file's content has changed since it was last accounted for. It's `OptimisticWatcher`'s Layer 3 suppression primitive (above), and is exported standalone for the same check-and-record pattern elsewhere.\n\n```ts\nimport { ObservedState } from \"@genvidtech/mcp-utils\";\n\nconst observed = new ObservedState(); // maxEntries optional, default 1000\n\nobserved.isChanged(\"/path/to/file.json\"); // true — never seen before; also records it\nobserved.isChanged(\"/path/to/file.json\"); // false — content unchanged since the last check\n// ...file is edited...\nobserved.isChanged(\"/path/to/file.json\"); // true — content differs from what was recorded\n\nobserved.forget(\"/path/to/file.json\"); // stop tracking; next isChanged() call reports true again\n```\n\n`isChanged(filePath)` is check-and-record: it fingerprints the current content, compares it against the stored value, stores the new fingerprint either way, and returns whether they differed — mirroring `ExpectedChanges.consume`'s check-and-remove shape. A path that has never been seen is treated as changed. `record(filePath)` stores the current fingerprint unconditionally with no comparison or return value — use it to seal a path as \"accounted for\" without caring whether it changed.\n\nThe ledger is bounded by `maxEntries` (default 1000) with LRU eviction, so a long-running watch over a large tree doesn't grow it unboundedly; an evicted path simply reports changed again on its next check.\n\nThe default `Fingerprinter` is the exported `contentFingerprint`: a sha1 hex digest of the file's bytes. A missing file (`ENOENT`) fingerprints as the literal string `\"absent\"` — a deletion is a real, detectable change. Any other read failure (e.g. `EACCES`) fingerprints as a unique per-failure token that can never compare equal to any other reading. Every failure mode fails open toward reporting an *extra* change rather than risking a missed one.\n\nSupply your own `Fingerprinter` — `(filePath: string) => string` — via the constructor for a cheaper, less precise comparison:\n\n```ts\nimport { statSync } from \"node:fs\";\n\nconst observed = new ObservedState({\n  // Cheaper than hashing, but see the caveat below before adopting this one:\n  // two distinct same-size writes landing in the same timestamp tick compare\n  // equal, and a fingerprint collision means a real change is silently missed.\n  fingerprint: (filePath) => {\n    const { size, mtimeMs } = statSync(filePath);\n    return `${size}:${mtimeMs}`;\n  },\n  maxEntries: 500,\n});\n```\n\nThere is no `stat`-based fingerprinter built in, and the snippet above is an illustration of the seam rather than a recommendation. A fingerprinter that returns equal values for genuinely different content makes the ledger suppress a real change — staleness, which is the one failure this primitive exists to prevent, and the reason hashing content is the default. See [ADR-0002](wiki/decisions/0002-observed-state-collapses-duplicate-watch-events.md) for the measured collision rate that ruled it out as a shipped default.\n\n### TxToken\n\nA wire codec for a project-scoped transaction counter: `${projectId}:${n}`. This is the on-the-wire encoding of the same counter `OptimisticWatcher` tracks as `txId` (above) — five exports, all in `txToken.ts`, with zero imports.\n\n```ts\nimport {\n  isValidProjectId, formatTxToken, parseTxToken, compareTxToken,\n} from \"@genvidtech/mcp-utils\";\nimport type { TxToken } from \"@genvidtech/mcp-utils\";\n```\n\n- `formatTxToken(projectId, n)` — mints a token. **Throws `TypeError`** if `projectId` fails `isValidProjectId` or `n` is not a non-negative safe integer (`Number.isSafeInteger`). This is the module's one deliberate exception to the package's never-throw contract: the input comes from the server's own construction path, not off the wire, so failing loudly here is correct.\n- `parseTxToken(token)` — parses a client-supplied token. **Total**: returns `{ projectId: string; n: number } | null` and never throws, even for non-string input. `n` must be in strict canonical decimal shape — no leading zeros, sign, whitespace, exponent notation, or hex — and a safe integer; a shape-valid but overlarge digit string (e.g. `\"alpha:9007199254740993\"`) also parses to `null` rather than coercing lossily.\n- `compareTxToken(token, projectId, currentN)` — parses `token` and reports whether both `projectId` and `n` match. Always a `boolean` (`false` for a malformed token), never `null`/`undefined`.\n- `isValidProjectId(id)` — `true` iff `id` is non-empty and contains no `:` and no whitespace; the same shape `formatTxToken` requires of a token's left half.\n\nFor any token that parses, `formatTxToken(parsed.projectId, parsed.n) === token` (round-trip invariant).\n\n```ts\nformatTxToken(\"alpha\", 3);              // \"alpha:3\"\nparseTxToken(\"alpha:3\");                // { projectId: \"alpha\", n: 3 }\nparseTxToken(\"alpha:03\");               // null — leading zero is rejected\nparseTxToken(\"not-a-token\");            // null — no delimiter\ncompareTxToken(\"alpha:3\", \"alpha\", 3);  // true\ncompareTxToken(\"alpha:3\", \"alpha\", 4);  // false\n```\n\n**Upper bound: `Number.MAX_SAFE_INTEGER` (2^53 − 1).** An `n` beyond it is rejected outright — `formatTxToken` throws, `parseTxToken` returns `null` — never silently truncated.\n\nThe `:` delimiter and the canonical shape of `n` are a wire contract shared with two named consumers, `GenvidTechnologies/c3-domain-manager` and `GenvidTechnologies/construct3-chef`, not an implementation detail. See [ADR-0005](wiki/decisions/0005-tx-token-wire-format.md).\n\n### loadProjectConfig / isMcpError\n\nLoads a single JSON config file from a project root, merges in defaults and overrides, validates it against a [zod](https://zod.dev) schema you supply, and optionally asserts that nominated path fields stay within the project root. The schema and its DTO stay in the **consuming** package — this utility owns only the load + validate + contain mechanism. `zod` is a peer dependency; only `import type { ZodType }` is used here (the schema's `.parse()` runs on the object you pass in), so no second zod copy is pulled into your tree.\n\nIt does **not throw** on failure: a missing required file, JSON parse error, schema violation, or path escape all return an `mcpError` `CallToolResult` (with `isError: true`). On success it returns the validated config `T`. Use the `isMcpError` type guard to narrow the `T | CallToolResult` union — in an MCP tool handler you can propagate the error result straight through:\n\n```ts\nimport { z } from \"zod\";\nimport { loadProjectConfig, isMcpError } from \"@genvidtech/mcp-utils\";\n\nconst ConfigSchema = z.object({\n  extractedDir: z.string().default(\"build\"),\n  port: z.number().default(3000),\n});\n\nconst cfg = await loadProjectConfig(\n  projectRoot,\n  \"my-tool.config.json\",\n  ConfigSchema,\n  { port: requestArgs.port },        // overrides (highest precedence)\n  {\n    defaults: { extractedDir: \"dist\" },\n    containedPaths: [\"extractedDir\"], // must resolve within projectRoot\n    optional: true,                   // missing file → use defaults, don't error\n  },\n);\n\nif (isMcpError(cfg)) return cfg; // propagate parse/validation/containment failure\n// cfg is now the validated config (typed as z.infer<typeof ConfigSchema>)\nconsole.log(cfg.extractedDir, cfg.port);\n```\n\n**Merge precedence (highest → lowest):** `overrides` > file contents > `opts.defaults` > schema `.default()`. All layers are shallow-merged at the top level — nested objects are not deep-merged.\n\n**LoadConfigOpts**\n\n| Field | Type | Description |\n|---|---|---|\n| `containedPaths` | `(keyof T)[]` | Keys whose string values must resolve within `projectRoot` (via `resolveWithin`). Assertion-only — the value is returned as authored, not rewritten to an absolute path. Non-string values are skipped. |\n| `optional` | `boolean` | When `true`, a missing file (ENOENT) skips the file layer instead of erroring; defaults and schema `.default()` still apply. |\n| `defaults` | `Partial<T>` | Lowest-precedence values, merged under the file contents and `overrides`. |\n\nAll error messages are prefixed with `loadProjectConfig(<fileName>):` for unambiguous failure attribution. Schema validation failures append each zod issue (`<path>: <message>`) to the error text.\n\n## Requirements\n\nNode.js >= 22.\n",
  "bytes": 37783,
  "sha": "962d7ea8955aaa72d4ef45e50a2aa21b9919e97123d9c807e416692b6fd216ef",
  "repo_slug": "genvidtechnologies/mcp-utils",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/okf_genvidtechnologies_mcp_utils_wiki_index__497be27b/readme"
}