@seed-ship/mcp-ui-solid 6.6.0 → 6.7.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 (49) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/adapters/index.d.ts +2 -1
  3. package/dist/adapters/index.d.ts.map +1 -1
  4. package/dist/adapters/macro-run.cjs +226 -0
  5. package/dist/adapters/macro-run.cjs.map +1 -0
  6. package/dist/adapters/macro-run.d.ts +65 -0
  7. package/dist/adapters/macro-run.d.ts.map +1 -0
  8. package/dist/adapters/macro-run.js +226 -0
  9. package/dist/adapters/macro-run.js.map +1 -0
  10. package/dist/adapters.cjs +3 -0
  11. package/dist/adapters.cjs.map +1 -1
  12. package/dist/adapters.d.cts +2 -1
  13. package/dist/adapters.d.ts +2 -1
  14. package/dist/adapters.js +4 -1
  15. package/dist/adapters.js.map +1 -1
  16. package/dist/components/ActionGroupRenderer.cjs +12 -3
  17. package/dist/components/ActionGroupRenderer.cjs.map +1 -1
  18. package/dist/components/ActionGroupRenderer.d.ts.map +1 -1
  19. package/dist/components/ActionGroupRenderer.js +12 -3
  20. package/dist/components/ActionGroupRenderer.js.map +1 -1
  21. package/dist/components/UIResourceRenderer.cjs +22 -15
  22. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  23. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  24. package/dist/components/UIResourceRenderer.js +22 -15
  25. package/dist/components/UIResourceRenderer.js.map +1 -1
  26. package/dist/context/MCPActionContext.cjs +4 -1
  27. package/dist/context/MCPActionContext.cjs.map +1 -1
  28. package/dist/context/MCPActionContext.d.ts +13 -1
  29. package/dist/context/MCPActionContext.d.ts.map +1 -1
  30. package/dist/context/MCPActionContext.js +4 -1
  31. package/dist/context/MCPActionContext.js.map +1 -1
  32. package/dist/mcp-ui-spec/dist/schemas.cjs +250 -1
  33. package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
  34. package/dist/mcp-ui-spec/dist/schemas.js +251 -2
  35. package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
  36. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.cjs +2 -0
  37. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.cjs.map +1 -1
  38. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.js +2 -0
  39. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/adapters/index.ts +4 -5
  42. package/src/adapters/macro-run.test.ts +293 -0
  43. package/src/adapters/macro-run.ts +362 -0
  44. package/src/components/ActionGroupRenderer.test.tsx +1 -0
  45. package/src/components/ActionGroupRenderer.tsx +19 -4
  46. package/src/components/ActionSubmit.test.tsx +188 -0
  47. package/src/components/UIResourceRenderer.tsx +19 -6
  48. package/src/context/MCPActionContext.tsx +17 -1
  49. package/tsconfig.tsbuildinfo +1 -1
