State Sync
Prerequisites
Install Vurb.ts before following this guide: npm install @vurb/core @modelcontextprotocol/sdk zod — or scaffold a project with vurb create.
- Introduction
- Inline Fluent API
- Registry-Level Policies
- How It Works
- Cache Directives
- Cross-Domain Invalidation
- Glob Patterns
- Observability
- Overlap Detection
- Performance
- API Reference
Introduction
LLMs have no sense of time. After calling sprints.list and then sprints.create, the agent still believes the list is unchanged — nothing told it the data is stale. It makes decisions on outdated information.
Vurb.ts's State Sync injects RFC 7234-inspired cache-control signals into MCP responses, guiding the agent to re-read after mutations. LLMs are trained on web pages with HTTP cache headers — they interpret no-store as "re-fetch before using" and immutable as "never changes." Zero overhead when not configured.
Inline Fluent API
The simplest way to declare state sync is directly on the tool builder:
import { initVurb } from '@vurb/core';
const f = initVurb<AppContext>();
// Reference data — safe to cache forever
const listCountries = f.query('countries.list')
.describe('List all country codes')
.cached()
.handle(async (input, ctx) => {
return ctx.db.countries.findMany();
});
// Volatile data — always re-fetch before acting on it
const listSprints = f.query('sprints.list')
.describe('List workspace sprints')
.stale()
.handle(async (input, ctx) => {
return ctx.db.sprints.findMany({ where: { tenantId: ctx.tenantId } });
});
// Mutation — invalidates cached data on success
const createSprint = f.action('sprints.create')
.describe('Create a new sprint')
.invalidates('sprints.*')
.withString('name', 'Sprint name')
.handle(async (input, ctx) => {
return ctx.db.sprints.create({ data: { name: input.name } });
});
// Cross-domain invalidation — tasks affect sprints too
const updateTask = f.action('tasks.update')
.describe('Update a task')
.invalidates('tasks.*', 'sprints.*')
.withString('id', 'Task ID')
.withOptionalString('title', 'New title')
.handle(async (input, ctx) => {
return ctx.db.tasks.update({
where: { id: input.id },
data: { title: input.title },
});
});| Method | Cache Directive | Use When |
|---|---|---|
.cached() | immutable | Reference data — country codes, timezones, enums |
.stale() | no-store | Volatile data — always re-fetch before acting |
.invalidates(...patterns) | Causal signal | Mutations — tell the agent what data changed |
TIP
Inline methods are the recommended approach for simple tools. For complex policies (dozens of tools, cross-tool dependencies), use registry-level configuration instead.
Registry-Level Policies
For full control over cache policies across your entire server, configure stateSync at the registry level:
import { ToolRegistry } from '@vurb/core';
const registry = new ToolRegistry<AppContext>();
registry.registerAll(sprintsTool, tasksTool, countriesEnumTool);
registry.attachToServer(server, {
contextFactory: (extra) => createAppContext(extra),
stateSync: {
defaults: { cacheControl: 'no-store' },
policies: [
{ match: 'sprints.update', invalidates: ['sprints.*'] },
{ match: 'sprints.create', invalidates: ['sprints.*'] },
{ match: 'sprints.delete', invalidates: ['sprints.*'] },
{ match: 'tasks.update', invalidates: ['tasks.*', 'sprints.*'] },
{ match: 'countries.*', cacheControl: 'immutable' },
],
},
});Two things happen automatically: tools/list descriptions get cache directives appended, and successful mutations prepend invalidation signals to responses.
How It Works
Description decoration — the LLM sees cache directives inline:
"Manage workspace sprints. [Cache-Control: no-store]"
"List country codes. [Cache-Control: immutable]"Causal invalidation — after a successful mutation, a system block is prepended:
{
"content": [
{ "type": "text", "text": "[System: Cache invalidated for sprints.*, tasks.* — caused by tasks.update]" },
{ "type": "text", "text": "{\"ok\": true}" }
]
}Failed mutations (isError: true) emit no invalidation — the state didn't change.
Cache Directives
'no-store' — dynamic data, may change at any time. 'immutable' — reference data, never changes. No max-age because LLMs have no internal clock.
Cross-Domain Invalidation
A task update changes the sprint's task count. Declare the causal dependency:
// Inline:
const updateTask = f.action('tasks.update')
.invalidates('tasks.*', 'sprints.*')
.handle(async (input, ctx) => { /* ... */ });
// Or via policies:
policies: [
{ match: 'tasks.update', invalidates: ['tasks.*', 'sprints.*'] },
{ match: 'tasks.create', invalidates: ['tasks.*', 'sprints.*'] },
]After tasks.update succeeds: [System: Cache invalidated for tasks.*, sprints.* — caused by tasks.update]
Glob Patterns
* matches one segment. ** matches zero or more segments.
| Pattern | Matches | Doesn't match |
|---|---|---|
sprints.get | sprints.get | sprints.list |
sprints.* | sprints.get, sprints.update | sprints.tasks.get |
sprints.** | sprints.get, sprints.tasks.get | tasks.get |
Policies are first-match-wins. A broad pattern before a narrow one swallows it:
policies: [
{ match: 'sprints.get', cacheControl: 'immutable' }, // wins for sprints.get
{ match: 'sprints.*', cacheControl: 'no-store' }, // wins for other sprints.*
]Unmatched tools use defaults.cacheControl. No defaults = no decoration.
Observability
onInvalidation receives events for logging or metrics:
stateSync: {
policies: [
{ match: 'billing.pay', invalidates: ['billing.invoices.*', 'reports.balance'] },
],
onInvalidation: (event) => {
console.log(`[invalidation] ${event.causedBy} → ${event.patterns.join(', ')}`);
metrics.increment('cache.invalidations', { tool: event.causedBy });
},
}InvalidationEvent: causedBy (string), patterns (readonly string[]), timestamp (ISO-8601). Observer exceptions are silently caught.
notificationSink emits MCP notifications/resources/updated for each invalidated domain:
notificationSink: (notification) => {
server.notification(notification);
}
// → { method: 'notifications/resources/updated', params: { uri: 'Vurb.ts://stale/sprints.*' } }Fire-and-forget. Async rejections are swallowed.
Overlap Detection
detectOverlaps() catches policy ordering bugs at startup:
import { detectOverlaps } from '@vurb/core';
const warnings = detectOverlaps([
{ match: 'sprints.*', cacheControl: 'no-store' },
{ match: 'sprints.update', invalidates: ['sprints.*'] }, // shadowed!
]);
for (const w of warnings) {
console.warn(`Policy [${w.shadowingIndex}] shadows [${w.shadowedIndex}]: ${w.message}`);
}TIP
Run detectOverlaps() in your dev or startup script. It catches shadowed policies that are otherwise silent bugs — the narrow policy never fires because a broader one matches first.
Performance
Policy resolution: O(P) first call, O(1) cached. tools/list decoration: O(1) per tool (cached). tools/call invalidation: O(1) (cached). Memory capped at 2048 entries with full eviction on overflow. Glob matcher has MAX_ITERATIONS = 1024 against adversarial patterns. All ResolvedPolicy objects are frozen. Policies validated at construction time.
API Reference
interface StateSyncConfig {
policies: SyncPolicy[];
defaults?: { cacheControl?: CacheDirective };
onInvalidation?: (event: InvalidationEvent) => void;
notificationSink?: (notification: ResourceNotification) => void | Promise<void>;
}
interface SyncPolicy {
match: string;
cacheControl?: CacheDirective;
invalidates?: string[];
}matchGlob(pattern, name) — pure function for dot-separated glob matching. PolicyEngine — advanced class for custom pipelines: new PolicyEngine(policies, defaults).resolve('sprints.get').