@seed-ship/mcp-ui-solid 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/components/ElicitationForm.cjs +51 -0
  3. package/dist/components/ElicitationForm.cjs.map +1 -0
  4. package/dist/components/ElicitationForm.d.ts +68 -0
  5. package/dist/components/ElicitationForm.d.ts.map +1 -0
  6. package/dist/components/ElicitationForm.js +51 -0
  7. package/dist/components/ElicitationForm.js.map +1 -0
  8. package/dist/components/index.d.ts +2 -0
  9. package/dist/components/index.d.ts.map +1 -1
  10. package/dist/components.cjs +2 -0
  11. package/dist/components.cjs.map +1 -1
  12. package/dist/components.d.cts +2 -0
  13. package/dist/components.d.ts +2 -0
  14. package/dist/components.js +2 -0
  15. package/dist/components.js.map +1 -1
  16. package/dist/index.cjs +8 -0
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +4 -0
  19. package/dist/index.d.ts +4 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +8 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/stores/server-capabilities-store.cjs +61 -0
  24. package/dist/stores/server-capabilities-store.cjs.map +1 -0
  25. package/dist/stores/server-capabilities-store.d.ts +172 -0
  26. package/dist/stores/server-capabilities-store.d.ts.map +1 -0
  27. package/dist/stores/server-capabilities-store.js +61 -0
  28. package/dist/stores/server-capabilities-store.js.map +1 -0
  29. package/docs/recipes/elicitation-pseudo-spec-adapter.md +171 -0
  30. package/docs/recipes/feedback-inline-wiring.md +142 -0
  31. package/package.json +1 -1
  32. package/src/components/ElicitationForm.test.tsx +197 -0
  33. package/src/components/ElicitationForm.tsx +126 -0
  34. package/src/components/index.ts +4 -0
  35. package/src/index.ts +16 -0
  36. package/src/stores/server-capabilities-store.test.tsx +206 -0
  37. package/src/stores/server-capabilities-store.tsx +215 -0
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Tests for ElicitationForm — v5.3.0
3
+ *
4
+ * Coverage focus : the inverse mapping (ChatPromptResponse → spec content)
5
+ * that this wrapper owns. The forward mapping (spec → ChatPromptConfig) is
6
+ * already covered by `chat-bus.test.ts elicitationToPromptConfig`.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
10
+ import { render, fireEvent, cleanup } from '@solidjs/testing-library'
11
+ import { ElicitationForm } from './ElicitationForm'
12
+ import type { ElicitationEvent } from '../types/chat-bus'
13
+
14
+ describe('ElicitationForm — v5.3.0', () => {
15
+ beforeEach(() => {
16
+ cleanup()
17
+ })
18
+
19
+ it('boolean property → confirm UI → onAccept gets { propName: true }', () => {
20
+ const onAccept = vi.fn()
21
+ const event: ElicitationEvent = {
22
+ message: 'Proceed?',
23
+ requestedSchema: {
24
+ type: 'object',
25
+ properties: {
26
+ consent: { type: 'boolean', description: 'I agree' },
27
+ },
28
+ required: ['consent'],
29
+ },
30
+ }
31
+
32
+ const { getByText } = render(() => (
33
+ <ElicitationForm event={event} onAccept={onAccept} onCancel={() => {}} />
34
+ ))
35
+
36
+ // ChatPrompt renders a Confirm button — find and click it.
37
+ const confirmBtn = getByText('Confirm') as HTMLElement
38
+ fireEvent.click(confirmBtn)
39
+
40
+ expect(onAccept).toHaveBeenCalledTimes(1)
41
+ expect(onAccept).toHaveBeenCalledWith({ consent: true })
42
+ })
43
+
44
+ it('single enum property → choice UI → onAccept gets { propName: enumValue }', () => {
45
+ const onAccept = vi.fn()
46
+ const event: ElicitationEvent = {
47
+ message: 'Pick a tier',
48
+ requestedSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ tier: {
52
+ type: 'string',
53
+ enum: ['free', 'pro', 'enterprise'],
54
+ enumNames: ['Free', 'Pro', 'Enterprise'],
55
+ },
56
+ },
57
+ },
58
+ }
59
+
60
+ const { getByText } = render(() => (
61
+ <ElicitationForm event={event} onAccept={onAccept} />
62
+ ))
63
+
64
+ fireEvent.click(getByText('Pro') as HTMLElement)
65
+
66
+ expect(onAccept).toHaveBeenCalledWith({ tier: 'pro' })
67
+ })
68
+
69
+ it('numeric enum property → onAccept coerces value to number', () => {
70
+ const onAccept = vi.fn()
71
+ const event: ElicitationEvent = {
72
+ message: 'Pick a level',
73
+ requestedSchema: {
74
+ type: 'object',
75
+ properties: {
76
+ level: { type: 'integer', enum: [1, 2, 3] },
77
+ },
78
+ },
79
+ }
80
+
81
+ const { getByText } = render(() => (
82
+ <ElicitationForm event={event} onAccept={onAccept} />
83
+ ))
84
+
85
+ fireEvent.click(getByText('2') as HTMLElement)
86
+
87
+ expect(onAccept).toHaveBeenCalledWith({ level: 2 })
88
+ })
89
+
90
+ it('multi-property schema → form UI → onAccept gets formValues unchanged', () => {
91
+ const onAccept = vi.fn()
92
+ const event: ElicitationEvent = {
93
+ message: 'Tenant scope required',
94
+ requestedSchema: {
95
+ type: 'object',
96
+ properties: {
97
+ tenant_id: { type: 'string', title: 'Tenant ID' },
98
+ space_id: { type: 'string', title: 'Space ID', default: 'default' },
99
+ },
100
+ required: ['tenant_id', 'space_id'],
101
+ },
102
+ }
103
+
104
+ const { container, getByText } = render(() => (
105
+ <ElicitationForm event={event} onAccept={onAccept} />
106
+ ))
107
+
108
+ const tenantInput = container.querySelector('input[name="tenant_id"]') as HTMLInputElement
109
+ const spaceInput = container.querySelector('input[name="space_id"]') as HTMLInputElement
110
+ expect(tenantInput).toBeTruthy()
111
+ expect(spaceInput).toBeTruthy()
112
+
113
+ fireEvent.input(tenantInput, { target: { value: 'acme-co' } })
114
+ fireEvent.input(spaceInput, { target: { value: 'prod' } })
115
+
116
+ const submitBtn = getByText('Submit') as HTMLElement
117
+ fireEvent.click(submitBtn)
118
+
119
+ expect(onAccept).toHaveBeenCalledTimes(1)
120
+ const [content] = onAccept.mock.calls[0]
121
+ expect(content).toMatchObject({ tenant_id: 'acme-co', space_id: 'prod' })
122
+ })
123
+
124
+ it('X dismiss → onCancel fires, onAccept does NOT', () => {
125
+ const onAccept = vi.fn()
126
+ const onCancel = vi.fn()
127
+ const event: ElicitationEvent = {
128
+ message: 'Pick a tier',
129
+ requestedSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ tier: { type: 'string', enum: ['a', 'b'] },
133
+ },
134
+ },
135
+ }
136
+
137
+ const { container } = render(() => (
138
+ <ElicitationForm event={event} onAccept={onAccept} onCancel={onCancel} />
139
+ ))
140
+
141
+ const dismissBtn = container.querySelector('[aria-label="Dismiss"]') as HTMLElement
142
+ fireEvent.click(dismissBtn)
143
+
144
+ expect(onCancel).toHaveBeenCalledTimes(1)
145
+ expect(onAccept).not.toHaveBeenCalled()
146
+ })
147
+
148
+ it('onDecline takes precedence over onCancel when provided', () => {
149
+ const onAccept = vi.fn()
150
+ const onCancel = vi.fn()
151
+ const onDecline = vi.fn()
152
+ const event: ElicitationEvent = {
153
+ message: 'Proceed?',
154
+ requestedSchema: {
155
+ type: 'object',
156
+ properties: { consent: { type: 'boolean' } },
157
+ },
158
+ }
159
+
160
+ const { getByText } = render(() => (
161
+ <ElicitationForm
162
+ event={event}
163
+ onAccept={onAccept}
164
+ onCancel={onCancel}
165
+ onDecline={onDecline}
166
+ dismissLabel="Decline"
167
+ />
168
+ ))
169
+
170
+ fireEvent.click(getByText('Decline') as HTMLElement)
171
+
172
+ expect(onDecline).toHaveBeenCalledTimes(1)
173
+ expect(onCancel).not.toHaveBeenCalled()
174
+ expect(onAccept).not.toHaveBeenCalled()
175
+ })
176
+
177
+ it('confirm cancel button → onCancel fires (dismissed=true via cancel button)', () => {
178
+ const onAccept = vi.fn()
179
+ const onCancel = vi.fn()
180
+ const event: ElicitationEvent = {
181
+ message: 'Proceed?',
182
+ requestedSchema: {
183
+ type: 'object',
184
+ properties: { consent: { type: 'boolean' } },
185
+ },
186
+ }
187
+
188
+ const { getByText } = render(() => (
189
+ <ElicitationForm event={event} onAccept={onAccept} onCancel={onCancel} />
190
+ ))
191
+
192
+ fireEvent.click(getByText('Cancel') as HTMLElement)
193
+
194
+ expect(onCancel).toHaveBeenCalledTimes(1)
195
+ expect(onAccept).not.toHaveBeenCalled()
196
+ })
197
+ })
@@ -0,0 +1,126 @@
1
+ /**
2
+ * ElicitationForm — schema-driven renderer for MCP `elicitation/create` requests
3
+ *
4
+ * @experimental
5
+ * @since v5.3.0
6
+ *
7
+ * Thin wrapper over `<ChatPrompt>` + `elicitationToPromptConfig()` that
8
+ * accepts a spec-shaped `ElicitationEvent` (MCP 2025-06-18) and exposes a
9
+ * spec-shaped `onAccept(content)` callback whose payload is ready to send
10
+ * back as the `accept` outcome of an `elicitation/create` reply.
11
+ *
12
+ * The mapping (boolean → confirm, single enum ≤4 → choice, else → form) is
13
+ * delegated to the `elicitationToPromptConfig` helper — same rules, same
14
+ * tests. This component owns the inverse mapping : extracting a spec-shaped
15
+ * `Record<string, unknown>` from the `ChatPromptResponse`.
16
+ *
17
+ * ## Outcome semantics (per MCP spec 2025-06-18)
18
+ *
19
+ * | User action | Callback fired | Payload |
20
+ * |---------------------------------------|--------------------|----------------------------------|
21
+ * | Submit form / pick choice / confirm | `onAccept(content)`| `{ [propName]: value, ... }` |
22
+ * | X icon / Cancel button | `onCancel()` *or* `onDecline()` if provided | none |
23
+ *
24
+ * mcp-ui's `<ChatPrompt>` does not natively distinguish "decline" (explicit
25
+ * refusal) from "cancel" (passive close). To surface a decline action,
26
+ * pass `dismissLabel="Decline"` and route the callback via `onDecline`.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * bus.events.on('onElicitation', ({ elicitation }) => {
31
+ * render(() => (
32
+ * <ElicitationForm
33
+ * event={elicitation}
34
+ * onAccept={(content) => sendElicitationReply({ action: 'accept', content })}
35
+ * onCancel={() => sendElicitationReply({ action: 'cancel' })}
36
+ * />
37
+ * ), mountPoint)
38
+ * })
39
+ * ```
40
+ */
41
+
42
+ import { Component } from 'solid-js'
43
+ import { ChatPrompt } from './ChatPrompt'
44
+ import { elicitationToPromptConfig } from '../services/chat-bus'
45
+ import type {
46
+ ChatPromptResponse,
47
+ ElicitationEvent,
48
+ ElicitationPropertySchema,
49
+ } from '../types/chat-bus'
50
+
51
+ export interface ElicitationFormProps {
52
+ /** MCP `elicitation/create` request payload to render. */
53
+ event: ElicitationEvent
54
+ /**
55
+ * Called when user submits a valid response. `content` is keyed by the
56
+ * elicitation `requestedSchema.properties` names — ready to send back as
57
+ * the `accept` outcome of an `elicitation/create` reply.
58
+ */
59
+ onAccept: (content: Record<string, unknown>) => void
60
+ /** Called when user dismisses (X icon, confirm-cancel button). */
61
+ onCancel?: () => void
62
+ /**
63
+ * Optional explicit decline action. When provided, takes precedence over
64
+ * `onCancel` on dismiss. Pair with `dismissLabel="Decline"` to surface as
65
+ * a decline action in the UI.
66
+ */
67
+ onDecline?: () => void
68
+ /** Label on the dismiss button (default: X icon). */
69
+ dismissLabel?: string
70
+ }
71
+
72
+ /**
73
+ * @experimental
74
+ * Schema-driven renderer for MCP `elicitation/create` requests.
75
+ */
76
+ export const ElicitationForm: Component<ElicitationFormProps> = (props) => {
77
+ const config = () => elicitationToPromptConfig(props.event)
78
+
79
+ const handleSubmit = (response: ChatPromptResponse): void => {
80
+ if (response.dismissed) {
81
+ ;(props.onDecline ?? props.onCancel)?.()
82
+ return
83
+ }
84
+ props.onAccept(extractContent(response, props.event))
85
+ }
86
+
87
+ return <ChatPrompt config={config()} dismissLabel={props.dismissLabel} onSubmit={handleSubmit} />
88
+ }
89
+
90
+ function extractContent(
91
+ response: ChatPromptResponse,
92
+ event: ElicitationEvent
93
+ ): Record<string, unknown> {
94
+ // Form: response.value is already a Record keyed by property names.
95
+ if (typeof response.value !== 'string') {
96
+ return response.value
97
+ }
98
+
99
+ const propEntries = Object.entries(event.requestedSchema.properties)
100
+
101
+ // Single-property cases (boolean confirm or single enum choice).
102
+ if (propEntries.length === 1) {
103
+ const [name, schema] = propEntries[0]
104
+ return { [name]: coerceScalar(response.value, schema) }
105
+ }
106
+
107
+ // Multi-property string response — shouldn't happen since the helper
108
+ // routes multi-prop schemas to 'form'. Fall back gracefully.
109
+ console.warn(
110
+ '[MCP-UI] ElicitationForm: received string value for multi-property schema. Falling back to _value.'
111
+ )
112
+ return { _value: response.value }
113
+ }
114
+
115
+ function coerceScalar(value: string, schema: ElicitationPropertySchema): unknown {
116
+ // Confirm always emits the literal 'confirmed' on accept (cancel path is
117
+ // trapped earlier by `dismissed: true`). Map to boolean true.
118
+ if (schema.type === 'boolean') return true
119
+
120
+ if (schema.type === 'number' || schema.type === 'integer') {
121
+ const n = Number(value)
122
+ return Number.isFinite(n) ? n : value
123
+ }
124
+
125
+ return value
126
+ }
@@ -88,5 +88,9 @@ export type { DataPreviewSectionProps } from './DataPreviewSection'
88
88
  export { RenderContext, RenderProvider, useRenderContext } from './RenderContext'
