Repository navigation
feat(sdk): a live surface — entries that turn on and off while sessions are connected - #1004
Conversation
… list_changed, a surface hash and STALE_TOOL_LIST - enabled on @tool, @resource, @ResourceTemplate and @prompt: a boolean or an EnabledSource { get(ctx?), subscribe(listener), remedy? }, validated in each metadata schema; fileSignal(path, key) follows a JSON file (watch plus poll), using its default for a missing or unparsable file, logged once - filterByEnabled stages in the four list flows; checkEnabled stages in tools:call-tool, resources:read-resource, prompts:get-prompt and completion:complete refuse an entry that is off with FEATURE_OFF (403 in data) and the source's remedy - a per-scope LiveSurface follows the sources and sends one list_changed per list per 100 ms through the registries, to every connected session - list results carry _meta['frontmcp/surface']; a call from a session whose tool list changed since it listed, to a tool no longer listed, answers STALE_TOOL_LIST once and sends that session tools/list_changed (bounded per-session memory) - feature flags: FeatureFlagSource and featureFlagSource(); optional FeatureFlagAdapter.subscribe(), implemented by the Split.io, LaunchDarkly and Unleash adapters, so a flag flip sends list_changed - a list with an enabled source is cacheScope private under 2026-07-28 - tool metadata keeps false values; Node SHA-256 needs no global Buffer
… and isolate failing surface listeners - a 2026-07-28 resources/read is cacheScope private when a resource has an enabled source, as its lists already are - a fileSignal listener that throws is logged and no longer stops the checks that follow - a list change the registry fails to announce from the coalescing timer is logged instead of thrown - the e2e features tools declare an outputSchema
…subscribing to a resource that is off, and keep a fileSignal's last value over a half-written file - LiveSurface remembers a tool listing only for a session registered with the notification service: the per-request id of a 2026-07-28 request and the shared stateless id were recorded on every tools/list and pushed real sessions out of the 10 000-entry memory. - checkSurface skips the task runner's run of a task-augmented call; the call that created the task was checked. - resources:subscribe refuses a resource or template URI whose enabled is off with FEATURE_OFF, as resources/read does. - fileSignal keeps the last value it read when the file is not valid JSON, as a truncating writer leaves it mid-write, instead of flipping to the default and back (two list_changed and a window of FEATURE_OFF). - Type errors in the live-surface flows spec fixed.
…sources, and evaluate anonymously when a resolver throws
- The FeatureFlagSources of one adapter share one adapter.subscribe(): each source subscribed on its own, so eleven
flagged entries put eleven listeners on the SDK client and Node warned of an EventEmitter leak.
- A userIdResolver or attributesResolver that throws again has the flag evaluated for an anonymous caller ({}) in the
list hooks and the gates, as before: the lists threw (tools/list failed) and the gates fell back to the default.
…at/m08-live-surface # Conflicts: # libs/sdk/src/transport/mcp-20260728/__tests__/cache-scope.per-caller.spec.ts # libs/sdk/src/transport/mcp-20260728/result-decorator.ts # libs/skills/catalog/create-tool/references/decorator-options.md
|
@coderabbitai review |
|
Warning Review limit reachedThis review is too large to run within your organization's remaining usage spending cap. Raise or remove your spending cap in the billing tab, then retry. Next included review available in 8 minutes. View limit details
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Enablement contract and file signals libs/sdk/src/common/metadata/*, libs/sdk/src/common/tokens/*, libs/sdk/src/surface/entry-enabled.ts, libs/sdk/src/surface/file-signal.ts, libs/sdk/src/surface/__tests__/file-signal.spec.ts, libs/sdk/src/surface/index.ts, libs/sdk/src/index.ts |
Metadata schemas accept boolean or EnabledSource values. The SDK adds helpers to evaluate enabled entries and a file-backed source with configurable polling, defaults, and remedies. |
Live-surface lifecycle and notifications libs/sdk/src/surface/live-surface.ts, libs/sdk/src/surface/__tests__/live-surface.spec.ts, libs/sdk/src/scope/*, libs/sdk/src/common/entries/scope.entry.ts, libs/sdk/src/{tool,resource,prompt}/*.registry.ts, libs/utils/src/crypto/* |
Scopes start and stop LiveSurface. It tracks source and registry changes, sends coalesced list-change notifications, computes surface hashes, and records tool-list versions for sessions. |
Entry filtering, request checks, and list responses libs/sdk/src/{tool,resource,prompt,completion}/flows/*, libs/sdk/src/{tool,resource,prompt}/*.registry.ts, libs/sdk/src/errors/*, libs/sdk/src/transport/mcp-20260728/*, libs/sdk/src/surface/__tests__/live-surface.flows.spec.ts, libs/skills/catalog/create-tool/*, libs/skills/catalog/frontmcp-development/references/{create-prompt.md,create-resource.md}, libs/skills/catalog/skills-manifest.json |
List flows filter disabled entries and return surface hashes. Request flows check enabled state; tool calls also check stale lists. Tests and SDK guidance cover filtering, refusals, notifications, and cache behavior. |
Feature-flag sources and adapter subscriptions plugins/plugin-feature-flags/src/*, plugins/plugin-feature-flags/README.md, plugins/plugin-feature-flags/src/__tests__/*, libs/skills/catalog/frontmcp-development/references/official-plugins.md |
Feature-flag sources evaluate flags with caller context and share adapter subscriptions. The LaunchDarkly, Split.io, and Unleash adapters expose subscriptions, and the plugin signals changes to affected lists. |
Consumer and live-session examples apps/e2e/demo-e2e-dts-consumer/*, apps/e2e/demo-e2e-notifications/* |
The declaration fixture checks the new types. The notification demo tests file-driven listing changes, remedies, surface metadata, and stale-tool-list responses. |
Estimated code review effort: 4 (Complex) | ~60 minutes
Sequence Diagram(s)
sequenceDiagram
participant FeatureFlagAdapter
participant FeatureFlagPlugin
participant LiveSurface
participant Client
participant ToolsListFlow
participant CallToolFlow
FeatureFlagAdapter->>FeatureFlagPlugin: report a flag change
FeatureFlagPlugin->>LiveSurface: signal affected list changes
LiveSurface-->>Client: send list_changed notification
Client->>ToolsListFlow: request tools/list
ToolsListFlow-->>Client: return enabled tools and surface hash
Client->>CallToolFlow: call a tool from an earlier list
CallToolFlow->>LiveSurface: check recorded tool-list version
CallToolFlow-->>Client: return STALE_TOOL_LIST when the list is stale
Suggested reviewers: alexmercerpo
Merge Risk: 
Minimal · previous review 7b8f2
-
The live-surface changes have no established merge-blocking issue in the supplied evidence; merge after normal checks.
Pre-merge checks: 
4 · 
1
-
Failed checks: 1 inconclusive Passed checks: 4 passedFull details: Docstring Coverage
-
Explanation
Docstring coverage is 42.86% 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 14 functions across 50 files. (19 skipped: 10 unsupported, 9 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
…at/m08-live-surface
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-02T15:43:26.260Z |
Action not completed
|
…at/m08-live-surface
…at/m08-live-surface
Stacked on #1003
What and why
Proposal item 2.3 (b): a live tool surface.
availableWhenis decided when the server starts. A server where people turn features on and off while sessions arelive (a settings page that writes a file, a flag service) had to filter the lists in its own hooks, poll the file,
broadcast
list_changeditself and refuse calls with its own error, and a call made from a cached list answeredTool "x" not foundwith no hint that the list was out of date.What this PR changes:
enabledon@Tool,@Resource,@ResourceTemplateand@Prompt: a boolean, or anEnabledSource(
{ get(ctx?), subscribe(listener), remedy? }).getanswers for a request (it gets the request context);subscribetells FrontMCP the answer may have changed. Validated in each metadata Zod schema: anything else fails atthe decorator with
`enabled` must be a boolean or an EnabledSource ({ get(ctx), subscribe(listener) }), such as fileSignal().fileSignal(path, key, { defaultValue?, pollIntervalMs?, remedy?, logger? }), the built-in source: the booleanunder
keyin a JSON file. It watches the file's directory (watchFilefrom@frontmcp/utils) and reads the fileagain every
pollIntervalMs(2000), which is the only check where watching fails. A missing file or no boolean underthe key means
defaultValue(false); content that is not JSON, as a write in progress leaves it (a plainwriteFiletruncates first), keeps the last value read (defaultValuebefore one was read). Each problem is loggedonce. It calls its listeners only when the value changes. A bad path, key or interval throws
InvalidFileSignalErrorwhere the entry is declared.filterByEnabledstage (tools:list-tools,resources:list-resources,resources:list-resource-templates,prompts:list-prompts).tools:call-tool,resources:read-resourceandprompts:get-prompthave a newcheckEnabledstage that refuses an entry that is offwith
FeatureOffError:FEATURE_OFF, JSON-RPC -32003,data: { code, status: 403, entryType, entry, remedy }(on
tools/call, anisErrorresult with_meta.code,_meta.remedyand aTo fix:line). The remedy is thesource's, else
Ask the server's operator to turn on tool "x".A call by an alias andthis.callTool()are refusedtoo.
completion:completegetscheckEnabledas well: an entry that is off completes nothing.resources:subscriberefuses a resource that is off in its
validateResourcestage, as it refuses whatresources/readrefuses. A sourcethat throws counts as off.
list_changed. A new per-scopeLiveSurface(scope.surface) follows theenabledsources of thescope's entries (including ones registered later) and, when one fires, emits a registry change, so
notifications/tools/list_changed(or the resources or prompts one) reaches every connected session, the2026-07-28
subscriptions/listenstreams and the WebMCP bridge: one per list per 100 ms.scope.surface.listChanged(kind)is the same coalesced path for code that changes a list some other way.
STALE_TOOL_LIST. The four list results carry_meta['frontmcp/surface'](SURFACE_META_KEY),a 16-character hash of the names (URIs, URI templates) the list shows, the same on every page. The live surface
remembers, for the last 10 000 connected sessions that listed tools, whether the tool list changed since; the id of
a 2026-07-28 or stateless request names no session and is not remembered. A new
checkSurfacestage oftools:call-tool(beforefindTool) answers a client call from such a session to a tool that is not in the currentlist (unknown, or off) with
StaleToolListError(STALE_TOOL_LIST, 409, remedyCall tools/list again, then call a tool from the new list.) and sends that sessionnotifications/tools/list_changed. The next call gets the usualanswer, so a client that never lists again is not stuck. The task runner's run of a task-augmented call is not
checked: the call that created the task was.
EnabledSource. Each flag is aFeatureFlagSource(exported, withfeatureFlagSource(adapter, ref, options?)): the plugin's gates evaluate through it, and an entry can use one asenabled.FeatureFlagAdaptergets an optional
subscribe(listener); the Split.io (SDK_UPDATE), LaunchDarkly (update) and Unleash (changed)adapters implement it. When the adapter reports a change, the plugin sends the coalesced
list_changedfor the liststhat hold an entry with a
featureFlag. The flag sources of one adapter share onesubscribe()on it, so its SDKclient gets one listener however many entries use them. The plugin's options,
featureFlagmetadata andFEATURE_FLAG_DISABLEDrefusal are unchanged; auserIdResolverorattributesResolverthat throws still has theflag evaluated for an anonymous caller (
{}), in the lists and the gates.enabledis a source, and aresources/readon a serverwith a resource whose
enabledis a source, arecacheScope: 'private': the source answers per request, so theresult can differ by caller.
Different from the plan, and why:
hooks (authorities, flags) that only a full listing can run. The live surface counts tool registry changes (a source
flip, a runtime registration, a remote app's tools) and remembers the count each session listed at; the hash is what
the client sees.
list_changedgoes through the registries (notifyListChanged()on the tool, resource and prompt registries)instead of
NotificationService.broadcastNotification, so 2026-07-28 subscription streams and the WebMCP bridgesee it too, and the stale-list version moves with it.
featureFlagmetadata (per-app judging, skills, completions andits error code stay as they were) and moves the flag evaluation onto
FeatureFlagSource. Entries that want the SDK'shandling use
enabled: featureFlagSource(...).STALE_TOOL_LISTis answered once per change. Answering it until the client lists again would leave a clientthat ignores
list_changedwithout the real error.collectToolMetadatadroppedfalsemetadata values (enabled: falsenever reached thetool; resources and prompts already kept them), and the Node
sha256/sha256Hexof@frontmcp/utilswrapped theirinput in the global
Buffer, which a browser-like run oftools/list(noBuffer) now needs to avoid; Node's hashtakes strings and typed arrays directly.
User-facing change
enabledoption on@Tool,@Resource,@ResourceTemplate,@Prompt; newEnabledSource,EnabledOption,isEnabledSource,enabledOptionSchema,fileSignal(),FileSignalOptions,SURFACE_META_KEY,surfaceHash(),LiveSurface(type) andScopeEntry.surface.FeatureOffError(FEATURE_OFF, 403),StaleToolListError(STALE_TOOL_LIST, 409),InvalidFileSignalError(INVALID_FILE_SIGNAL).filterByEnabledin the four list flows;checkSurfaceandcheckEnabledintools:call-tool;checkEnabledinresources:read-resource,prompts:get-promptandcompletion:complete.tools/list,resources/list,resources/templates/listandprompts/listresults carry_meta['frontmcp/surface'].notifyListChanged()onToolRegistry,ResourceRegistry,PromptRegistry.@frontmcp/plugin-feature-flags:FeatureFlagSource,featureFlagSource(),FeatureFlagSourceOptions; optionalFeatureFlagAdapter.subscribe(), implemented by the Split.io, LaunchDarkly and Unleash adapters.list_changedfor the flagged lists.tools/callfrom a session whose tool list changed since it listed, to a tool no longer listed, answeredTOOL_NOT_FOUND→STALE_TOOL_LISTonce, with atools/list_changedto that session.enabled)resources/subscribeof a resource or template URI that is off answersFEATURE_OFF, as itsresources/readdoes._meta→_meta['frontmcp/surface'].enabled.@Tool,@Resource/@ResourceTemplateand@Promptoptionreferences (
enabled), the tool availability page (new live surface section:fileSignal,FEATURE_OFF,list_changed, the surface hash,STALE_TOOL_LIST), the error reference (FEATURE_OFF,STALE_TOOL_LIST), thenotifications page (coalesced
list_changed), the flow stage reference (new stages), and the feature-flags pluginpage (adapter
subscribe,featureFlagSource).create-tool/references/availability.md(new "enabled" section and "The surface hash and stale tool lists"),decorator-options.md(enabledrow,availableWhen+enabled),error-handling.md(FeatureOffError,StaleToolListError, remedies),create-tool/SKILL.md(decision tree and reference table),skills-manifest.json;frontmcp-development/references/create-resource.mdandcreate-prompt.md(enabledoption);frontmcp-development/references/official-plugins.md(feature flags: "A flag flip updates connected clients",featureFlagSource), and the plugin'sREADME.md.Tests
Unit (
libs/sdk):surface/__tests__/file-signal.spec.ts: the value under the key; a change calls the listeners andgetanswers thenew value; a change that keeps the value calls nothing; an empty or half-written file keeps the last value; a
missing file, invalid JSON and a non-boolean value answer the default and log once each; a file that appears later; polling where watching fails; unsubscribe stops it; a
listener that throws is logged and the others keep getting changes; the remedy; invalid path, key or interval.
surface/__tests__/live-surface.spec.ts: onelist_changedper kind per window; kinds apart; one subscription fora shared source; sources of entries added and removed;
listChanged(); nothing afterstop(); a registry thatfails to announce the change is logged from the timer, not thrown; a source that cannot be subscribed to; the hash in
any order; a session stale after a change, current after listing; never stale without a listing or a session;
refreshSession(); the bounded session memory; only connected sessions remembered (2026-07-28 per-request ids and thestateless id push none out).
surface/__tests__/live-surface.flows.spec.ts(a connected client): the four lists leave out what is off and followa source; a failing source counts as off;
FEATURE_OFFwith a remedy by name, by alias and fromthis.callTool();the source's remedy in the
To fix:line; reads of a resource and a template URI and a get of a prompt refused withstatus: 403indata; a subscription to a resource that is off; completion of a prompt that is off; coalescedlist_changed;STALE_TOOL_LISTthenFEATURE_OFF; a stale call to an unknown tool, thenTOOL_NOT_FOUND; the taskrunner's run of a task answered as usual; a stale session calling a tool still on; the hash on the four list results
and the session record; the decorator validation message.
transport/mcp-20260728/__tests__/cache-scope.per-caller.spec.ts: the four lists andresources/readwith anenabledsource arecacheScope: 'private'.libs/utilscrypto.spec.ts: Node SHA-256 without a globalBuffer.Unit (
plugin-feature-flags):feature-flag.source.spec.ts: the adapter's answer, the fallback chain for an unknown flag and a throwing adapter,the caller's context,
subscribefollowing the adapter (and a no-op without), one adapter subscription shared byits sources, the remedy.
feature-flag.caller-context.spec.ts: a throwing resolver evaluates the lists and the gate anonymously, as before.feature-flag.live-surface.spec.ts(a connected client): onelist_changedper flagged list per window on a flip,none for resources; the lists follow the flag;
enabled: featureFlagSource()refused withFEATURE_OFF;featureFlagstillFEATURE_FLAG_DISABLED; the adapter is no longer followed once the server is disposed.launchdarkly/unleash/splitio.adapter.spec.ts:subscribefollows the SDK event until stopped, and followsnothing before
initialize().E2E:
apps/e2e/demo-e2e-notifications/e2e/live-surface.e2e.spec.ts(new server entrysrc/live-surface.main.tswith afileSignaltool and asettings_settool that writes the file): flipping the file sendsnotifications/tools/list_changedandtools/listfollows it;tools/listcarries the hash; a call while off getsFEATURE_OFFwith the remedy; calling the remedy tool turns it back on; a call from a stale list getsSTALE_TOOL_LISTand alist_changed, thenFEATURE_OFF.apps/e2e/demo-e2e-dts-consumer/fixture/live-surface-must-compile.ts:enabled: true,false,fileSignal(...)and a custom
EnabledSourceon the four decorators,SURFACE_META_KEY, and a@ts-expect-errorforenabled: 'yes'.Run:
libs/sdkunit suite: 589 suites, 7653 tests passed;libs/utils: 69 suites, 2000 tests passed;plugin-feature-flags: 25 suites, 262 tests passed;nx test skills: 249 passed.demo-e2e-notifications: 5 suites, 47 tests passed (5 new);demo-e2e-feature-flags: 4 suites, 33 passed;demo-e2e-dts-consumer: 17 passed (1 new).tsc --noEmitof the SDK library and the plugin library and specs,fix-unused-imports, Prettier check.Summary by CodeRabbit