koala73/worldmonitor · error

cache_all_null

Error message

cache_all_null

What it means

Thrown by executeTool in api/mcp/dispatch.ts when a cache-backed MCP tool has at least one _cacheKeys entry and EVERY Redis read for those keys returned null/undefined. This is the F6 contract: an all-null read set is a degenerate-empty result (Redis transient failure, cache stampede, or keys never seeded), not a legitimate empty dataset — a genuine empty list is a JSON value, not null. It is deliberately checked after tool execution, so for Pro callers the already-reserved daily slot stays charged; dispatchToolsCall then reports it as a normal tool-execution failure (-32603 class), not a quota refund.

Solutions

  1. Retry the same tools/call after a short delay — transient Redis nulls and stampede windows usually clear in seconds
  2. Check the health surfaces (/api/health, /api/seed-health) for the dataset's key freshness and seed-meta markers to confirm the keys exist at all
  3. If keys are genuinely absent, run the corresponding seed script (scripts/ follow seed metadata rules; they must write seed-meta:<key>)
  4. Verify UPSTASH_REDIS_REST_URL/TOKEN on the MCP edge point at the same store the producers write to
Defensive patterns

Strategy: retry

Validate before calling

// Optional pre-flight: confirm the tool's cache keys exist before paying for the call
// (health endpoint reports dataset freshness; seed-meta:<key> markers prove a seed ran)
const health = await fetch('https://<host>/api/health').then(r => r.json());
const datasetFresh = health?.datasets?.[datasetKey]?.fresh === true;
if (!datasetFresh) skipOrDefer('dataset not seeded');

Type guard

function isCacheAllNull(e) {
  return e instanceof Error && e.message === 'cache_all_null';
}

Try / catch

async function callCacheTool(name, args, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try { return await client.callTool(name, args); }
    catch (e) {
      if (isCacheAllNull(e) && i < attempts - 1) {
        await sleep(500 * 2 ** i + Math.random() * 250); // transient Redis/stampede window
        continue;
      }
      throw e;
    }
  }
}

Prevention

When it happens

Trigger: Calling any cache-backed tools/call (tools declared with _cacheKeys in the registry) while: the Upstash Redis REST endpoint is degraded/unreachable and reads resolve null; a cache stampede or eviction wiped all the tool's keys mid-request; the deployment never ran the seed scripts so the keys do not exist yet.

Common situations: Fresh environment/worktree where seed scripts were not run (missing seed-meta:<key> markers). Upstash outage or rate limiting returning nulls. TTL expiry of all of a tool's keys between producer runs. Region/env mismatch — MCP edge pointing at a different Upstash database than the one the producers write.

Related errors


AI-assisted analysis of koala73/worldmonitor@e586b8b4b8 (2026-08-21). Data as JSON: /api/errors/c1165ee7473ad73f. Report an issue: GitHub.

Appendix: source

Thrown at api/mcp/dispatch.ts:139

    metas,
    evaluatedAt,
    activationStates,
  );

  // F6: if every cache key returned null/undefined AND the tool actually
  // had keys configured, this is a degenerate-empty result (Redis transient
  // / stampede). Throw so dispatchToolsCall reports a normal tool-execution
  // failure; for Pro callers the already-reserved daily slot stays charged
  // because this check runs after the tool has executed.
  //
  // Cache-tools always have at least one key (validated in the registry
  // type). The all-null case is structurally distinguishable from "the
  // upstream returned an empty list" (which is a JSON value, not null).
  if (
    tool._cacheKeys.length > 0 &&
    results.every((v: unknown) => v === null || v === undefined)
  ) {
    throw new Error('cache_all_null');
  }

  const data: Record<string, unknown> = {};
  // Walk backward through ':'-delimited segments, skipping non-informative suffixes
  // (version tags, bare numbers, internal format names) to produce a readable label.
  const NON_LABEL = /^(v\d+|\d+|stale|sebuf)$/;
  tool._cacheKeys.forEach((key, i) => {
    const parts = key.split(':');
    let label = '';
    for (let idx = parts.length - 1; idx >= 0; idx--) {
      const seg = parts[idx] ?? '';
      if (!NON_LABEL.test(seg)) { label = seg; break; }
    }
    data[tool._cacheLabels?.[key] || label || (parts[0] ?? key)] = results[i];
  });

  // Optional in-memory post-filter (declared per-tool, mirrors that tool's
  // inputSchema.properties). A filter bug must NEVER break the tool — on throw

View on GitHub (pinned to e586b8b4b8)