Skip to content

feat(sdk): materialized resources — stored values that announce their own changes, from any process - #1015

Merged
alexmercerpo merged 9 commits into
feat/m12-dutiesfrom
feat/m13-materialized-resources
Nov 3, 2026
Merged

alexmercerpo merged 9 commits into
feat/m12-dutiesfrom
feat/m13-materialized-resources

Conversation

@frontegg-david

@frontegg-david frontegg-david commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1014

What and why

Proposal item 2.5: materialized resources.

The reported problem: a server folded events into a store of values (a value, a revision, an update time) and served
dozens of @Resource classes from it, but nothing sent notifications/resources/updated when a value changed, and
the processes writing the store (workers, reducers, a second server process) had no way to tell the server's
sessions. this.notifyResourceUpdated(uri) existed, but it is in-process only: a write from another process was
never seen. The live dashboard had to run its own SSE hub on the side.

What this PR adds:

  • resourceStore({ storage?, pollIntervalMs?, coalesceMs?, onError? }) and store.key<T>(name, { initial? }).
    A key holds { value, revision, updatedAt }: revision 0 and initial until the first write. set(value) and
    update(change) write atomically in every process that shares the store and add 1 to the revision; update
    re-reads and re-runs change when another write came first (a compare-and-set loop). Values are stored as JSON,
    initial included, so every read and write returns a JSON copy (a Date comes back as its ISO string), and an
    initial JSON cannot hold (a BigInt) throws InvalidResourceStoreError where the key is declared.
    subscribe(listener) calls the listener with the snapshot whenever the revision changes, and never after its stop
    function was called. storage is a createStorage() config on memory
    (the default), sqlite or redis (ResourceStoreStorage: the backends with an atomic compare-and-set; any
    other type, auto included, is refused in the type and the schema), picked through the storage factory, with
    fallback: 'error' unless set, so a SQLite store never silently becomes per-process memory. Options are validated with Zod; a bad one throws
    InvalidResourceStoreError (INVALID_RESOURCE_STORE) where the store is declared, and a backend that cannot open
    fails the server's boot.
  • @Resource({ uri, from: store.key(name) }), validated in the metadata schema (`from` must be a key of a resource store; from on a @ResourceTemplate is refused with a message). MaterializedResourceContext
    serves the value as it is: a string as text/plain, anything else as JSON (mimeType wins), with
    _meta['frontmcp/materialized'] = { revision, updatedAt } (MATERIALIZED_META_KEY). A class that extends it
    without from fails the boot. A plain ResourceContext with from keeps its own execute() and still gets the
    updates.
  • Subscriptions. A per-scope MaterializedResources (started after the notification service, like the live
    surface) follows the key of every materialized resource, including ones registered later, and turns a revision
    change into the registry's content change, so it reaches the sessions subscribed through resources/subscribe
    and the 2026-07-28 subscriptions/listen streams through the existing paths. It opens the stores at boot and
    closes them when the scope is disposed (and closes the ones it opened when another fails to open at boot);
    connect()/close() are counted, so a store two scopes serve stays open until both stop.
  • Cross-process change feed, behind one interface (createChangeFeed(storage, options) in @frontmcp/utils):
    • pubsub (Redis, Upstash, memory): a publish is a message on the topic's channel. When a lost Redis connection
      comes back (new optional StorageAdapter.onPubSubReconnect(), on the Redis adapter and namespaced storage),
      every followed topic is told, since its messages were missed meanwhile; a key listener then reads the key and is
      called only if the revision moved;
    • poll (SQLite): a publish increments the topic's counter; each process checks PRAGMA data_version (new
      SqliteStorageAdapter.dataVersion(), on the shared connection) every pollIntervalMs (250) and reads its
      topics' counters only when another process wrote the file. Stores on one file in one process share one
      connection, whose data version their own writes leave, so poll feeds in a process also tell each other of their
      publishes (a feed on another file with the same names hears it too, which costs its listeners one read; only a
      feed's own publishes and polls move the counters it has seen);
    • local (no pub/sub and no data version): this process only.
      Changes to one topic within coalesceMs (50) reach each listener once; a failing poll or subscription is
      reported once until it works again.
  • Atomic writes: a new optional StorageAdapter.setIfEquals(key, expected, value) (compare-and-set, like
    deleteIfEquals), on the memory, Redis (one Lua script) and SQLite (one BEGIN IMMEDIATE transaction) adapters
    and on namespaced storage; other adapters refuse it with StorageNotSupportedError.
  • Topic API for M16. store.subscribe(topic, listener) and store.publish(topic) are the same feed with no
    value attached (a key's topic is its name): a listener reads the current state. This is the input M16's SSE routes
    are meant to use.

Access control (the model, documented in create-resource):

  • Who is told of a change. resources/subscribe refuses what resources/read refuses: availableWhen and
    enabled (already), and now the resource's authorities, in a new hookable checkEntryAuthorities stage of
    resources:subscribe. A 2026-07-28 subscriptions/listen request runs each requested URI through the same
    resources:subscribe flow with the caller's auth, and a refused URI is left out of the acknowledgement (the spec
    lets the acknowledgement be a subset). An update of a resource under an acknowledged URI (the stream's prefix
    rule) is checked the same way before it is sent; an update of a URI no resource is named by still counts as part
    of the acknowledged resource. Each scope follows only its own resources, so with splitByApp an app's sessions
    hear only that app. A legacy session's subscription is checked when it subscribes; if enabled turns off later,
    the session gets resources/list_changed and its reads are refused.
  • Who can write. The write API is code, not an MCP method: no client can write a value. A client changes one
    only through a tool you write, and that call runs through tools:call-tool with the tool's authorities,
    enabled and hooks. Any process that can open the store's SQLite file or Redis can write, the same trust boundary
    as the rest of the server's data there.
  • Caching (2026-07-28). Decided: a materialized read keeps ttlMs: 0 (the resources/read default), since
    another process can change the value at any moment and subscribers learn of it from resources/updated;
    cacheScope follows the existing rule (the stored value is the same for every caller, so an anonymous read that
    nothing shapes per caller is public, at a TTL of 0). Tested end to end.

Also:

  • McpTestClient's resources.subscribe(uri) / unsubscribe(uri) were placeholders that logged a warning; they now
    send the requests and throw the server's error on a refusal.
  • resources:subscribe logs a request without a session (what a listen check sends) at verbose, not warn.

Different from the plan, and why:

  • Only @Resource (a fixed URI) takes from. A template with from is refused, not ignored. Serving a template
    from from: (params) => store.key(...) is the small part (the metadata schema and MaterializedResourceContext
    calling it with the URI's params). Telling its subscribers is not: the scope would have to follow the key of every
    URI someone is subscribed to, and nothing records that today across the two paths (the notification service holds
    legacy sessions' URIs; each subscriptions/listen stream keeps its own). It needs a subscribed-URI registry that
    resources:subscribe/unsubscribe, session close and listen streams feed, with a counted key watch per URI. M14
    can serve job://runs/{id} from a key per run read in its own execute(), and announce a run's changes with
    store.subscribe(topic) + notifyContentChanged(uri) for the runs it tracks, until that registry exists.
  • A compare-and-set instead of "M02's atomic ops" alone. incr gives a unique revision, but pairing it with the
    value needs a read-modify-write; without compare-and-set, two writers could store revision 5 after revision 6, and
    update(fn) could lose a fold. setIfEquals is the same shape as M02's deleteIfEquals.

Bundle size: see "Bundle size" below.

User-facing change

  • What changed:
    • New resourceStore(), ResourceStore, ResourceStoreOptions, ResourceStoreStorage, ResourceKeyOptions, ResourceKey,
      ResourceSnapshot, ResourceStoreHandle, isResourceKey, MaterializedResourceContext,
      MATERIALIZED_META_KEY in @frontmcp/sdk; new from option on @Resource.
    • New error InvalidResourceStoreError (INVALID_RESOURCE_STORE).
    • New createChangeFeed(), ChangeFeed, ChangeFeedKind, ChangeFeedOptions,
      DEFAULT_CHANGE_FEED_POLL_INTERVAL_MS, DEFAULT_CHANGE_FEED_COALESCE_MS in @frontmcp/utils; optional
      StorageAdapter.setIfEquals(), dataVersion() and onPubSubReconnect(); NamespacedStorage.setIfEquals()
      and onPubSubReconnect(); setIfEquals() implemented by the memory, Redis and SQLite adapters,
      dataVersion() by SQLite only, onPubSubReconnect() by Redis only.
    • New checkEntryAuthorities stage in resources:subscribe.
    • McpTestClient.resources.subscribe() / unsubscribe() work.
  • Before → after:
    • resources/subscribe to a resource whose authorities deny the caller was accepted → refused with the
      resources/read error (AuthorityDeniedError).
    • A subscriptions/listen request acknowledged every requested resource URI → only those the caller may subscribe
      to; an update of a resource under an acknowledged URI is sent only if the caller may subscribe to it.
    • mcp.resources.subscribe() in @frontmcp/testing logged "not yet implemented" → sends resources/subscribe.
    • Nothing else changes until a resource sets from.
  • frontmcp.dev pages affected (if known): the @Resource option reference (from), the resources guide (new
    "Materialized resources" section: the store, keys, revisions, cross-process updates, who is told, who can write,
    caching), the notifications page (resources/updated from a store, the listen-stream access checks), the error
    reference (INVALID_RESOURCE_STORE), the flow stage reference (resources:subscribe → checkEntryAuthorities),
    the storage page (setIfEquals, dataVersion, createChangeFeed), and the testing client page
    (resources.subscribe()).
  • Skills catalog entries updated:
    • frontmcp-development/references/create-resource.md (from option, new "Materialized Resources" section:
      values stored as JSON, a Redis reconnect re-reading the keys; common pattern, checklist and troubleshooting rows,
      example row);
    • new example frontmcp-development/examples/create-resource/materialized-resource.md (manifest and
      frontmcp-development/SKILL.md tables);
    • frontmcp-setup/references/setup-sqlite.md (setIfEquals, hearing other processes through data_version);
    • frontmcp-testing/references/test-e2e-handler.md (resources.subscribe() / unsubscribe()).

Tests

  • Unit, @frontmcp/utils (72 suites, 2053 pass):
    • set-if-equals.spec.ts: memory (replaces only while equal, drops the TTL, missing or expired keys, one of two
      concurrent calls wins), Redis (one script with the prefixed key), refused on Cloudflare KV and by default,
      namespaced storage prefixes and refuses over an adapter without it.
    • change-feed.spec.ts (fake timers): the backend picked from the storage; pub/sub coalesces a burst, publishes on
      the namespaced channel, hears a message on it, tells every followed topic (and not a left one) once the pub/sub
      connection is back and stops listening for that on close, reports a failed subscribe or unsubscribe; the poll
      tells a listener after another process's write moves data_version, reads counters only when the file moved, tells
      another feed in the process at once, still hears a later write after a feed on another file with the same names
      published, does not repeat its own publish, stops polling with the last listener and
      on close, does not report a poll that closing the feed and the storage cut short, reports a failing poll once
      until it recovers; defaults (250 ms, 50 ms); a throwing listener; close.
    • redis-adapter.spec.ts: onPubSubReconnect() listeners are called when the subscriber connection is ready again,
      not on its first connect, and not after they stop.
  • Unit, @frontmcp/storage-sqlite (12 suites, 243 pass): setIfEquals (equal, different, missing, expired,
    encrypted, holds the write lock from its read to its write against another connection) and dataVersion() moving
    on another connection's commit and not on its own.
  • Unit, @frontmcp/sdk (598 suites, 7844 pass):
    • resource-store.spec.ts: option validation (including upstash, auto and a missing storage type), key names,
      initial value at revision 0, read as a JSON copy a caller cannot change, an initial JSON cannot hold refused
      where the key is declared, a write returning the snapshot a read then serves, revisions and times, ten
      concurrent update()s lose nothing, a throwing change writes nothing, key listeners told once per revision and
      not for a publish that left it, no key listener called after it unsubscribed while a read was under way, a key
      without a store refused by isResourceKey, topic listeners and unsubscribe, a throwing listener reported and
      told once, an unreadable value reported, counted connect()/close(), a backend that cannot open is an error,
      and on SQLite: another store on the file reads the value and its listener is told of the write.
    • materialized-resource.spec.ts: through connect(), the read serves JSON or text with _meta revision and time;
      a subscribed session gets resources/updated on a tool's update() and none after it unsubscribes; a session
      that closed gets nothing while another still does. MaterializedResources: follows a resource registered later,
      stops, keeps a store open while another scope serves it, fails the boot for a store that cannot open (closing the
      stores it opened, and keeping the start error when one fails to close) or a MaterializedResourceContext class
      without from, warns for a later resource whose store cannot open.
    • listen-resource-access.spec.ts (2026-07-28): a store write reaches a listen stream; the acknowledgement leaves
      out a resource denied by authorities and an unknown URI; a denied sub-resource's update is not sent, a
      sub-URI that names no resource and the acknowledged resource are.
    • resource-subscribe.availability.spec.ts: resources/subscribe refuses a resource denied by authorities with
      the resources/read error.
  • Unit, @frontmcp/testing (34 suites, 429 pass): resource-subscriptions.spec.ts.
  • E2E, demo-e2e-notifications (6 suites, 50 pass), new materialized-resources.e2e.spec.ts with
    src/materialized.main.ts on a SQLite store and src/board-writer.ts run as another process: the write reaches a
    subscribed session as resources/updated and the read serves the new value and revision; a session that did not
    subscribe is told nothing; an unsubscribed session is told no more. Waits poll with a 30 s deadline.
  • E2E, demo-e2e-protocol-20260728 (14 suites, 147 pass), new materialized-listen.e2e.spec.ts with
    src/main-materialized.ts: a write from another process reaches a subscriptions/listen stream tagged with its
    subscription id, the unknown URI is left out of the acknowledgement, the read serves the written revision; the read
    answers ttlMs: 0 and cacheScope: 'public' for an anonymous caller.
  • Checked red: with the scope's MaterializedResources start removed, all three notifications specs and the
    listen spec time out with no resources/updated; the listen-access unit spec failed on the acknowledgement
    (admin://board and nothing://here were acknowledged) and the subscribe spec accepted the denied resource.
  • E2E, demo-e2e-dts-consumer (22 pass): materialized-resources-must-compile.ts types the store options, keys
    and snapshots, from, MaterializedResourceContext, the topic API, and rejects a wrong value type, a from that
    is not a key, a string pollIntervalMs and an upstash storage.
  • E2E, demo-e2e-bundle-size (12 pass): both budgets raised by exactly the measured growth (see "Bundle size").
  • E2E, demo-e2e-local-runtime (3 suites, 4 pass), unchanged.
  • Unit, @frontmcp/auth (98 suites, 2810 pass) and @frontmcp/skills (249 pass), unchanged.
  • Checks: lint (no new warnings), build for utils, storage-sqlite, sdk, testing, skills; nx test skills (249 pass); tsc --noEmit over every changed file; prettier; fix-unused-imports.

Bundle size

The resource store, its change feed and the per-scope watcher reach the browser bundle. Measured against #1014's head (on today's main): create() + one tool grows by 3,992 bytes minified (1,324,174 → 1,328,166) and create() + WebMcpPlugin + one tool by 3,992 (1,331,362 → 1,335,354). The two browser budgets move up by exactly that (1,325,128 → 1,329,120 and 1,332,004 → 1,335,996), keeping the headroom they had.

Summary by CodeRabbit

  • New Features
    • Added materialized resources backed by memory, SQLite, or Redis, with revisioned snapshots and updates that can be shared across processes.
    • Subscribers can receive live resource-update notifications, and resource reads and subscriptions respect access permissions.
    • Resource subscription and unsubscription operations are supported in the test client, with server errors surfaced when requests are refused.
  • Documentation
    • Added guidance and examples for creating, updating, and subscribing to materialized resources.
  • Tests
    • Expanded coverage for materialized resources, storage updates, notifications, and subscription access.

…rved from a store, with a cross-process change feed

@resource({ uri, from: store.key(name) }) serves a stored value with a revision
and update time. Writes through set() and update() are atomic in every process
that shares the store (a new setIfEquals compare-and-set on memory, Redis and
SQLite), and resources/updated reaches subscribed sessions and 2026-07-28
subscriptions/listen streams whenever the revision changes, from any process.

The change feed (createChangeFeed) picks Redis or memory pub/sub, or a SQLite
PRAGMA data_version poll (SqliteStorageAdapter.dataVersion), and coalesces
bursts per topic; the store exposes it as a topic API for later SSE routes.

resources/subscribe now checks a resource's authorities as resources/read does,
and a listen stream runs each requested URI, and each sub-resource update,
through the resources:subscribe flow. The test client's resources.subscribe()
and unsubscribe() send their requests instead of logging a placeholder.
…t, keep poll feeds on other files from moving each other's counters, close stores on a failed start

A resource store now takes only memory, redis or sqlite storage (a type is
required), in the type and the schema, since every write uses setIfEquals; any
other backend failed on the first write. A poll feed that hears a publish from
another feed in the process no longer adopts its counter, so a feed on another
file with the same names cannot hide a later write. When one store fails to
open at boot, the stores already opened are closed.
… comes back, and drop the error of a poll a close cut short

Messages published while the Redis subscriber was down were missed, so a key listener stayed stale until the next
write. The Redis adapter now calls onPubSubReconnect() listeners when its subscriber connection is ready again, and a
pub/sub feed tells every followed topic then. A poll interrupted by closing the feed and its storage no longer
reaches onError.
…ored snapshot from a write, and stop a key listener at once

Before the first write, get() returned the initial object itself (a caller could change it) and kept a Date that a
later read returns as a string; set() and update() returned the value given rather than the one stored. The initial
value is now kept as the JSON record a write starts from, and one JSON cannot hold fails where the key is declared
with InvalidResourceStoreError. A key listener is no longer called after its stop function when a read had already
started, and isResourceKey() also requires the key's store, which the server opens at boot.
…aterialized-resources

# Conflicts:
#	apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts
#	apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
#	apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
#	libs/sdk/src/common/metadata/resource.metadata.ts
#	libs/sdk/src/common/tokens/resource.tokens.ts
#	libs/sdk/src/resource/flows/subscribe-resource.flow.ts
The resource store, its change feed and the per-scope watcher reach the browser bundle, so create() + one tool and create() + WebMcpPlugin + one tool each grow by 3,992 bytes minified over #1014's head. The two budgets move up by exactly that, keeping the headroom they had.
@frontegg-david
frontegg-david marked this pull request as ready for review November 2, 2026 18:17
@frontegg-david
frontegg-david added this pull request to stack #997 November 2, 2026 18:17
@frontegg-david

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Warning

Review limit reached

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

Next included review available in 45 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available. Your 56 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between abac856 and 9f11fd5.

Files selected for processing: 7
  • libs/sdk/src/resource/materialized/__tests__/resource-store.spec.ts
  • libs/sdk/src/resource/materialized/resource-store.ts
  • libs/sdk/src/transport/flows/handle.mcp-20260728.flow.ts
  • libs/sdk/src/transport/mcp-20260728/__tests__/listen-resource-access.spec.ts
  • libs/skills/catalog/frontmcp-development/examples/create-resource/materialized-resource.md
  • libs/skills/catalog/frontmcp-development/references/create-resource.md
  • libs/skills/catalog/skills-manifest.json
Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 6da5847 and abac856.

Files selected for processing: 59
  • apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts
  • apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
  • apps/e2e/demo-e2e-dts-consumer/fixture/materialized-resources-must-compile.ts
  • apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
  • apps/e2e/demo-e2e-notifications/e2e/helpers/board-writer.ts
  • apps/e2e/demo-e2e-notifications/e2e/materialized-resources.e2e.spec.ts
  • apps/e2e/demo-e2e-notifications/src/apps/board/board.resource.ts
  • apps/e2e/demo-e2e-notifications/src/apps/board/board.store.ts
  • apps/e2e/demo-e2e-notifications/src/apps/board/index.ts
  • apps/e2e/demo-e2e-notifications/src/board-writer.ts
  • apps/e2e/demo-e2e-notifications/src/materialized.main.ts
  • apps/e2e/demo-e2e-protocol-20260728/e2e/helpers/board-writer.ts
  • apps/e2e/demo-e2e-protocol-20260728/e2e/materialized-listen.e2e.spec.ts
  • apps/e2e/demo-e2e-protocol-20260728/src/apps/board/board.resource.ts
  • apps/e2e/demo-e2e-protocol-20260728/src/apps/board/board.store.ts
  • apps/e2e/demo-e2e-protocol-20260728/src/apps/board/index.ts
  • apps/e2e/demo-e2e-protocol-20260728/src/board-writer.ts
  • apps/e2e/demo-e2e-protocol-20260728/src/main-materialized.ts
  • libs/sdk/src/common/metadata/index.ts
  • libs/sdk/src/common/metadata/materialized.metadata.ts
  • libs/sdk/src/common/metadata/resource.metadata.ts
  • libs/sdk/src/common/tokens/resource.tokens.ts
  • libs/sdk/src/errors/index.ts
  • libs/sdk/src/errors/mcp.error.ts
  • libs/sdk/src/index.ts
  • libs/sdk/src/resource/__tests__/resource-subscribe.availability.spec.ts
  • libs/sdk/src/resource/flows/subscribe-resource.flow.ts
  • libs/sdk/src/resource/materialized/__tests__/materialized-resource.spec.ts
  • libs/sdk/src/resource/materialized/__tests__/resource-store.spec.ts
  • libs/sdk/src/resource/materialized/index.ts
  • libs/sdk/src/resource/materialized/materialized-resource.context.ts
  • libs/sdk/src/resource/materialized/materialized-resources.ts
  • libs/sdk/src/resource/materialized/resource-store.ts
  • libs/sdk/src/scope/scope.instance.ts
  • libs/sdk/src/transport/flows/handle.mcp-20260728.flow.ts
  • libs/sdk/src/transport/mcp-20260728/__tests__/listen-resource-access.spec.ts
  • libs/sdk/src/transport/mcp-20260728/subscriptions.ts
  • libs/skills/catalog/frontmcp-development/SKILL.md
  • libs/skills/catalog/frontmcp-development/examples/create-resource/materialized-resource.md
  • libs/skills/catalog/frontmcp-development/references/create-resource.md
  • libs/skills/catalog/frontmcp-setup/references/setup-sqlite.md
  • libs/skills/catalog/frontmcp-testing/references/test-e2e-handler.md
  • libs/skills/catalog/skills-manifest.json
  • libs/storage-sqlite/src/__tests__/sqlite-storage.adapter.spec.ts
  • libs/storage-sqlite/src/sqlite-storage.adapter.ts
  • libs/testing/src/client/__tests__/resource-subscriptions.spec.ts
  • libs/testing/src/client/mcp-test-client.ts
  • libs/utils/src/index.ts
  • libs/utils/src/storage/__tests__/change-feed.spec.ts
  • libs/utils/src/storage/__tests__/set-if-equals.spec.ts
  • libs/utils/src/storage/adapters/__tests__/redis-adapter.spec.ts
  • libs/utils/src/storage/adapters/base.ts
  • libs/utils/src/storage/adapters/memory.ts
  • libs/utils/src/storage/adapters/redis.ts
  • libs/utils/src/storage/change-feed.ts
  • libs/utils/src/storage/index.ts
  • libs/utils/src/storage/namespace.ts
  • libs/utils/src/storage/types.ts
  • libs/utils/src/storage/utils/compare-and-delete.ts

Included review availability: This review used your included allowance. 0 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Walkthrough

This PR adds revisioned materialized resources backed by memory, Redis, or SQLite stores. It adds cross-process change feeds, resource subscription authorization, and SDK support for serving stored values. It also adds E2E and type-consumer coverage, examples, documentation, and updated browser bundle-size limits.

Changes: Materialized resources and storage

Layer / File(s) Summary
Storage compare-and-set and change feeds
libs/utils/src/storage/*, libs/storage-sqlite/src/*
Storage adapters add setIfEquals, SQLite adds dataVersion, and the new change-feed API supports pub/sub, polling, and local notifications.
Resource-store API and revisioned keys
libs/sdk/src/common/metadata/*, libs/sdk/src/resource/materialized/resource-store.ts, libs/sdk/src/errors/*, libs/sdk/src/index.ts
The SDK adds resource-key and snapshot contracts, @Resource({ from }) metadata, and a store API with revisioned snapshots, atomic updates, and subscriptions.
Materialized resource serving and lifecycle
libs/sdk/src/resource/materialized/*, libs/sdk/src/scope/scope.instance.ts
Materialized resources serve values and revision metadata. Scope startup connects stores and follows key changes; scope disposal stops subscriptions and closes stores.
Subscription authorization and update filtering
libs/sdk/src/resource/flows/*, libs/sdk/src/transport/*, libs/testing/src/client/*
Resource subscription checks use entry authorities. Listen streams filter requested URIs and updates by access. Test-client subscribe and unsubscribe methods send requests and surface server errors.
Examples, end-to-end coverage, and public exports
apps/e2e/demo-e2e-*/*, apps/e2e/demo-e2e-protocol-20260728/*, libs/skills/catalog/*, apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts
The E2E demos cover cross-process writes and resource updates. The compile fixture checks the SDK declarations, the skill catalog documents materialized resources, and the browser bundle limits increase.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Writer
  participant ResourceStore
  participant ChangeFeed
  participant MaterializedResources
  participant ResourceRegistry
  Writer->>ResourceStore: Write value and advance revision
  ResourceStore->>ChangeFeed: Publish key change
  ChangeFeed->>MaterializedResources: Deliver change notification
  MaterializedResources->>ResourceRegistry: Notify resource URI update
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: alexmercerpo

Merge Risk: Moderate · previous review abac8

Materialized resources and cross-process notifications work as described. Before merging, cap and deduplicate the URI list on subscriptions/listen, so one request cannot trigger unbounded access-check work before the stream is acknowledged. Also stop the subscription check from reopening a closed store during shutdown, and reword the docs to say a notification means "reread the latest value", not "one event per revision".

Pre-merge checks: Passed 4 · Inconclusive 1

Failed Failed checks: 1 inconclusive

Check name Status Explanation Resolution
Docstring Coverage Inconclusive Docstring coverage is 33.33% which is insufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the primary change: SDK materialized resources that use stored values and announce changes across processes.
Description check Passed The description follows the required template and is substantially complete. It explains the purpose, user-facing API and behavior changes, affected documentation, skills catalog updates, and extensiv…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description follows the required template and is substantially complete. It explains the purpose, user-facing API and behavior changes, affected documentation, skills catalog updates, and extensive validation results.

Full details: Docstring Coverage

Explanation

Docstring coverage is 33.33% which is insufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 15 functions across 50 files. (9 skipped: 7 unsupported, 2 over the file limit.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches
Generate docstrings
  • Commit to this branch
  • Create a new PR
Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor
Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Performance Test Results

Status: ✅ All tests passed

Summary

Project Tests Passed Warnings Failed Leaks
✅ demo-e2e-agents 4 4 0 0 0
✅ demo-e2e-cache 11 11 0 0 0
✅ demo-e2e-codecall 4 4 0 0 0
✅ demo-e2e-config 4 4 0 0 0
✅ demo-e2e-direct 3 3 0 0 0
✅ demo-e2e-elicitation 1 1 0 0 0
✅ demo-e2e-errors 4 4 0 0 0
✅ demo-e2e-hooks 3 3 0 0 0
✅ demo-e2e-multiapp 4 4 0 0 0
✅ demo-e2e-notifications 3 3 0 0 0
✅ demo-e2e-openapi 2 2 0 0 0
✅ demo-e2e-providers 4 4 0 0 0
✅ demo-e2e-public 4 4 0 0 0
✅ demo-e2e-redis 15 15 0 0 0
✅ demo-e2e-remember 4 4 0 0 0
✅ demo-e2e-remote 5 5 0 0 0
✅ demo-e2e-serverless 2 2 0 0 0
✅ demo-e2e-skills 15 15 0 0 0
✅ demo-e2e-standalone 2 2 0 0 0
✅ demo-e2e-transport-recreation 3 3 0 0 0
✅ demo-e2e-ui 4 4 0 0 0

Total: 101 tests across 21 projects

📊 View full report in workflow run


Generated at: 2026-11-02T19:55:27.390Z
Commit: c79bd4c7

@frontegg-david

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor
Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Fix comments: Fix CodeRabbit comments on this PR

@frontegg-david
frontegg-david marked this pull request as draft November 2, 2026 19:39
…thing after unsubscribing

A subscriptions/listen request ran the resources:subscribe checks for every
URI it listed, all at once, with no bound and duplicates repeated. It now
takes at most 1000 resourceSubscriptions (more is refused with -32602 before
any check runs) and checks each distinct URI once.

A resource store key's subscription ran a change it had queued before
unsubscribing, and that read reopened a store that had been closed. A queued
check now returns at once once the subscription is gone.

The docs say a notification is a signal to read the latest value, not one
event per revision.
@frontegg-david
frontegg-david marked this pull request as ready for review November 2, 2026 19:39
@frontegg-david

Copy link
Copy Markdown
Contributor Author

Local runs on 9f11fd5: SDK resource and transport suites 1230/1230, nx test skills 249/249, demo-e2e-protocol-20260728 147/147, demo-e2e-notifications 50/50.

@alexmercerpo
alexmercerpo merged commit c7a739f into main Nov 3, 2026
46 checks passed
@alexmercerpo
alexmercerpo deleted the feat/m13-materialized-resources branch November 3, 2026 07:27
alexmercerpo pushed a commit that referenced this pull request Nov 3, 2026
…get, and skills exported from the server's entry (#1017)

* feat(sdk,cli): write frontmcp.manifest.json on every build, lint it against instructionsBudget, and export a server's skills with a drift check

frontmcp build writes the server manifest (every tool, resource, template,
prompt and skill with its schemas and owning app; versioned, sorted, plain
JSON) into each target's output directory and prints its lint: missing
descriptions, and instructions or tool descriptions over the new
@FrontMcp({ instructionsBudget }). --strict-lint fails the build on a
finding, --no-manifest skips it. The server warns at startup when it is over
its budget. buildManifest(), lintServerManifest() and serverManifestSchema
are exported from @frontmcp/sdk.

frontmcp skills export --from-entry <entry> --provider claude,codex writes
the server's @skill entries to each agent's skills folder with a source hash
marker and a generated tools section, and --check exits 1 when a copy is
missing, stale or modified. The extraction bundle now carries a source map,
so a skill's instructions file resolves against the file that declared it.

* fix(sdk): build a manifest in process without writing the CLI log file

buildManifest() booted the server in CLI mode, which adds a log file under
~/.frontmcp/logs; a test that lints its server wrote one on every run.
createForCli() takes { fileLog: false } for it.

* fix(sdk): type buildManifest() to take a @FrontMcp-decorated class, and compile its dts fixture

* fix(cli): keep the earlier copy of a skill the export skips, and use @frontmcp/utils in the new specs

* test(e2e): raise the browser bundle budgets by exactly the growth the startup budget check measured

* fix(sdk,cli): read the server at build time without serving it, opening its stores or running onReady

The schema extraction behind frontmcp build, skills install and skills export booted the
server in CLI mode: it connected to the config's Redis (tasks, the job store; an unreachable
jobs.store.redis failed the extraction), opened or created its SQLite file, ran every
provider's onReady() through connect(), and an entry calling bootstrap() or runStdio() itself
started a server during the build. A boot that never finished hung the build.

- connect(config, { mode: 'manifest' }) boots through the internal
  FrontMcpInstance.createForManifest(): CLI mode, in process only, no log file, and without
  redis, pubsub, sqlite and jobs.store; it runs no onReady(). buildManifest() uses the same
  boot, and the extractor asks for it when the SDK builds manifests.
- bootstrap(), runStdio() and runUnixSocket() return without serving while
  FRONTMCP_SCHEMA_EXTRACT=1 is set, as the decorator already did.
- The extractor builds the manifest before listing, so a remote app's tools discovered by a
  list stay out of it, as with buildManifest() called directly.
- The extraction stops waiting for a boot after 60 s.
- createForCli() keeps its original signature.

* fix(sdk): give a skill the same hash on every machine

A skill's hash in the manifest, and the marker skills export --check compares it with,
depended on the machine: instructions checked out with CRLF hashed differently from LF, and
computeSkillHash sorted tools, parameters and examples in the default locale's order (a Czech
locale sorts "chart" after "hash"). The manifest reads instructions with LF line endings, and
computeSkillHash sorts in English order; a hash computed on an English or C-locale machine is
unchanged.

* fix(cli): keep skills export out of folders it did not write, and copy only what a skill's folders hold

- A skill whose instructions or resources live in the folder the export would write (an agent
  folder used as the skill's source) had that folder deleted, resources included; it is now
  skipped with "holds the skill's own source", exit 1.
- A folder without the export's marker (a skill written by hand, or installed) was replaced; it
  is now left alone with "was not written by frontmcp skills export", exit 1.
- Resource files are copied, and compared by --check, from one listing: a link is followed
  while it stays inside its folder; one leading outside it, or nowhere, is left out. A link
  in a copy is drift, not an ENOENT crash.
- SKILL.md is written with LF, and --check ignores line endings, so a CRLF checkout matches.
- The marker names the entry by its path from the --out folder, so two entries exporting
  --all to one folder no longer remove each other's copies as orphans.

* fix(cli): ship frontmcp.manifest.json inside the mcpb archive

The mcpb target wrote the manifest next to the .mcpb archive only, where a server reading it
at run time cannot see it. The archive now holds server/frontmcp.manifest.json, next to the
server it describes, from the same extraction frontmcp build writes dist/mcpb/ with, unless
--no-manifest.

* docs(skills): the build-time boot, the manifest in the mcpb archive, and what skills export refuses

- build-manifest: what the boot does and does not do (nothing serves, no store, no onReady,
  the 60 s limit), the manifest inside the mcpb archive, the LF hash, a troubleshooting row.
- build-for-mcpb: server/frontmcp.manifest.json in the archive layout.
- create-provider: the provider lifecycle at build time.
- frontmcp-skills-usage: links, LF, the folders the export refuses, the marker path, two
  troubleshooting rows.

* test(sdk): type the localeCompare stand-in with the method's own parameter types

* test(e2e): fit the browser bundle budgets to the build manifest on top of #1015

The instructions budget check and the manifest-mode guards reach the browser bundle: create() + one tool grows by 2,158 bytes minified and create() + WebMcpPlugin + one tool by 2,147 over #1015's head. The two budgets move up by exactly that.

* fix(cli): keep the other skills' copies when skills export --from-entry has --name and --all

With --name, the skills an export keeps were the named one only, so --all
removed every other copy the entry had exported, and --check reported them
as drift, though their skills were still in the source. The copies kept are
now those of every skill in the source.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants