npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@genvid/mcp-utils

v0.5.0

Published

Dependency-light TypeScript utilities for building MCP (Model Context Protocol) servers: concurrency control, file-change tracking, text pagination, path/filesystem helpers, and MCP response/error/annotation helpers.

Readme

@genvid/mcp-utils

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

Installation

npm install @genvid/mcp-utils

zod is a peer dependency (^3.23.0) — only required if you use loadProjectConfig. Install it alongside this package:

npm install zod
import {
  ReadWriteLock, ExpectedChanges, paginateText,
  walkFiles, resolveWithin, resolveRootFolder, escapeRegExp, toPosixPath,
  mcpError, withMcpErrors, bufferingLogger, paginatedContent, mcpContent,
  READ_ONLY, REGENERATE, MUTATE, NON_IDEMPOTENT_READ,
  OptimisticWatcher, loadProjectConfig, isMcpError,
} from "@genvid/mcp-utils";

Utilities

ReadWriteLock

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

const lock = new ReadWriteLock();

// Multiple readers can run concurrently
const result = await lock.read(async () => {
  return readSharedState();
});

// Writers get exclusive access; queued reads wait until all writes drain
await lock.write(async () => {
  mutateSharedState();
});

ExpectedChanges

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

const expected = new ExpectedChanges(5000); // ttlMs optional, default 5000

// Register before writing
expected.add("/path/to/file.json");
try {
  await fs.writeFile("/path/to/file.json", newContent);
} finally {
  expected.remove("/path/to/file.json"); // clean up if watcher fires before expiry
}

// In your file watcher callback:
if (expected.consume(changedPath)) {
  return; // suppress — we triggered this change ourselves
}
handleExternalChange(changedPath);

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.

paginateText

Paginates large text content by line using a 1-based offset and limit. A trailing newline does not count as an extra line.

import { paginateText } from "@genvid/mcp-utils";

const result = paginateText("a\nb\nc\n", { offset: 2, limit: 1 });
// {
//   text: "b",
//   totalLines: 3,
//   offset: 2,
//   limit: 1,
//   hasMore: true,
// }

PaginationOptions

| Field | Type | Default | Description | |-------|------|---------|-------------| | offset | number | 1 | 1-based start line | | limit | number | all lines | Maximum lines to return |

PaginatedResult

| Field | Type | Description | |-------|------|-------------| | text | string | The requested slice of text | | returnedLines | number | Number of lines actually returned (0 for an out-of-range page) | | totalLines | number | Total line count of the input | | offset | number | Actual offset used | | limit | number | Actual limit used | | hasMore | boolean | True if lines remain after this page |

Logger type

A minimal logger interface used by MCP server utilities:

import type { Logger } from "@genvid/mcp-utils";

function setup(log: Logger) {
  log("server started");
}

walkFiles

Recursively 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) are re-thrown. Symlinked directories are not followed — only entries for which entry.isDirectory() returns true are recursed into.

import { walkFiles } from "@genvid/mcp-utils";

// String match: suffix / endsWith test
const jsonFiles = walkFiles("/project/data", ".json");

// Predicate match: arbitrary filter
const testFiles = walkFiles("/project/src", (p) => p.includes(".test."));

escapeRegExp / toPosixPath

Two lightweight string helpers.

escapeRegExp escapes all regex metacharacters in a string so it can be used as a literal pattern inside new RegExp(...).

toPosixPath converts all backslashes to forward slashes, producing a POSIX-style path. No-ops on paths that already use forward slashes.

import { escapeRegExp, toPosixPath } from "@genvid/mcp-utils";

const pattern = new RegExp(escapeRegExp("file.name[0]")); // literal match

const posix = toPosixPath("C:\\Users\\dev\\project"); // "C:/Users/dev/project"

resolveWithin

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

  • "" and "." resolve to base itself and are returned.
  • A rel that escapes base via .. segments, an absolute path outside base, or a cross-drive path on Windows all return null.
  • A filename that merely starts with .. without traversing upward (e.g. ..gitkeep) stays inside base and is returned.

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.

