Skip to content

AppEvaluationFailed: cache.forAccount() puts OAuth grant fields in cache keys and exceeds the 8 KB keyBytes limit (Cloudflare MCP) #2146

Description

@Minoo7

Summary

On 2.0.0-beta.4 self-host, an MCP app fails tool discovery with AppEvaluationFailed ("Executor could not load this app's tool definitions") when its account has a large OAuth grant. The real cause is CacheError { reason: "capacity" } from the cache-key size check. cache.forAccount(account) puts the account's full credential fields into the cache scope, and every key is canonicalJson([scope, key]), capped at keyBytes: 8192.

Cloudflare's MCP OAuth (https://mcp.cloudflare.com/mcp) advertises 384 scopes (~7.7 KB). The stored grant is just under the limit, so the short …/current key passes and the first catalog page write (…/<revision>/tool/<name>) goes over.

Reproduction

The app is the generated MCP app for Cloudflare:

export default defineApp({ accounts: { service: provider.many() } }, async ({ accounts, signal, cache }) =>
  accountOperations(accounts.service, async (account) => mcpOperations({
    url: "https://mcp.cloudflare.com/mcp?codemode=false",
    cache: cache.forAccount(account),
    accountId: account.id,
    headers: { Authorization: "Bearer " + account.fields.access_token },
    signal,
  }), { signal }),
)
  1. Connect a Cloudflare account through OAuth (oauth2({ discover: "https://mcp.cloudflare.com/mcp" })).
  2. Open the Tools page, or run tools.search over MCP.
  3. Result: app.inspect fails with HostEvaluationFailed, which surfaces as AppEvaluationFailed. No catalog rows are written; only a released lease row appears in the app's executor_cache.

It also fails without ?codemode=false (3 tools), so catalog size is not the cause.

Evidence:

  • Wrapping the cache passed to mcpOperations shows the first write (64 entries, ~64 KB) rejected with CacheError capacity. The stack points at the key-size check in the app-side cache client (vi/Gs in node_modules/apps/chunk-3JO5LYEW.js: if (n.byteLength > St.keyBytes) return yield* new ce({ reason: "capacity" })).
  • In a standalone workerd harness running the real apps/main.js runtime and DO cache, credential fields around 7.7 KB pass and fields around 8.1 KB fail with the same HostEvaluationFailed.
  • The same app with cache (shared scope) instead of cache.forAccount(account) loads all 3,595 tools and calls work.

Expected

  • Cache scopes should not embed credential fields. Use the account ID, or a digest of the account and credentials, so key size doesn't depend on grant size and secrets don't enter key material.
  • A CacheError during inspect should keep its identity (or at least reach telemetry/logs). Today cn in chunk-3JO5LYEW.js collapses it into HostEvaluationFailed, and the self-host logs and motel spans show no cause.

Workaround

Pass cache instead of cache.forAccount(account) for MCP apps. The mcp-catalog-v2 prefix already fingerprints url, headers (including the token) and accountId, so entries stay per account.

Environment

  • ghcr.io/usefulsoftwareco/executor-selfhost:beta, EXECUTOR_BUILD_VERSION=2.0.0-beta.4, workerd 2026-09-01
  • Provider: Cloudflare MCP OAuth (384 advertised scopes)

🤖 Generated with Claude Code

Activity

  1. ntindle commented on Oct 8, 2026

    @ntindle

    Another way the same defect shows up, on 2.0.0-beta.8 self-host: the cache key changes on every call, so an app's cache fills to the 128 MB totalBytes limit, and from then on every call to that app fails.

    Setup. MCP apps (Linear's and PostHog's servers) whose provider sets hosts, so the token reaches app code as a sealed handle, with the usual pattern:

    mcpRouter({
      url: MCP_URL,
      cache: cache.forAccount(account),
      accountId: account.id,
      headers: { Authorization: "Bearer " + account.fields.access_token },
      signal,
    })

    What we saw

    • After working for a while, every call to the app returns AppEvaluationFailed: "The app threw CacheError: CacheError was thrown without a message". The reason is not shown.
    • The Linear app's cache usage row (executor_cache_usage) read 127,994,086 bytes in 41,110 entries, against totalBytes: 128e6. Every entry had a different version.
    • Linear's catalog is 86 entries (about 266 KB). The same 86 entry sizes each appear 478 times, so the cache held 478 copies of one catalog. 464 of them were written in one hour of heavy use (many agents calling the app in parallel).
    • The PostHog app filled the same way: 127,819,507 bytes in 9,273 entries.
    • Three more apps built the same way were filling the same way (42 to 94 copies each) and stopped growing once cache was removed. Two quick-added apps (no hosts, token readable by app code) hold one copy each.

    Likely cause. As above, the catalog key fingerprints headers, token included. With hosts set, the Authorization header holds the sealed handle, which appears to differ on every invocation, so no key repeats: each call writes the whole catalog again and nothing is read back, until retention or the size limit. If so, the workaround above (cache instead of cache.forAccount(account)) would not help apps with hosts, since the header is still in the key (not tested).

    Expected, in addition to the above

    • The key should not depend on credential material at all (the forAccount docs say token renewal keeps the entries).
    • A full cache should evict old entries, or the app should run without the cache, rather than fail every call.
    • The CacheError should carry its reason (capacity here).

    Workaround. Remove cache from mcpRouter. The apps then list the catalog on each call (about 1.5 s for Linear) and work.

  2. RhysSullivan commented on Oct 9, 2026

    @RhysSullivan
    Collaborator

    Thanks for the detailed report and the follow-up.

    This is fixed in 2.0.0-beta.5. cache.forAccount(account) now scopes entries by the account's ID and credential generation only. Credential fields never go into the scope or key, so a large OAuth grant like Cloudflare's 384 scopes no longer pushes keys past the 8 KB limit. The MCP catalog key is now just the server URL. Headers, including sealed hosts handles, only pick the account's scope and are never part of the key. That should also stop the repeated catalog copies you saw with Linear and PostHog. Token renewal keeps the entries, as the docs describe.

    To pick it up, upgrade to beta.5 or later (beta.6 is current). After that, you can put cache: cache.forAccount(account) back in mcpRouter.

    The two remaining points, CacheError not showing its reason and a full cache failing every call, are tracked in #2229. Closing this one.

    Sent from my Claude

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions