Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

perf(mcp): cache repeated external-MCP reads + harden 4 tool schemas

closed#2329CedarCopilot

CedarCopilot wants to merge 0 commits into staging from feat/mcp-surface-fixes

Not deployedTimeline and evidence
  1. Opened
    Jul 29, 2026, 12:22 PM
  2. Not deployed

    Not deployed

    Pending

Libra has no production signal for this change yet because it has not deployed. Libra checks hourly for 3 days after each deploy.

Why

A review of Cedar's external MCP surface (775 tool calls) found the dominant cost is repeated reads, not the 8 hard failures , e.g. one session loaded the drafting skill 8Ɨ in ~2.5 min, and re-fetched the same conversation 4Ɨ in 39s. This PR addresses both the repetition and the validation failures.

The external MCP server is built fresh per HTTP request (stateless mode , routes/external-mcp.ts sessionIdGenerator: undefined), so a client model that calls the same read tool several times over one conversation issues each as a separate request against a separate server. A closure cache can't span calls; only a process-level cache can , so this uses one, mirroring the existing getUserContext idiom (module-level Map, per-entry TTL, no cross-request invalidation), identity-scoped by the impersonation-effective userId.

Changes

Caching (process-level, identity-scoped, TTL-only)

  • load-skill , 5 min TTL keyed by user+skill+resource+scope. Skill instructions are per-user but don't change between calls seconds apart; 5 min self-heals after a playbook edit.
  • conversation-read get , 20 s TTL keyed by user+get-params, successful reads only. The short TTL is a deliberate tradeoff: the surface is multi-instance (ECS) with no cross-instance write-invalidation, so TTL is the only staleness bound , 20 s collapses the sub-minute re-read bursts while keeping live CRM reads at most ~20 s stale. Only get is ever cached, so no write path serves stale data through the cache.

Schema hardening (shared tools → fixes external and in-app surfaces)

  • mutation: accept mutation.type as an alias for the operation discriminator, and copy a top-level conversationId down into the branch that needs it (normalizeMutationInput via z.preprocess). Fixes 2 of the 8 failures.
  • document write type: defaults to "document" instead of being required.
  • draftCategory: an unrecognized value (e.g. productQuestions) is dropped, not rejected (.catch(undefined)) , it's an optional tag, never load-bearing.

All three publish unchanged JSON schemas (preprocess/default/catch unwrap), so connected clients need no reload.

Observability

  • recent-executions rows now carry a truncated failureReason for failed runs, so triage no longer needs an execution-detail drill-in per failure (the report saw 33 such follow-ups).

Deliberately not changed

  • user_request_summary stays required. It's required by design (tool-call-observability.ts) precisely so the model fills it , a miss is a self-correcting validation error, not a real failure. Defaulting it would gut the observability signal.
  • debug.health-report already returns success (DebugOutputSchema), so the reported "add success" item is already satisfied , no change.

Tests

22 new tests (cache TTL/eviction, normalizeMutationInput + preprocess wiring, truncateFailureReason, document-type default, draftCategory tolerance). All 434 tests across the touched modules pass; typecheck clean.

šŸ¤– Generated with Claude Code

Greptile Summary

This PR reduces repeated external-MCP reads and makes several tool inputs more tolerant.

  • Adds identity-scoped, process-level TTL caching for external load-skill and conversation-read get calls.
  • Normalizes common conversation-mutation input mistakes and defaults or discards optional document/draft fields.
  • Adds truncated failure reasons to recent execution summaries and updates debug-tool descriptions.
  • Adds focused tests for cache behavior, schema normalization, defaults, and failure-reason formatting.

Confidence Score: 3/5

The PR should not merge until both external-MCP cache keys distinguish the effective organization and cannot return content authorized for a user's previous tenant.

Cache hits bypass the current organization-aware execution paths, while the keys retain only the

Show production surfaces and changed-file mapping

Production surfaces

Libra has not measured any production surfaces for this change yet.

Changed files → surfaces

  • apps/server/src/mastra/mcp/external/__tests__/tool-result-cache.test.tsno production surface mapped
  • apps/server/src/mastra/mcp/external/load-skill-tool.tsno production surface mapped
  • apps/server/src/mastra/mcp/external/server.tsno production surface mapped
  • apps/server/src/mastra/mcp/external/tool-result-cache.tsno production surface mapped
  • apps/server/src/mastra/skills/account-config/tools/__tests__/executeConversationMutationTool.schema.test.tsno production surface mapped
  • apps/server/src/mastra/skills/account-config/tools/__tests__/getRecentExecutionsTool.failure-reason.test.tsno production surface mapped
  • apps/server/src/mastra/skills/account-config/tools/executeConversationMutationTool.tsno production surface mapped
  • apps/server/src/mastra/skills/account-config/tools/getRecentExecutionsTool.tsno production surface mapped
  • apps/server/src/mastra/tools/debug/debugTool.tsno production surface mapped
  • apps/server/src/mastra/tools/document/__tests__/documentTool.write-type-default.test.tsno production surface mapped
  • apps/server/src/mastra/tools/document/documentTool.tsno production surface mapped
  • apps/server/src/mastra/tools/draft-comms/__tests__/draftCommsTool.category-catch.test.tsno production surface mapped
  • apps/server/src/mastra/tools/draft-comms/draftCommsTool.tsno production surface mapped