import { resolveWithin } from "@genvid/mcp-utils";

resolveWithin("/project", "src/index.ts"); // "/project/src/index.ts"
resolveWithin("/project", "../secret");    // null  — escapes base
resolveWithin("/project", "");             // "/project"

resolveRootFolder

Resolves a 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.

import { resolveRootFolder, isMcpError } from "@genvid/mcp-utils";

const result = resolveRootFolder({
  explicit: args.projectDir,        // highest precedence: CLI flag
  envVar: "MY_SERVER_PROJECT_DIR",  // second: environment variable
  marker: "project.c3proj",         // discovery: look for this entry in child dirs
  searchDepth: 2,                   // how many levels below cwd to search (default: 1)
});

if (isMcpError(result)) return result; // propagate any error
const { path, source } = result;
if (source === "cwd") {
  console.warn("No project root found; using cwd:", path);
}

ResolveRootFolderOpts

| Field | Type | Default | Description | |---|---|---|---| | 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. | | explicit | string | — | Highest-precedence override. Relative values are resolved against cwd; absolute values used as-is. No containment restriction — a ../sibling path is permitted. | | envVar | string | — | Name of an environment variable to check when explicit is absent. Same resolution rules as explicit. | | cwd | string | process.cwd() | Starting directory for discovery and the resolution base for relative explicit/envVar values. | | 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. |

ResolvedRoot

| Field | Type | Description | |---|---|---| | path | string | Absolute path to the resolved project root. | | 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. |

Resolution algorithm

  1. If opts.explicit is set and non-blank → return it (resolved to absolute). No containment restriction.
  2. Else if opts.envVar is set and the named env var is non-blank → return it (resolved to absolute). No containment restriction.
  3. Else search for a directory that contains opts.marker:
    • Check cwd itself (depth 0), then scan child directories up to opts.searchDepth.
    • Exactly 1 match → return it with source: "discovery".
    • 0 matches → fall through to step 4.
    • ≥2 matches → return mcpError (ambiguous root). Only cwd and its descendants are searched; discovery never escapes the base directory.
  4. Return cwd with source: "cwd" — no marker found anywhere.

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.

mcpError / withMcpErrors

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

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.

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? }:

  • 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.
  • 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.
  • prefix: string — passed through to mcpError (see above).
import { mcpError, withMcpErrors, bufferingLogger } from "@genvid/mcp-utils";

// Direct conversion of a caught error
try {
  await doWork();
} catch (err) {
  return mcpError(err, ["context: file write failed"]);
}

// Opt-in "Error:" prefix:
mcpError(new Error("boom"), { prefix: "Error:" });
// content[0].text === "Error: boom"

// Wrap a handler; extraLines thunk reads state at catch time
const { log, text } = bufferingLogger();
const handler = withMcpErrors(
  async (args) => {
    log("starting");
    await doWork(args);
    return { content: [{ type: "text", text: "ok" }] };
  },
  () => [text()],  // captures log output accumulated before the throw
);

// Options form: run a side-effect on the error path, then prefix the message
const mutateHandler = withMcpErrors(
  async (args) => mutateAndRespond(args),
  {
    onError: (err) => { if (err instanceof CancelledError) watcher.bump(); },
    prefix: "Error:",
  },
);

bufferingLogger

Creates 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".

import { bufferingLogger } from "@genvid/mcp-utils";

const { log, text } = bufferingLogger();
log("processed", 3, "files");
log("done");
text(); // "processed 3 files\ndone"

paginatedContent

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

import { paginatedContent } from "@genvid/mcp-utils";

const result = paginatedContent("a\nb\nc\n", { offset: 1, limit: 2 });
// result.content[0].text === "a\nb\n\nlines: 1-2 / 3"

// No offset/limit → no range footer:
paginatedContent("a\nb\nc\n", {});
// content[0].text === "a\nb\nc"

// Out-of-range page → "lines: 0 / N":
paginatedContent("a\nb\nc\n", { offset: 5, limit: 2 });
// content[0].text === "lines: 0 / 3"