89
89
  export type { RenderContextValue, RenderComponentFn } from './RenderContext'
90
90
 
91
+ // MCP elicitation (v5.3.0)
92
+ export { ElicitationForm } from './ElicitationForm'
93
+ export type { ElicitationFormProps } from './ElicitationForm'
94
+
91
95
  // Default exports for lazy loading compatibility
92
96
  export { UIResourceRenderer as default } from './UIResourceRenderer'
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export type { FeedbackInlineProps, FeedbackInlineContext } from './components/Fe
43
43
  // Chat Bus (v2.4.0 — @experimental)
44
44
  export { ChatBusProvider, useChatBus } from './hooks/useChatBus'
45
45
  export { ChatPrompt } from './components/ChatPrompt'
46
+ export { ElicitationForm } from './components/ElicitationForm'
46
47
  export { ScratchpadPanel } from './components/ScratchpadPanel'
47
48
  export {
48
49
  dispatchScratchpad,
@@ -53,6 +54,20 @@ export {
53
54
  } from './stores/scratchpad-store'
54
55
  export type { ScratchpadStoreHandle } from './stores/scratchpad-store'
55
56
 
57
+ // Server Capabilities (v5.3.0)
58
+ export {
59
+ setServerCapabilities,
60
+ useServerCapabilities,
61
+ createServerCapabilitiesStore,
62
+ ServerCapabilitiesContext,
63
+ ServerCapabilitiesProvider,
64
+ } from './stores/server-capabilities-store'
65
+ export type {
66
+ ServerCapabilities,
67
+ ServerInitializeInfo,
68
+ ServerCapabilitiesStoreHandle,
69
+ } from './stores/server-capabilities-store'
70
+
56
71
  // Data Verification Components (v3.1.0)
57
72
  export { VerifiedText } from './components/VerifiedText'
58
73
  export { DataPreviewSection } from './components/DataPreviewSection'
@@ -80,6 +95,7 @@ export type { EditableUIResourceRendererProps } from './components/EditableUIRes
80
95
  export type { ExpandableWrapperProps } from './components/ExpandableWrapper'
81
96
  export type { ComponentToolbarProps, ToolbarAction, ToolbarIcon } from './components/ComponentToolbar'
82
97
  export type { ChatPromptProps } from './components/ChatPrompt'
98
+ export type { ElicitationFormProps } from './components/ElicitationForm'
83
99
  export type { ScratchpadPanelProps } from './components/ScratchpadPanel'
84
100
  export type { VerifiedTextProps } from './components/VerifiedText'
85
101
  export type { DataPreviewSectionProps } from './components/DataPreviewSection'
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Tests for server-capabilities-store — v5.3.0
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach } from 'vitest'
6
+ import { render, cleanup } from '@solidjs/testing-library'
7
+ import { createEffect, createRoot } from 'solid-js'
8
+ import {
9
+ createServerCapabilitiesStore,
10
+ setServerCapabilities,
11
+ useServerCapabilities,
12
+ ServerCapabilitiesProvider,
13
+ } from './server-capabilities-store'
14
+ import type { ServerInitializeInfo } from './server-capabilities-store'
15
+
16
+ const sampleInfo: ServerInitializeInfo = {
17
+ protocolVersion: '2025-06-18',
18
+ serverInfo: { name: 'deposium-mcp', version: '1.4.2' },
19
+ capabilities: {
20
+ tools: { listChanged: true },
21
+ prompts: { listChanged: false },
22
+ resources: { listChanged: true, subscribe: false },
23
+ },
24
+ instructions: 'Use deposium_chat for synthesis questions.',
25
+ }
26
+
27
+ describe('createServerCapabilitiesStore — v5.3.0', () => {
28
+ beforeEach(() => {
29
+ cleanup()
30
+ setServerCapabilities(null) // reset module singleton between tests
31
+ })
32
+
33
+ it('two factory stores do not share state', () => {
34
+ const storeA = createServerCapabilitiesStore()
35
+ const storeB = createServerCapabilitiesStore()
36
+
37
+ storeA.set(sampleInfo)
38
+
39
+ expect(storeA.info()?.serverInfo.name).toBe('deposium-mcp')
40
+ expect(storeB.info()).toBeNull()
41
+ })
42
+
43
+ it('accessors return null until set is called', () => {
44
+ const store = createServerCapabilitiesStore()
45
+
46
+ expect(store.info()).toBeNull()
47
+ expect(store.capabilities()).toBeNull()
48
+ expect(store.serverInfo()).toBeNull()
49
+ expect(store.protocolVersion()).toBeNull()
50
+ expect(store.hasCapability('tools')).toBe(false)
51
+ })
52
+
53
+ it('set(info) populates all derived accessors', () => {
54
+ const store = createServerCapabilitiesStore()
55
+ store.set(sampleInfo)
56
+
57
+ expect(store.protocolVersion()).toBe('2025-06-18')
58
+ expect(store.serverInfo()?.version).toBe('1.4.2')
59
+ expect(store.capabilities()?.tools?.listChanged).toBe(true)
60
+ })
61
+
62
+ it('set(null) clears the store', () => {
63
+ const store = createServerCapabilitiesStore()
64
+ store.set(sampleInfo)
65
+ expect(store.info()).not.toBeNull()
66
+
67
+ store.set(null)
68
+ expect(store.info()).toBeNull()
69
+ expect(store.hasCapability('tools')).toBe(false)
70
+ })
71
+
72
+ it('hasCapability returns true for present keys, false for absent', () => {
73
+ const store = createServerCapabilitiesStore()
74
+ store.set(sampleInfo)
75
+
76
+ expect(store.hasCapability('tools')).toBe(true)
77
+ expect(store.hasCapability('prompts')).toBe(true)
78
+ expect(store.hasCapability('resources')).toBe(true)
79
+ expect(store.hasCapability('logging')).toBe(false)
80
+ expect(store.hasCapability('completions')).toBe(false)
81
+ expect(store.hasCapability('experimental')).toBe(false)
82
+ })
83
+ })
84
+
85
+ describe('setServerCapabilities + useServerCapabilities (singleton path)', () => {
86
+ beforeEach(() => {
87
+ cleanup()
88
+ setServerCapabilities(null)
89
+ })
90
+
91
+ it('useServerCapabilities falls back to module singleton outside provider', () => {
92
+ setServerCapabilities(sampleInfo)
93
+
94
+ let captured: ReturnType<typeof useServerCapabilities> | null = null
95
+ const Probe = () => {
96
+ captured = useServerCapabilities()
97
+ return null
98
+ }
99
+ render(() => <Probe />)
100
+
101
+ expect(captured).not.toBeNull()
102
+ expect(captured!.serverInfo()?.name).toBe('deposium-mcp')
103
+ expect(captured!.hasCapability('tools')).toBe(true)
104
+ })
105
+
106
+ it('singleton state survives across renders', () => {
107
+ setServerCapabilities(sampleInfo)
108
+
109
+ const captures: Array<string | undefined> = []
110
+ const Probe = () => {
111
+ const { serverInfo } = useServerCapabilities()
112
+ captures.push(serverInfo()?.name)
113
+ return null
114
+ }
115
+ render(() => <Probe />)
116
+ render(() => <Probe />)
117
+
118
+ expect(captures).toEqual(['deposium-mcp', 'deposium-mcp'])
119
+ })
120
+ })
121
+
122
+ describe('ServerCapabilitiesProvider scoping', () => {
123
+ beforeEach(() => {
124
+ cleanup()
125
+ setServerCapabilities(null)
126
+ })
127
+
128
+ it('provider with explicit store overrides the singleton for descendants', () => {
129
+ setServerCapabilities(sampleInfo) // singleton has deposium-mcp
130
+
131
+ const scopedStore = createServerCapabilitiesStore()
132
+ scopedStore.set({
133
+ ...sampleInfo,
134
+ serverInfo: { name: 'other-mcp', version: '0.1.0' },
135
+ })
136
+
137
+ let inside: string | undefined
138
+ let outside: string | undefined
139
+
140
+ const Inside = () => {
141
+ inside = useServerCapabilities().serverInfo()?.name
142
+ return null
143
+ }
144
+ const Outside = () => {
145
+ outside = useServerCapabilities().serverInfo()?.name
146
+ return null
147
+ }
148
+
149
+ render(() => (
150
+ <>
151
+ <Outside />
152
+ <ServerCapabilitiesProvider store={scopedStore}>
153
+ <Inside />
154
+ </ServerCapabilitiesProvider>
155
+ </>
156
+ ))
157
+
158
+ expect(outside).toBe('deposium-mcp')
159
+ expect(inside).toBe('other-mcp')
160
+ })
161
+
162
+ it('provider without store creates a fresh isolated handle', () => {
163
+ setServerCapabilities(sampleInfo)
164
+
165
+ let inside: ReturnType<typeof useServerCapabilities> | null = null
166
+ const Inside = () => {
167
+ inside = useServerCapabilities()
168
+ return null
169
+ }
170
+ render(() => (
171
+ <ServerCapabilitiesProvider>
172
+ <Inside />
173
+ </ServerCapabilitiesProvider>
174
+ ))
175
+
176
+ expect(inside).not.toBeNull()
177
+ expect(inside!.info()).toBeNull() // fresh, not the singleton's state
178
+ })
179
+
180
+ it('set() updates trigger reactive effects', () => {
181
+ const store = createServerCapabilitiesStore()
182
+ const captures: Array<string | null> = []
183
+
184
+ let dispose: (() => void) | undefined
185
+ createRoot((d) => {
186
+ dispose = d
187
+ createEffect(() => {
188
+ captures.push(store.serverInfo()?.name ?? null)
189
+ })
190
+ })
191
+
192
+ // Initial effect run
193
+ expect(captures).toEqual([null])
194
+
195
+ store.set(sampleInfo)
196
+ expect(captures).toEqual([null, 'deposium-mcp'])
197
+
198
+ store.set({ ...sampleInfo, serverInfo: { name: 'renamed', version: '2.0.0' } })
199
+ expect(captures).toEqual([null, 'deposium-mcp', 'renamed'])
200
+
201
+ store.set(null)
202
+ expect(captures).toEqual([null, 'deposium-mcp', 'renamed', null])
203
+
204
+ dispose?.()
205
+ })
206
+ })