Repository navigation
feat(sdk): materialized resources — stored values that announce their own changes, from any process - #1015
Conversation
…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.
…bytes the resource store fixes measured
…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.
|
@coderabbitai review |
|
Warning Review limit reachedYour organization has reached its usage spending cap. Adjust your spending cap in the billing tab. Next included review available in 45 minutes. View limit details
|
| 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
Possibly related issues
Possibly related PRs
- agentfront/frontmcp#996 adds SQLite storage and related version support used by the resource store.
- agentfront/frontmcp#1016 updates the SDK root export structure, which must account for the materialized-resource exports added here.
- agentfront/frontmcp#943 establishes browser bundle budgets that this PR updates.
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: 
4 · 
1
-
Failed checks: 1 inconclusive Passed checks: 4 passed-
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
Action not completed
|
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-02T19:55:27.390Z |
…aterialized-resources
|
@coderabbitai review |
Action performed
|
…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.
|
Local runs on 9f11fd5: SDK resource and transport suites 1230/1230, |
…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.
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
@Resourceclasses from it, but nothing sentnotifications/resources/updatedwhen a value changed, andthe 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 wasnever seen. The live dashboard had to run its own SSE hub on the side.
What this PR adds:
resourceStore({ storage?, pollIntervalMs?, coalesceMs?, onError? })andstore.key<T>(name, { initial? }).A key holds
{ value, revision, updatedAt }: revision0andinitialuntil the first write.set(value)andupdate(change)write atomically in every process that shares the store and add 1 to the revision;updatere-reads and re-runs
changewhen another write came first (a compare-and-set loop). Values are stored as JSON,initialincluded, so every read and write returns a JSON copy (aDatecomes back as its ISO string), and aninitialJSON cannot hold (aBigInt) throwsInvalidResourceStoreErrorwhere the key is declared.subscribe(listener)calls the listener with the snapshot whenever the revision changes, and never after its stopfunction was called.
storageis acreateStorage()config onmemory(the default),
sqliteorredis(ResourceStoreStorage: the backends with an atomic compare-and-set; anyother type,
autoincluded, is refused in the type and the schema), picked through the storage factory, withfallback: 'error'unless set, so a SQLite store never silently becomes per-process memory. Options are validated with Zod; a bad one throwsInvalidResourceStoreError(INVALID_RESOURCE_STORE) where the store is declared, and a backend that cannot openfails the server's boot.
@Resource({ uri, from: store.key(name) }), validated in the metadata schema (`from` must be a key of a resource store;fromon a@ResourceTemplateis refused with a message).MaterializedResourceContextserves the value as it is: a string as
text/plain, anything else as JSON (mimeTypewins), with_meta['frontmcp/materialized'] = { revision, updatedAt }(MATERIALIZED_META_KEY). A class that extends itwithout
fromfails the boot. A plainResourceContextwithfromkeeps its ownexecute()and still gets theupdates.
MaterializedResources(started after the notification service, like the livesurface) 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/subscribeand the 2026-07-28
subscriptions/listenstreams through the existing paths. It opens the stores at boot andcloses 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.createChangeFeed(storage, options)in@frontmcp/utils):pubsub(Redis, Upstash, memory): a publish is a message on the topic's channel. When a lost Redis connectioncomes 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 checksPRAGMA data_version(newSqliteStorageAdapter.dataVersion(), on the shared connection) everypollIntervalMs(250) and reads itstopics' 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 isreported once until it works again.
StorageAdapter.setIfEquals(key, expected, value)(compare-and-set, likedeleteIfEquals), on the memory, Redis (one Lua script) and SQLite (oneBEGIN IMMEDIATEtransaction) adaptersand on namespaced storage; other adapters refuse it with
StorageNotSupportedError.store.subscribe(topic, listener)andstore.publish(topic)are the same feed with novalue 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):resources/subscriberefuses whatresources/readrefuses:availableWhenandenabled(already), and now the resource'sauthorities, in a new hookablecheckEntryAuthoritiesstage ofresources:subscribe. A 2026-07-28subscriptions/listenrequest runs each requested URI through the sameresources:subscribeflow with the caller's auth, and a refused URI is left out of the acknowledgement (the speclets 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
splitByAppan app's sessionshear only that app. A legacy session's subscription is checked when it subscribes; if
enabledturns off later,the session gets
resources/list_changedand its reads are refused.only through a tool you write, and that call runs through
tools:call-toolwith the tool'sauthorities,enabledand hooks. Any process that can open the store's SQLite file or Redis can write, the same trust boundaryas the rest of the server's data there.
ttlMs: 0(theresources/readdefault), sinceanother process can change the value at any moment and subscribers learn of it from
resources/updated;cacheScopefollows the existing rule (the stored value is the same for every caller, so an anonymous read thatnothing shapes per caller is
public, at a TTL of 0). Tested end to end.Also:
McpTestClient'sresources.subscribe(uri)/unsubscribe(uri)were placeholders that logged a warning; they nowsend the requests and throw the server's error on a refusal.
resources:subscribelogs a request without a session (what a listen check sends) atverbose, notwarn.Different from the plan, and why:
@Resource(a fixed URI) takesfrom. A template withfromis refused, not ignored. Serving a templatefrom
from: (params) => store.key(...)is the small part (the metadata schema andMaterializedResourceContextcalling 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/listenstream keeps its own). It needs a subscribed-URI registry thatresources:subscribe/unsubscribe, session close and listen streams feed, with a counted key watch per URI. M14can serve
job://runs/{id}from a key per run read in its ownexecute(), and announce a run's changes withstore.subscribe(topic)+notifyContentChanged(uri)for the runs it tracks, until that registry exists.incrgives a unique revision, but pairing it with thevalue 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.setIfEqualsis the same shape as M02'sdeleteIfEquals.Bundle size: see "Bundle size" below.
User-facing change
resourceStore(),ResourceStore,ResourceStoreOptions,ResourceStoreStorage,ResourceKeyOptions,ResourceKey,ResourceSnapshot,ResourceStoreHandle,isResourceKey,MaterializedResourceContext,MATERIALIZED_META_KEYin@frontmcp/sdk; newfromoption on@Resource.InvalidResourceStoreError(INVALID_RESOURCE_STORE).createChangeFeed(),ChangeFeed,ChangeFeedKind,ChangeFeedOptions,DEFAULT_CHANGE_FEED_POLL_INTERVAL_MS,DEFAULT_CHANGE_FEED_COALESCE_MSin@frontmcp/utils; optionalStorageAdapter.setIfEquals(),dataVersion()andonPubSubReconnect();NamespacedStorage.setIfEquals()and
onPubSubReconnect();setIfEquals()implemented by the memory, Redis and SQLite adapters,dataVersion()by SQLite only,onPubSubReconnect()by Redis only.checkEntryAuthoritiesstage inresources:subscribe.McpTestClient.resources.subscribe()/unsubscribe()work.resources/subscribeto a resource whoseauthoritiesdeny the caller was accepted → refused with theresources/readerror (AuthorityDeniedError).subscriptions/listenrequest acknowledged every requested resource URI → only those the caller may subscribeto; an update of a resource under an acknowledged URI is sent only if the caller may subscribe to it.
mcp.resources.subscribe()in@frontmcp/testinglogged "not yet implemented" → sendsresources/subscribe.from.@Resourceoption 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/updatedfrom a store, the listen-stream access checks), the errorreference (
INVALID_RESOURCE_STORE), the flow stage reference (resources:subscribe→checkEntryAuthorities),the storage page (
setIfEquals,dataVersion,createChangeFeed), and the testing client page(
resources.subscribe()).frontmcp-development/references/create-resource.md(fromoption, new "Materialized Resources" section:values stored as JSON, a Redis reconnect re-reading the keys; common pattern, checklist and troubleshooting rows,
example row);
frontmcp-development/examples/create-resource/materialized-resource.md(manifest andfrontmcp-development/SKILL.mdtables);frontmcp-setup/references/setup-sqlite.md(setIfEquals, hearing other processes throughdata_version);frontmcp-testing/references/test-e2e-handler.md(resources.subscribe()/unsubscribe()).Tests
@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 twoconcurrent 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 onthe 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, tellsanother 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.
@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()movingon another connection's commit and not on its own.
@frontmcp/sdk(598 suites, 7844 pass):resource-store.spec.ts: option validation (includingupstash,autoand a missing storage type), key names,initial value at revision 0, read as a JSON copy a caller cannot change, an
initialJSON cannot hold refusedwhere 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 andnot for a publish that left it, no key listener called after it unsubscribed while a read was under way, a key
without a
storerefused byisResourceKey, topic listeners and unsubscribe, a throwing listener reported andtold 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: throughconnect(), the read serves JSON or text with_metarevision and time;a subscribed session gets
resources/updatedon a tool'supdate()and none after it unsubscribes; a sessionthat 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
MaterializedResourceContextclasswithout
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 leavesout a resource denied by
authoritiesand an unknown URI; a denied sub-resource's update is not sent, asub-URI that names no resource and the acknowledged resource are.
resource-subscribe.availability.spec.ts:resources/subscriberefuses a resource denied byauthoritieswiththe
resources/readerror.@frontmcp/testing(34 suites, 429 pass):resource-subscriptions.spec.ts.demo-e2e-notifications(6 suites, 50 pass), newmaterialized-resources.e2e.spec.tswithsrc/materialized.main.tson a SQLite store andsrc/board-writer.tsrun as another process: the write reaches asubscribed session as
resources/updatedand the read serves the new value and revision; a session that did notsubscribe is told nothing; an unsubscribed session is told no more. Waits poll with a 30 s deadline.
demo-e2e-protocol-20260728(14 suites, 147 pass), newmaterialized-listen.e2e.spec.tswithsrc/main-materialized.ts: a write from another process reaches asubscriptions/listenstream tagged with itssubscription id, the unknown URI is left out of the acknowledgement, the read serves the written revision; the read
answers
ttlMs: 0andcacheScope: 'public'for an anonymous caller.MaterializedResourcesstart removed, all three notifications specs and thelisten spec time out with no
resources/updated; the listen-access unit spec failed on the acknowledgement(
admin://boardandnothing://herewere acknowledged) and the subscribe spec accepted the denied resource.demo-e2e-dts-consumer(22 pass):materialized-resources-must-compile.tstypes the store options, keysand snapshots,
from,MaterializedResourceContext, the topic API, and rejects a wrong value type, afromthatis not a key, a string
pollIntervalMsand anupstashstorage.demo-e2e-bundle-size(12 pass): both budgets raised by exactly the measured growth (see "Bundle size").demo-e2e-local-runtime(3 suites, 4 pass), unchanged.@frontmcp/auth(98 suites, 2810 pass) and@frontmcp/skills(249 pass), unchanged.buildforutils,storage-sqlite,sdk,testing,skills;nx test skills(249 pass);tsc --noEmitover 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) andcreate()+ 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