// With an optional caller footer:
const withFooter = paginatedContent(
  "a\nb\nc\n",
  { offset: 1, limit: 2 },
  (r) => `hasMore: ${r.hasMore}`,
);
// withFooter.content[0].text === "a\nb\n\nlines: 1-2 / 3\nhasMore: true"

mcpContent

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

import { mcpContent } from "@genvid/mcp-utils";

mcpContent("wrote 3 files");
// content[0].text === "wrote 3 files"

mcpContent("wrote 3 files", `txId: ${txId}`);
// content[0].text === "wrote 3 files\ntxId: 7"

Tool annotation presets

Four ToolAnnotations constants for use when registering MCP tools. Each preset sets readOnlyHint, destructiveHint, and idempotentHint to reflect the tool's expected behavior.

import { READ_ONLY, REGENERATE, MUTATE, NON_IDEMPOTENT_READ } from "@genvid/mcp-utils";

server.tool("list-files", schema, READ_ONLY, handler);
server.tool("write-config", schema, REGENERATE, handler);
server.tool("delete-entry", schema, MUTATE, handler);
server.tool("consume-event", schema, NON_IDEMPOTENT_READ, handler);

| Preset | readOnlyHint | destructiveHint | idempotentHint | Use when | |---|---|---|---|---| | READ_ONLY | true | false | true | Reads state, no side effects, safe to repeat | | REGENERATE | false | false | true | Writes output but repeated calls produce the same result; nothing permanently lost | | MUTATE | false | true | false | Modifies or deletes data; cannot be trivially undone; result may differ across calls | | NON_IDEMPOTENT_READ | true | false | false | Reads without modification but each call may return different results (e.g. consuming a queue) |

OptimisticWatcher

Watches 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 and fs.watch({ recursive: true }) by default.

Two-layer suppression

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

Cancelled-write idiom

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:

import { OptimisticWatcher, ExpectedChanges } from "@genvid/mcp-utils";

const expected = new ExpectedChanges();
const watcher = new OptimisticWatcher({
  watchDirs: ["/project/data"],
  expected,
  onExternalChange: (filePath) => invalidateCache(filePath),
});
watcher.start();

// Normal write: suppress window + pre-registered path cover both layers
async function writeFile(targetPath: string, content: string) {
  try {
    await watcher.suppress(async () => {
      watcher.expect(targetPath);          // Layer 2 pre-registration
      await validate(content);             // may throw before any write
      await fs.writeFile(targetPath, content);
    });
  } catch (err) {
    watcher.bump();  // cancelled write still invalidates caches
    throw err;
  }
}

// Later:
watcher.stop();

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

loadProjectConfig / isMcpError

Loads a single JSON config file from a project root, merges in defaults and overrides, validates it against a zod 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.

It 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:

import { z } from "zod";
import { loadProjectConfig, isMcpError } from "@genvid/mcp-utils";

const ConfigSchema = z.object({
  extractedDir: z.string().default("build"),
  port: z.number().default(3000),
});

const cfg = await loadProjectConfig(
  projectRoot,
  "my-tool.config.json",
  ConfigSchema,
  { port: requestArgs.port },        // overrides (highest precedence)
  {
    defaults: { extractedDir: "dist" },
    containedPaths: ["extractedDir"], // must resolve within projectRoot
    optional: true,                   // missing file → use defaults, don't error
  },
);

if (isMcpError(cfg)) return cfg; // propagate parse/validation/containment failure
// cfg is now the validated config (typed as z.infer<typeof ConfigSchema>)
console.log(cfg.extractedDir, cfg.port);

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.

LoadConfigOpts

| Field | Type | Description | |---|---|---| | 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. | | optional | boolean | When true, a missing file (ENOENT) skips the file layer instead of erroring; defaults and schema .default() still apply. | | defaults | Partial<T> | Lowest-precedence values, merged under the file contents and overrides. |

All error messages are prefixed with loadProjectConfig(<fileName>): for unambiguous failure attribution. Schema validation failures append each zod issue (<path>: <message>) to the error text.

Requirements

Node.js >= 22.