@@ -26,7 +26,12 @@ const ActionButton: Component<{
26
26
  action: ActionComponentParams
27
27
  index: number
28
28
  }> = (props) => {
29
- const { execute, isExecuting } = useAction()
29
+ const { execute, executeAction, isExecuting } = useAction()
30
+
31
+ // tool-call and submit both go through the host executor — they show a
32
+ // loading state and gate disabled while running. link does neither.
33
+ const isExecutable = () =>
34
+ props.action.action === 'tool-call' || props.action.action === 'submit'
30
35
 
31
36
  const handleClick = async (e: MouseEvent) => {
32
37
  if (props.action.disabled) return
@@ -34,13 +39,23 @@ const ActionButton: Component<{
34
39
  if (props.action.action === 'tool-call' && props.action.toolName) {
35
40
  e.preventDefault()
36
41
  await execute(props.action.toolName, props.action.params || {})
42
+ } else if (props.action.action === 'submit') {
43
+ // submit is NOT a tool call — route it through the executor with the
44
+ // `action: 'submit'` kind preserved so the host can POST to
45
+ // params.submit_url etc. Works without a surrounding <form>.
46
+ e.preventDefault()
47
+ await executeAction({
48
+ action: 'submit',
49
+ toolName: props.action.toolName || 'submit',
50
+ params: props.action.params || {},
51
+ })
37
52
  } else if (props.action.action === 'link' && props.action.url) {
38
53
  window.open(props.action.url, '_blank', 'noopener,noreferrer')
39
54
  }
40
55
  }
41
56
 
42
57
  const isDisabled = () =>
43
- props.action.disabled || (props.action.action === 'tool-call' && isExecuting())
58
+ props.action.disabled || (isExecutable() && isExecuting())
44
59
 
45
60
  const variantClass = () => {
46
61
  switch (props.action.variant) {
@@ -98,10 +113,10 @@ const ActionButton: Component<{
98
113
  isDisabled() ? 'opacity-50 cursor-not-allowed' : ''
99
114
  }`}
100
115
  >
101
- <Show when={isExecuting() && props.action.action === 'tool-call'}>
116
+ <Show when={isExecuting() && isExecutable()}>
102
117
  <span class="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
103
118
  </Show>
104
- <Show when={props.action.icon && !(isExecuting() && props.action.action === 'tool-call')}>
119
+ <Show when={props.action.icon && !(isExecuting() && isExecutable())}>
105
120
  <span class="text-current">{props.action.icon}</span>
106
121
  </Show>
107
122
  {props.action.label}
@@ -0,0 +1,188 @@
1
+ /**
2
+ * v6.6.1 — `action: 'submit'` reaches the host executor outside a <form>.
3
+ *
4
+ * Before v6.6.1, `submit` actions were inert : `ActionGroupRenderer` only
5
+ * branched on `tool-call` / `link`, and the standalone `action` renderer
6
+ * emitted a native `type="submit"` button that did nothing outside a real
7
+ * `<form>`. This file pins the fix — a full integration test through the
8
+ * real `useAction` → `MCPActionContext` → host `executor` path (no mocks).
9
+ *
10
+ * Covers both render surfaces :
11
+ * - `<ActionGroupRenderer>` (action-group)
12
+ * - `<UIResourceRenderer content={{ type: 'action', ... }}>` (standalone)
13
+ */
14
+
15
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
16
+ import { render, screen, fireEvent, waitFor, cleanup } from '@solidjs/testing-library'
17
+ import { ActionGroupRenderer } from './ActionGroupRenderer'
18
+ import { UIResourceRenderer } from './UIResourceRenderer'
19
+ import { MCPActionProvider } from '../context/MCPActionContext'
20
+ import type { ActionRequest, ActionResult } from '../context/MCPActionContext'
21
+ import type { UIComponent } from '../types'
22
+
23
+ // The connector "feedback format" payload from the bug report.
24
+ const SUBMIT_PARAMS = {
25
+ submit_url: '/api/connector-render-feedback',
26
+ feedback_kind: 'presentation',
27
+ connector_id: 'clinicaltrials',
28
+ tool_name: 'clinicaltrials_search',
29
+ render_kind: 'clinical_trial_search',
30
+ preferred_layout_options: ['table', 'cards', 'bar'],
31
+ }
32
+
33
+ function makeExecutor() {
34
+ const calls: ActionRequest[] = []
35
+ const executor = vi.fn(async (req: ActionRequest): Promise<ActionResult> => {
36
+ calls.push(req)
37
+ return { success: true, timestamp: new Date().toISOString(), toolName: req.toolName }
38
+ })
39
+ return { executor, calls }
40
+ }
41
+
42
+ describe('action: submit reaches the host executor (v6.6.1)', () => {
43
+ beforeEach(() => cleanup())
44
+
45
+ it('ActionGroupRenderer routes a submit action to the executor', async () => {
46
+ const { executor, calls } = makeExecutor()
47
+ const component: UIComponent = {
48
+ id: 'ag',
49
+ type: 'action-group',
50
+ position: { colStart: 1, colSpan: 12 },
51
+ params: {
52
+ actions: [
53
+ { label: 'Feedback format', action: 'submit', params: SUBMIT_PARAMS },
54
+ ],
55
+ },
56
+ } as UIComponent
57
+
58
+ render(() => (
59
+ <MCPActionProvider executor={executor}>
60
+ <ActionGroupRenderer component={component} />
61
+ </MCPActionProvider>
62
+ ))
63
+
64
+ fireEvent.click(screen.getByRole('button', { name: 'Feedback format' }))
65
+
66
+ await waitFor(() => expect(executor).toHaveBeenCalledTimes(1))
67
+ const req = calls[0]
68
+ // The action KIND is preserved — host can tell it apart from a tool call.
69
+ expect(req.action).toBe('submit')
70
+ // The full params payload survives intact.
71
+ expect(req.params).toEqual(SUBMIT_PARAMS)
72
+ })
73
+
74
+ it('a submit action is NOT executed as a tool call', async () => {
75
+ const { executor, calls } = makeExecutor()
76
+ const component: UIComponent = {
77
+ id: 'ag',
78
+ type: 'action-group',
79
+ position: { colStart: 1, colSpan: 12 },
80
+ params: {
81
+ actions: [{ label: 'Send', action: 'submit', params: SUBMIT_PARAMS }],
82
+ },
83
+ } as UIComponent
84
+
85
+ render(() => (
86
+ <MCPActionProvider executor={executor}>
87
+ <ActionGroupRenderer component={component} />
88
+ </MCPActionProvider>
89
+ ))
90
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }))
91
+
92
+ await waitFor(() => expect(executor).toHaveBeenCalledTimes(1))
93
+ // action is 'submit', never silently coerced to 'tool-call'
94
+ expect(calls[0].action).not.toBe('tool-call')
95
+ expect(calls[0].action).toBe('submit')
96
+ })
97
+
98
+ it('standalone action component (UIResourceRenderer) routes submit to the executor', async () => {
99
+ const { executor, calls } = makeExecutor()
100
+ const component: UIComponent = {
101
+ id: 'act',
102
+ type: 'action',
103
+ position: { colStart: 1, colSpan: 12 },
104
+ params: { label: 'Feedback format', action: 'submit', params: SUBMIT_PARAMS },
105
+ } as UIComponent
106
+
107
+ render(() => (
108
+ <MCPActionProvider executor={executor}>
109
+ <UIResourceRenderer content={component} />
110
+ </MCPActionProvider>
111
+ ))
112
+
113
+ fireEvent.click(screen.getByRole('button', { name: 'Feedback format' }))
114
+
115
+ await waitFor(() => expect(executor).toHaveBeenCalledTimes(1))
116
+ expect(calls[0].action).toBe('submit')
117
+ expect(calls[0].params).toEqual(SUBMIT_PARAMS)
118
+ })
119
+
120
+ it('standalone submit button is type="button" — not a native form submit', () => {
121
+ const component: UIComponent = {
122
+ id: 'act',
123
+ type: 'action',
124
+ position: { colStart: 1, colSpan: 12 },
125
+ params: { label: 'Send', action: 'submit', params: {} },
126
+ } as UIComponent
127
+
128
+ const { container } = render(() => (
129
+ <MCPActionProvider executor={makeExecutor().executor}>
130
+ <UIResourceRenderer content={component} />
131
+ </MCPActionProvider>
132
+ ))
133
+ const btn = container.querySelector('button')
134
+ // Must NOT rely on a surrounding <form> — JS-handled, type=button.
135
+ expect(btn?.getAttribute('type')).toBe('button')
136
+ })
137
+
138
+ it('tool-call actions still work unchanged', async () => {
139
+ const { executor, calls } = makeExecutor()
140
+ const component: UIComponent = {
141
+ id: 'ag',
142
+ type: 'action-group',
143
+ position: { colStart: 1, colSpan: 12 },
144
+ params: {
145
+ actions: [
146
+ { label: 'Run', action: 'tool-call', toolName: 'do_thing', params: { a: 1 } },
147
+ ],
148
+ },
149
+ } as UIComponent
150
+
151
+ render(() => (
152
+ <MCPActionProvider executor={executor}>
153
+ <ActionGroupRenderer component={component} />
154
+ </MCPActionProvider>
155
+ ))
156
+ fireEvent.click(screen.getByRole('button', { name: 'Run' }))
157
+
158
+ await waitFor(() => expect(executor).toHaveBeenCalledTimes(1))
159
+ expect(calls[0].toolName).toBe('do_thing')
160
+ expect(calls[0].params).toEqual({ a: 1 })
161
+ })
162
+
163
+ it('a disabled submit action does not call the executor', async () => {
164
+ const { executor } = makeExecutor()
165
+ const component: UIComponent = {
166
+ id: 'ag',
167
+ type: 'action-group',
168
+ position: { colStart: 1, colSpan: 12 },
169
+ params: {
170
+ actions: [
171
+ { label: 'Send', action: 'submit', params: SUBMIT_PARAMS, disabled: true },
172
+ ],
173
+ },
174
+ } as UIComponent
175
+
176
+ render(() => (
177
+ <MCPActionProvider executor={executor}>
178
+ <ActionGroupRenderer component={component} />
179
+ </MCPActionProvider>
180
+ ))
181
+ const btn = screen.getByRole('button', { name: 'Send' })
182
+ expect(btn.hasAttribute('disabled')).toBe(true)
183
+ fireEvent.click(btn)
184
+ // Give any async handler a tick — nothing should fire.
185
+ await new Promise((r) => setTimeout(r, 10))
186
+ expect(executor).not.toHaveBeenCalled()
187
+ })
188
+ })
@@ -1524,9 +1524,13 @@ function ComponentRenderer(props: {
1524
1524
  */
1525
1525
  function ActionRenderer(props: { component: UIComponent }) {
1526
1526
  const params = props.component.params as any
1527
- const { execute, isExecuting } = useAction()
1527
+ const { execute, executeAction, isExecuting } = useAction()
1528
1528
  const telemetry = useTelemetry()
1529
1529
 
1530
+ // tool-call and submit both run through the host executor — loading +
1531
+ // disabled state apply to both. link does neither.
1532
+ const isExecutable = () => params.action === 'tool-call' || params.action === 'submit'
1533
+
1530
1534
  // Telemetry: action:dispatched on click (B.5 — v5.6.0). Fires for every
1531
1535
  // click attempt (tool-call or link), regardless of execute success.
1532
1536
  // Privacy: actionName is `toolName` (tool-call) or the action kind
@@ -1549,11 +1553,20 @@ function ActionRenderer(props: { component: UIComponent }) {
1549
1553
  if (params.action === 'tool-call' && params.toolName) {
1550
1554
  e.preventDefault()
1551
1555
  await execute(params.toolName, params.params || {})
1556
+ } else if (params.action === 'submit') {
1557
+ // submit is NOT a tool call — route through the executor with the
1558
+ // `action: 'submit'` kind preserved. Works outside any <form>.
1559
+ e.preventDefault()
1560
+ await executeAction({
1561
+ action: 'submit',
1562
+ toolName: params.toolName || 'submit',
1563
+ params: params.params || {},
1564
+ })
1552
1565
  }
1553
1566
  }
1554
1567
 
1555
1568
  // Determine if button should be disabled (explicit disable or currently executing)
1556
- const isDisabled = () => params.disabled || (params.action === 'tool-call' && isExecuting())
1569
+ const isDisabled = () => params.disabled || (isExecutable() && isExecuting())
1557
1570
 
1558
1571
  if (params.type === 'link' || params.action === 'link') {
1559
1572
  return (
@@ -1579,9 +1592,9 @@ function ActionRenderer(props: { component: UIComponent }) {
1579
1592
 
1580
1593
  return (
1581
1594
  <button
1582
- type={params.action === 'submit' ? 'submit' : 'button'}
1595
+ type="button"
1583
1596
  disabled={isDisabled()}
1584
- aria-busy={isExecuting() && params.action === 'tool-call'}
1597
+ aria-busy={isExecuting() && isExecutable()}
1585
1598
  aria-label={params.ariaLabel || params.label}
1586
1599
  class={`inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
1587
1600
  ${params.variant === 'primary' ? 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm' :
@@ -1594,10 +1607,10 @@ function ActionRenderer(props: { component: UIComponent }) {
1594
1607
  ${params.className || ''}`}
1595
1608
  onClick={handleClick}
1596
1609
  >
1597
- <Show when={isExecuting() && params.action === 'tool-call'}>
1610
+ <Show when={isExecuting() && isExecutable()}>
1598
1611
  <span class="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" aria-hidden="true" />
1599
1612
  </Show>
1600
- <Show when={params.icon && !(isExecuting() && params.action === 'tool-call')}>
1613
+ <Show when={params.icon && !(isExecuting() && isExecutable())}>
1601
1614
  <span aria-hidden="true">{params.icon}</span>
1602
1615
  </Show>
1603
1616
  {params.label}
@@ -10,7 +10,9 @@ import { createContext, createSignal, useContext, ParentComponent, Accessor } fr
10
10
  */
11
11
  export interface ActionRequest {
12
12
  /**
13
- * MCP tool name to execute
13
+ * MCP tool name to execute. For a `submit` action with no associated
14
+ * tool, renderers pass the sentinel `'submit'` — branch on `action`,
15
+ * not on `toolName`, to tell a submit apart from a tool call.
14
16
  */
15
17
  toolName: string
16
18
 
@@ -28,6 +30,17 @@ export interface ActionRequest {
28
30
  * Optional macro ID for template execution
29
31
  */
30
32
  macroId?: string
33
+
34
+ /**
35
+ * Action kind (v6.6.1). Lets a host `executor` tell a tool call apart
36
+ * from a form-style `submit`. Absent ⇒ treat as `'tool-call'` (backward
37
+ * compatible — every pre-v6.6.1 request omits it).
38
+ *
39
+ * A `submit` action carries its payload in `params` (e.g. `submit_url`,
40
+ * `connector_id`, `feedback_value`) and **must NOT** be executed as a
41
+ * tool call — the host routes it (e.g. POST to `params.submit_url`).
42
+ */
43
+ action?: 'tool-call' | 'submit' | 'link'
31
44
  }
32
45
 
33
46
  /**
@@ -149,6 +162,9 @@ const defaultExecutor = async (request: ActionRequest): Promise<ActionResult> =>
149
162
  params: request.params || {},
150
163
  spaceIds: request.spaceIds,
151
164
  macroId: request.macroId,
165
+ // v6.6.1 — action kind so a window-level listener can route a
166
+ // `submit` (POST to params.submit_url) vs a tool call.
167
+ action: request.action ?? 'tool-call',
152
168
  },
153
169
  bubbles: true,
154
170
  })