@lensmcp/core
v1.7.0
Published
LensMCP core — event bus, resource store, graph store, and pattern detectors.
Maintainers
Readme
@lensmcp/core
The framework-agnostic core of LensMCP — the event bus, reducers, and stores that turn raw agent-observability events into a live graph and live resources.
LensMCP is an observability lens for coding agents. @lensmcp/core is the engine underneath it: a tiny in-process event bus, a reducer registry that runs on top of the bus, and the two stores it feeds — a multi-axis graph store and a diff-aware resource store. It takes the normalised events defined in @lensmcp/protocol-types and turns them into the traversable graph, flow "stories", and pattern matches that the MCP server and dashboard expose.
Everything here is pure and isomorphic — no Node-only APIs, no framework dependencies (the lone runtime dependency is @lensmcp/protocol-types). It runs the same in a NestJS process, a Vite/React app, or a worker. Wiring (which reducers feed which stores) is left to the host; this package supplies the primitives.
Install
yarn add @lensmcp/core@lensmcp/protocol-types is a peer concept — it ships as a direct dependency and provides the BaseEvent, TraceNode, TraceEdge, and EdgeAxis types used throughout.
Usage
Event bus + reducers + a resource
import {
EventBus,
ReducerRegistry,
ResourceStore,
GraphStore,
buildGraphFromEvents,
} from '@lensmcp/core';
import type { BaseEvent } from '@lensmcp/protocol-types';
const bus = new EventBus({ capacity: 10_000 });
const graph = new GraphStore();
const resources = new ResourceStore();
const reducers = new ReducerRegistry();
// Expose the live graph as a readable resource.
const events: BaseEvent[] = [];
resources.register('graph://summary', () => ({
nodes: graph.nodeCount(),
edges: graph.edgeCount(),
}));
// A reducer ingests events into the graph and marks the resource dirty.
reducers.register('graph', async (event) => {
events.push(event);
buildGraphFromEvents(graph, events);
await resources.markDirty('graph://summary');
});
// Feed every published event through the reducers.
bus.subscribe((event) => reducers.dispatch(event));
// React to resource changes (e.g. push an MCP notification).
resources.subscribe('graph://summary', (uri, revision) => {
console.log(`${uri} changed → revision ${revision}`);
});
bus.publish(someBaseEvent);
const summary = await resources.read('graph://summary');Traverse the graph along a lens
import { GraphStore } from '@lensmcp/core';
const graph = new GraphStore();
// ...nodes/edges populated by buildGraphFromEvents...
const { nodes, edges } = graph.traverse({
from: rootNodeId,
lens: ['render', 'state'], // one EdgeAxis or several
direction: 'both', // 'out' | 'in' | 'both'
depth: 4,
maxNodes: 1000,
});Compile a flow story and detect patterns
import { compileStory, detectNPlusOne, detectRenderStorm } from '@lensmcp/core';
const story = compileStory(events, { flowId });
const issues = [
...detectNPlusOne(events, { threshold: 3 }),
...detectRenderStorm(events, { threshold: 5, windowMs: 250 }),
];API
Event bus — event-bus.ts
| Export | Description |
| --- | --- |
| EventBus | In-process, single-threaded, bounded event bus. publish(event), subscribe(handler, filter?) → Unsubscribe, droppedCount(), subscriberCount(), capacity. Handler errors (sync and async) are swallowed so one subscriber can't break the bus. |
| EventBusOptions | { capacity?: number; onDrop?: (droppedCount: number) => void } — bounded queue size (default 10_000) and an overflow hook. |
| EventFilter | (event: BaseEvent) => boolean — optional per-subscription predicate. |
| EventHandler | (event: BaseEvent) => void | Promise<void> — subscriber callback. |
Reducer registry — reducer-registry.ts
| Export | Description |
| --- | --- |
| ReducerRegistry | Ordered set of named reducers. register(name, reducer) (throws on duplicate name), list() → readonly ReducerEntry[], dispatch(event) runs every reducer against one event (errors swallowed). |
| Reducer | (event: BaseEvent) => void | Promise<void> — consumes an event and may mutate stores / mark resources dirty. |
| ReducerEntry | { name: string; reducer: Reducer }. |
Graph store — graph-store.ts
| Export | Description |
| --- | --- |
| GraphStore | Typed multi-axis trace graph (in-memory, hot-tier). addNode, updateNode, getNode, addEdge, getEdge; indexed lookups childrenOf, nodesInFlow, nodesByLogical, nodesByInstance, nodesByRequest; traverse(opts); nodeCount(), edgeCount(). |
| traverse (method) | Breadth-first lens traversal returning { nodes, edges }, bounded by depth (default 4) and maxNodes (default 1000). |
| Lens | EdgeAxis | readonly EdgeAxis[] — one or more axes to walk. |
| TraversalDirection | 'out' | 'in' | 'both'. |
| TraverseOptions | { from; lens; depth?; direction?; maxNodes? }. |
| TraverseResult | { nodes: TraceNode[]; edges: TraceEdge[] }. |
Graph from events — graph-from-events.ts
| Export | Description |
| --- | --- |
| buildGraphFromEvents(store, events) | Bridge from the flat event log to the multi-axis GraphStore. Promotes render / state-update / server-request / user-action / loop events to nodes, then wires edges two ways: explicit causedByNodeId edges on each node's natural axis, plus a same-flow chronological spine on the caused axis. Returns GraphBuildResult. |
| GraphBuildResult | { nodeCount: number; edgeCount: number }. |
Resource store — resource-store.ts
| Export | Description |
| --- | --- |
| ResourceStore | Registry of URI-addressed, lazily-produced resources with diff-aware revisioning. register(uri, produce) → Unsubscribe, read(uri), list(), markDirty(uri) (recomputes JSON, bumps the revision only if it changed, then notifies), revision(uri), subscribe(uri, handler) → Unsubscribe. |
| ResourceValue | unknown — anything JSON.stringify can serialise. |
| ResourceProducer | () => ResourceValue | Promise<ResourceValue>. |
| ResourceUpdatedHandler | (uri: string, revision: number) => void. |
Story builder — story.ts
| Export | Description |
| --- | --- |
| compileStory(events, options?) | Compile one flow's events into a FlowStory — an ordered set of phases (user-action → loading-ui → backend-request → state-update → final-render) plus origin and final-state summary. Pass { flowId } or let it pick the first flow it sees. |
| groupByFlow(events) | Map<flowId, BaseEvent[]>; events without a flowId are dropped. |
| FlowStory | The compiled record: flowId, origin, phases, finalState?, startedAt, endedAt, eventCount. |
| StoryPhase / StoryPhaseKind | A single phase and its kind union (user-action, loading-ui, idle-ui, route-transition, state-update, backend-request, render, final-render, visual-violation). |
| CompileStoryOptions | { flowId? }. |
Pattern matching — patterns.ts
| Export | Description |
| --- | --- |
| detectDbInLoop(events, options?) | Flags loop events whose dbCallsInsideLoop exceeds the threshold (default 5). |
| detectNPlusOne(events, options?) | Flags identical DB query signatures repeated ≥ threshold times (default 3). |
| detectRenderStorm(events, options?) | Sliding-window detector: flags a component instance that rendered more than threshold times (default 5) within windowMs (default 250). |
| findSlowPath(roots, adjacency, durationOf) | Memoised, cycle-guarded DFS for the slowest cumulative chain; returns SlowPathResult. |
| PatternMatch / PatternKind | A detection result (kind, severity, title, evidence, details, detectedAt) and its kind union (db-in-loop, n+1-query, slow-path, render-storm, leak). |
| DbInLoopOptions / NPlusOneOptions / RenderStormOptions / SlowPathResult | Per-detector option and result shapes. |
Fingerprinting — fingerprint.ts
| Export | Description |
| --- | --- |
| fingerprint(input) | Stable, deterministic routing key for an event (kind:<FNV-1a-32 hex>). Built from kind, identity, optional file and detail — same root cause yields the same fingerprint even when line numbers drift. Not a security hash. |
| FingerprintInput | { kind; identity; file?; detail? }. |
Thresholds — thresholds.ts
| Export | Description |
| --- | --- |
| LensmcpThresholds | Tunable detector thresholds: dbInLoop, nPlusOne, renderStorm, slowRouteMs, slowRenderMs. |
| DEFAULT_THRESHOLDS | The defaults (5, 3, 5, 500, 16). |
| resolveThresholds(overrides?) | Merges a partial override over the defaults, ignoring non-finite / negative / non-numeric values so a malformed config can't disable detection. |
Ids — ulid.ts
| Export | Description |
| --- | --- |
| ulid(now?) | Dependency-free, time-sortable 26-char ULID-like id (48-bit time prefix + 80 bits of crypto.getRandomValues randomness, Crockford-Base32 alphabet). |
Common — common.ts
| Export | Description |
| --- | --- |
| Unsubscribe | () => void — the detach lambda returned by every subscribe-style API. |
How it fits
@lensmcp/core sits between the protocol and the consumers:
- Consumes
@lensmcp/protocol-types— theBaseEventstream and theTraceNode/TraceEdge/EdgeAxisgraph vocabulary are defined there; this package never invents its own event shapes. - Powers the LensMCP MCP server — the server registers reducers on the
ReducerRegistry, ingests live events through theEventBus, and surfaces theGraphStore, stories, and pattern matches as MCP tools andResourceStoreresources (with diff-aware update notifications). - Powers the dashboard — the same stores back the live graph, flow stories, and detected-issue views the dashboard renders.
Because the core is framework-agnostic and isomorphic, the host process owns all wiring: it decides which reducers run, which resources are registered, and how published events flow into dispatch.
Part of LensMCP. Apache-2.0.
