@seed-ship/mcp-ui-solid 5.2.0 → 5.3.1
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.
- package/CHANGELOG.md +50 -0
- package/dist/components/ElicitationForm.cjs +51 -0
- package/dist/components/ElicitationForm.cjs.map +1 -0
- package/dist/components/ElicitationForm.d.ts +68 -0
- package/dist/components/ElicitationForm.d.ts.map +1 -0
- package/dist/components/ElicitationForm.js +51 -0
- package/dist/components/ElicitationForm.js.map +1 -0
- package/dist/components/UIResourceRenderer.cjs +1 -1
- package/dist/components/UIResourceRenderer.js +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components.cjs +2 -0
- package/dist/components.cjs.map +1 -1
- package/dist/components.d.cts +2 -0
- package/dist/components.d.ts +2 -0
- package/dist/components.js +2 -0
- package/dist/components.js.map +1 -1
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/node_modules/.pnpm/{dompurify@3.3.3 → dompurify@3.4.1}/node_modules/dompurify/dist/purify.es.cjs +114 -53
- package/dist/node_modules/.pnpm/dompurify@3.4.1/node_modules/dompurify/dist/purify.es.cjs.map +1 -0
- package/dist/node_modules/.pnpm/{dompurify@3.3.3 → dompurify@3.4.1}/node_modules/dompurify/dist/purify.es.js +114 -53
- package/dist/node_modules/.pnpm/dompurify@3.4.1/node_modules/dompurify/dist/purify.es.js.map +1 -0
- package/dist/stores/server-capabilities-store.cjs +61 -0
- package/dist/stores/server-capabilities-store.cjs.map +1 -0
- package/dist/stores/server-capabilities-store.d.ts +172 -0
- package/dist/stores/server-capabilities-store.d.ts.map +1 -0
- package/dist/stores/server-capabilities-store.js +61 -0
- package/dist/stores/server-capabilities-store.js.map +1 -0
- package/docs/recipes/elicitation-pseudo-spec-adapter.md +171 -0
- package/docs/recipes/feedback-inline-wiring.md +142 -0
- package/package.json +2 -2
- package/src/components/ElicitationForm.test.tsx +197 -0
- package/src/components/ElicitationForm.tsx +126 -0
- package/src/components/index.ts +4 -0
- package/src/index.ts +16 -0
- package/src/stores/server-capabilities-store.test.tsx +206 -0
- package/src/stores/server-capabilities-store.tsx +215 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/node_modules/.pnpm/dompurify@3.3.3/node_modules/dompurify/dist/purify.es.cjs.map +0 -1
- package/dist/node_modules/.pnpm/dompurify@3.3.3/node_modules/dompurify/dist/purify.es.js.map +0 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Recipe — Pseudo-elicit → spec elicit adapter
|
|
2
|
+
|
|
3
|
+
> **Audience** : consumer chat apps that talk to an MCP server still emitting
|
|
4
|
+
> a *legacy* "pseudo-elicit" payload inline with `tools/call` results, but
|
|
5
|
+
> wanting to use mcp-ui's spec-correct `<ChatPrompt>` / `<ElicitationForm>`
|
|
6
|
+
> (MCP 2025-06-18).
|
|
7
|
+
>
|
|
8
|
+
> **Where this code lives** : in YOUR consumer app, not in mcp-ui. mcp-ui
|
|
9
|
+
> stays tool- and server-agnostic by design — it ships the spec helper
|
|
10
|
+
> (`elicitationToPromptConfig`, `<ElicitationForm>`) but does NOT bake in
|
|
11
|
+
> any vendor-specific wire shape.
|
|
12
|
+
|
|
13
|
+
## Why this exists
|
|
14
|
+
|
|
15
|
+
The MCP spec 2025-06-18 defines elicitation as a server→client JSON-RPC
|
|
16
|
+
*request* (`elicitation/create`) carrying `{ message, requestedSchema }`,
|
|
17
|
+
with `requestedSchema` shaped as a JSON Schema object.
|
|
18
|
+
|
|
19
|
+
Some servers ship a different convention — they return an `elicitation`
|
|
20
|
+
**object inline** in the result of a `tools/call`, with a flat `fields[]`
|
|
21
|
+
array instead of a JSON Schema. Example (deposium_MCPs as of 2026-04) :
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"jsonrpc": "2.0",
|
|
26
|
+
"id": 1,
|
|
27
|
+
"result": {
|
|
28
|
+
"elicitation": {
|
|
29
|
+
"type": "form",
|
|
30
|
+
"title": "Required Parameters Missing",
|
|
31
|
+
"description": "Please provide the following required parameters:",
|
|
32
|
+
"fields": [
|
|
33
|
+
{ "name": "tenant_id", "type": "string", "label": "Tenant ID", "required": true, "default": "<uuid>" },
|
|
34
|
+
{ "name": "space_id", "type": "string", "label": "Space ID", "required": true, "default": "default" }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Until that server migrates to spec elicitation, the chat app can adapt the
|
|
42
|
+
shape on the fly, then drive `<ElicitationForm>` (or
|
|
43
|
+
`elicitationToPromptConfig`) as if everything were spec.
|
|
44
|
+
|
|
45
|
+
## The adapter (drop-in TypeScript)
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import type { ElicitationEvent, ElicitationPropertySchema } from '@seed-ship/mcp-ui-solid'
|
|
49
|
+
|
|
50
|
+
interface PseudoElicit {
|
|
51
|
+
type: 'form'
|
|
52
|
+
title: string
|
|
53
|
+
description?: string
|
|
54
|
+
fields: Array<{
|
|
55
|
+
name: string
|
|
56
|
+
type: 'string' | 'number' | 'boolean'
|
|
57
|
+
label?: string
|
|
58
|
+
description?: string
|
|
59
|
+
required?: boolean
|
|
60
|
+
default?: unknown
|
|
61
|
+
enum?: Array<string | number>
|
|
62
|
+
}>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Convert a pseudo-elicit payload (legacy inline form spec) to a spec-shaped
|
|
67
|
+
* MCP `ElicitationEvent`. Returns `null` if the input does not look like a
|
|
68
|
+
* pseudo-elicit — the caller can then handle the tools/call result normally.
|
|
69
|
+
*/
|
|
70
|
+
export function pseudoElicitToSpec(toolResult: unknown): ElicitationEvent | null {
|
|
71
|
+
const pseudo = (toolResult as { elicitation?: PseudoElicit })?.elicitation
|
|
72
|
+
if (!pseudo || pseudo.type !== 'form' || !Array.isArray(pseudo.fields)) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const properties: Record<string, ElicitationPropertySchema> = {}
|
|
77
|
+
const required: string[] = []
|
|
78
|
+
|
|
79
|
+
for (const field of pseudo.fields) {
|
|
80
|
+
const schema: ElicitationPropertySchema = {
|
|
81
|
+
type: mapType(field.type),
|
|
82
|
+
...(field.label !== undefined && { title: field.label }),
|
|
83
|
+
...(field.description !== undefined && { description: field.description }),
|
|
84
|
+
...(field.default !== undefined && { default: field.default }),
|
|
85
|
+
...(field.enum && { enum: field.enum }),
|
|
86
|
+
}
|
|
87
|
+
properties[field.name] = schema
|
|
88
|
+
if (field.required) required.push(field.name)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
message: [pseudo.title, pseudo.description].filter(Boolean).join(' — '),
|
|
93
|
+
requestedSchema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties,
|
|
96
|
+
...(required.length > 0 && { required }),
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function mapType(t: string): ElicitationPropertySchema['type'] {
|
|
102
|
+
if (t === 'number' || t === 'boolean') return t
|
|
103
|
+
return 'string' // safe fallback for unknown legacy types
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Wiring it into the chat app
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { bus } from './your-bus-instance'
|
|
111
|
+
import { ElicitationForm } from '@seed-ship/mcp-ui-solid'
|
|
112
|
+
import { pseudoElicitToSpec } from './adapters/pseudo-elicit'
|
|
113
|
+
|
|
114
|
+
async function callTool(name: string, args: Record<string, unknown>) {
|
|
115
|
+
const response = await mcpClient.callTool(name, args)
|
|
116
|
+
|
|
117
|
+
// 1. Check for pseudo-elicit BEFORE treating result as a normal tool output.
|
|
118
|
+
const elicit = pseudoElicitToSpec(response.result)
|
|
119
|
+
if (elicit) {
|
|
120
|
+
showElicitationDialog(elicit, async (content) => {
|
|
121
|
+
// 2. Re-invoke the tool with the collected args merged in.
|
|
122
|
+
return callTool(name, { ...args, ...content })
|
|
123
|
+
})
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 3. Normal tool output path.
|
|
128
|
+
handleToolResult(response.result)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function showElicitationDialog(
|
|
132
|
+
event: ElicitationEvent,
|
|
133
|
+
onAccept: (content: Record<string, unknown>) => Promise<void>
|
|
134
|
+
) {
|
|
135
|
+
// Mount <ElicitationForm> in your modal layer, or pipe through the bus :
|
|
136
|
+
bus.events.emit('onElicitation', { streamKey: 'main', elicitation: event })
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
If you also want `<ElicitationForm>` to render directly :
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
<Show when={pendingElicit()}>
|
|
144
|
+
<ElicitationForm
|
|
145
|
+
event={pendingElicit()!}
|
|
146
|
+
onAccept={async (content) => {
|
|
147
|
+
setPendingElicit(null)
|
|
148
|
+
await retryToolCall(content)
|
|
149
|
+
}}
|
|
150
|
+
onCancel={() => setPendingElicit(null)}
|
|
151
|
+
/>
|
|
152
|
+
</Show>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Going both ways (spec + pseudo)
|
|
156
|
+
|
|
157
|
+
Once your server migrates to real `elicitation/create` (server→client
|
|
158
|
+
JSON-RPC request over a bidirectional transport), keep the adapter
|
|
159
|
+
in place — it's harmless on a normal `tools/call` result (returns
|
|
160
|
+
`null`) and lets you support both wire shapes for the duration of the
|
|
161
|
+
rollout.
|
|
162
|
+
|
|
163
|
+
For the spec path, your transport adapter handles the JSON-RPC request
|
|
164
|
+
directly and emits the same `onElicitation` event with a payload that
|
|
165
|
+
already matches `ElicitationEvent` — no adapter call needed.
|
|
166
|
+
|
|
167
|
+
## Reference
|
|
168
|
+
|
|
169
|
+
- MCP spec : https://spec.modelcontextprotocol.io/specification/2025-06-18/client/elicitation/
|
|
170
|
+
- mcp-ui types : `ElicitationEvent`, `ElicitationRequestedSchema`, `ElicitationPropertySchema` (all exported from `@seed-ship/mcp-ui-solid`)
|
|
171
|
+
- mcp-ui helpers : `elicitationToPromptConfig` (services/chat-bus), `<ElicitationForm>` (components)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Recipe — Wire `<FeedbackInline>` to a feedback HTTP endpoint
|
|
2
|
+
|
|
3
|
+
> **Audience** : consumer apps that ship `<FeedbackInline>` (per-message
|
|
4
|
+
> thumbs-up/down) and want to persist ratings to a backend.
|
|
5
|
+
>
|
|
6
|
+
> mcp-ui's `<FeedbackInline>` is intentionally endpoint-agnostic — it
|
|
7
|
+
> calls `onSubmit(rating, context)` and the consumer owns the HTTP / store
|
|
8
|
+
> wiring. This recipe shows the most common pattern, using the Deposium
|
|
9
|
+
> `POST /api/feedback` endpoint as a concrete example.
|
|
10
|
+
|
|
11
|
+
## What `<FeedbackInline>` gives you
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
<FeedbackInline
|
|
15
|
+
messageHash={msg.hash}
|
|
16
|
+
context={{ intent: msg.intent, confidenceBand: msg.band }}
|
|
17
|
+
onSubmit={(rating, context) => persistFeedback(rating, context)}
|
|
18
|
+
/>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The component :
|
|
22
|
+
- Renders two buttons (positive / negative).
|
|
23
|
+
- Flips to "submitted" optimistically on click — UI does NOT revert on network error (best-effort design).
|
|
24
|
+
- Calls `onSubmit('positive' | 'negative', context?)` exactly once.
|
|
25
|
+
|
|
26
|
+
`rating` already matches the shape Deposium expects. Mapping is direct.
|
|
27
|
+
|
|
28
|
+
## Endpoint reference (Deposium)
|
|
29
|
+
|
|
30
|
+
`POST /api/feedback` — no auth required, behind the chat-stream / standard
|
|
31
|
+
middleware chain.
|
|
32
|
+
|
|
33
|
+
### Request body
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
interface FeedbackRequest {
|
|
37
|
+
message_hash: string // REQUIRED — message ID being rated
|
|
38
|
+
rating: 'positive' | 'negative' | 'partial'
|
|
39
|
+
confidence_band?: 'high' | 'medium' | 'low' // optional, free-form string
|
|
40
|
+
intent?: string // optional, e.g. 'search_query'
|
|
41
|
+
space_ids?: string[] | string | null
|
|
42
|
+
comment?: string
|
|
43
|
+
tenant_id?: string
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Response
|
|
48
|
+
|
|
49
|
+
| Status | Body |
|
|
50
|
+
|---|---|
|
|
51
|
+
| 200 | `{ ok: true, id: 'fb_<timestamp>_<rand4>' }` |
|
|
52
|
+
| 400 | `{ error: 'rating must be one of: positive, negative, partial' }` |
|
|
53
|
+
|
|
54
|
+
### Side effects (worth knowing)
|
|
55
|
+
|
|
56
|
+
- `INSERT` into `logs.feedback` (PostgreSQL) — drives dashboard analytics.
|
|
57
|
+
- `'positive' | 'negative'` ratings also update
|
|
58
|
+
`logs.intent_classifications.feedback_success`. `'partial'` does **not**
|
|
59
|
+
propagate (intentional — neither true nor false).
|
|
60
|
+
|
|
61
|
+
## Wiring (the recipe)
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { FeedbackInline, type FeedbackInlineContext } from '@seed-ship/mcp-ui-solid'
|
|
65
|
+
|
|
66
|
+
function persistFeedback(
|
|
67
|
+
messageHash: string,
|
|
68
|
+
rating: 'positive' | 'negative',
|
|
69
|
+
ctx?: FeedbackInlineContext
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
return fetch('/api/feedback', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
message_hash: messageHash,
|
|
76
|
+
rating, // 'positive' | 'negative' — matches endpoint as-is
|
|
77
|
+
...(ctx?.intent && { intent: ctx.intent }),
|
|
78
|
+
...(ctx?.confidenceBand && { confidence_band: ctx.confidenceBand }),
|
|
79
|
+
...(ctx?.tenantId && { tenant_id: ctx.tenantId }),
|
|
80
|
+
...(ctx?.spaceIds && { space_ids: ctx.spaceIds }),
|
|
81
|
+
...(ctx?.comment && { comment: ctx.comment }),
|
|
82
|
+
}),
|
|
83
|
+
}).then(async (res) => {
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
console.warn('[feedback] persist failed', res.status, await res.text())
|
|
86
|
+
}
|
|
87
|
+
}).catch((err) => {
|
|
88
|
+
// Silent failure — UI is already in the optimistic "submitted" state.
|
|
89
|
+
console.warn('[feedback] network error', err)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function MessageRow(props: { msg: ChatMessage }) {
|
|
94
|
+
return (
|
|
95
|
+
<div class="message-row">
|
|
96
|
+
<p>{props.msg.text}</p>
|
|
97
|
+
<FeedbackInline
|
|
98
|
+
messageHash={props.msg.hash}
|
|
99
|
+
context={{
|
|
100
|
+
intent: props.msg.intent,
|
|
101
|
+
confidenceBand: props.msg.confidenceBand,
|
|
102
|
+
}}
|
|
103
|
+
onSubmit={(rating, ctx) => persistFeedback(props.msg.hash, rating, ctx)}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Variations
|
|
111
|
+
|
|
112
|
+
### "Partial" rating
|
|
113
|
+
|
|
114
|
+
`<FeedbackInline>` emits only `'positive'` / `'negative'`. If you need a
|
|
115
|
+
third state (`'partial'`), build a separate UI (e.g. a star rating or a
|
|
116
|
+
3-button row) and call the endpoint directly with `rating: 'partial'`.
|
|
117
|
+
|
|
118
|
+
### Free-text comment
|
|
119
|
+
|
|
120
|
+
Add a textarea below `<FeedbackInline>` that opens after the rating click.
|
|
121
|
+
Send a follow-up `POST /api/feedback` with the same `message_hash` and a
|
|
122
|
+
`comment` field — the endpoint accepts multiple records per message.
|
|
123
|
+
|
|
124
|
+
### Optimistic vs strict semantics
|
|
125
|
+
|
|
126
|
+
Default behavior is best-effort (UI never reverts). If you need stricter
|
|
127
|
+
semantics — offline retry queue, edit-rating UX — wrap `<FeedbackInline>`
|
|
128
|
+
in your own component and own the state externally instead of relying on
|
|
129
|
+
the component's internal flip.
|
|
130
|
+
|
|
131
|
+
## Where this code lives
|
|
132
|
+
|
|
133
|
+
In your consumer app. mcp-ui ships `<FeedbackInline>` and the `onSubmit`
|
|
134
|
+
contract; the HTTP wiring (URL, auth, retry policy, schema mapping) is
|
|
135
|
+
the consumer's responsibility by design — same pattern as
|
|
136
|
+
`pseudo-elicit-spec-adapter`.
|
|
137
|
+
|
|
138
|
+
## Reference
|
|
139
|
+
|
|
140
|
+
- mcp-ui component : `<FeedbackInline>` (exported from `@seed-ship/mcp-ui-solid`)
|
|
141
|
+
- mcp-ui types : `FeedbackInlineProps`, `FeedbackInlineContext`
|
|
142
|
+
- Deposium endpoint : `POST /api/feedback` — see deposium_MCPs `src/routes/feedback.ts`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seed-ship/mcp-ui-solid",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.1",
|
|
4
4
|
"description": "SolidJS components for rendering MCP-generated UI resources",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
},
|
|
141
141
|
"dependencies": {
|
|
142
142
|
"@types/dompurify": "^3.0.5",
|
|
143
|
-
"dompurify": "^3.
|
|
143
|
+
"dompurify": "^3.4.1",
|
|
144
144
|
"marked": "^16.3.0",
|
|
145
145
|
"zod": "^3.22.4"
|
|
146
146
|
},
|
|
@@ -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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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'
|