@candor-ui/react 1.7.0 → 1.8.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.
@@ -144,6 +144,21 @@ export type FromClaudeAgentOptions = {
144
144
  * Claude's own citations and web search results are read without it.
145
145
  */
146
146
  sourcesFrom?: ((tool: string, input: unknown, output: string) => ToolSource[] | undefined) | undefined;
147
+ /**
148
+ * The words for lines the adapter adds to the thread itself, as system messages: when the SDK summarised earlier
149
+ * messages to make room (`compacted`, given how it was started), and when the conversation was cleared (`cleared`).
150
+ * Defaults are in English; pass your own for other languages.
151
+ */
152
+ eventText?: {
153
+ compacted?: ((trigger: "manual" | "auto") => string) | undefined;
154
+ cleared?: string | undefined;
155
+ } | undefined;
156
+ };
157
+ /** A memory the SDK recalled for a reply: the file (or URL) it came from, and whose memory it is. */
158
+ export type ClaudeRecalledMemory = {
159
+ path: string;
160
+ scope: "personal" | "team" | "organization";
161
+ content?: string | undefined;
147
162
  };
148
163
  export type ClaudeAgentState = {
149
164
  messages: Message[];
@@ -156,6 +171,8 @@ export type ClaudeAgentState = {
156
171
  notice?: string | undefined;
157
172
  /** The permission mode last reported, by the SDK or the app. Absent until one is. */
158
173
  permissionMode?: ClaudePermissionMode | undefined;
174
+ /** Memories the SDK recalled for each reply, by the reply's message id, for a `MemoryUsed` line under it. Only replies that recalled any. */
175
+ recalled?: Record<string, ClaudeRecalledMemory[]> | undefined;
159
176
  };
160
177
  /**
161
178
  * Turns Claude Agent SDK events into Candor messages, waiting approvals and usage.
@@ -1,4 +1,8 @@
1
1
  import { markdownBlocks, plainBlocks } from "../lib/safe-markdown.js";
2
+ const compactedText = (trigger) => trigger === "auto"
3
+ ? "Earlier messages were summarised to make room. The agent works from that summary now; the messages above are unchanged."
4
+ : "Earlier messages were summarised, as asked. The agent works from that summary now; the messages above are unchanged.";
5
+ const clearedText = "The conversation was cleared. The agent doesn’t see the messages above.";
2
6
  /**
3
7
  * Turns Claude Agent SDK events into Candor messages, waiting approvals and usage.
4
8
  * Pure, so it can run on every render; useClaudeAgent calls it for you.
@@ -14,11 +18,35 @@ export function fromClaudeAgentEvents(events, options) {
14
18
  let lastCost = 0;
15
19
  let notice;
16
20
  let permissionMode;
17
- const current = (id) => (reply ??= { id, parts: [], reasoning: [], tools: new Map(), live: new Map(), sources: new Map(), inputs: new Map() });
21
+ const recalled = {};
22
+ // Memories recalled before the reply they're for has started.
23
+ let recalling = [];
24
+ const current = (id) => {
25
+ if (!reply) {
26
+ reply = { id, parts: [], reasoning: [], tools: new Map(), live: new Map(), sources: new Map(), inputs: new Map(), memories: recalling };
27
+ recalling = [];
28
+ }
29
+ return reply;
30
+ };
31
+ // Lines that arrived while a reply was still going: shown after it, so one turn stays one reply.
32
+ let afterReply = [];
18
33
  const close = (last) => {
19
- if (reply)
34
+ if (reply) {
20
35
  messages.push(render(reply, { ...options, model, last }));
36
+ if (reply.memories.length)
37
+ recalled[reply.id] = reply.memories;
38
+ }
21
39
  reply = null;
40
+ messages.push(...afterReply);
41
+ afterReply = [];
42
+ };
43
+ // A line the adapter adds between replies: the app's own words, drawn like any system message.
44
+ const systemLine = (id, text) => {
45
+ const line = { id, author: { role: "system" }, status: "complete", blocks: plainBlocks(text) };
46
+ if (reply)
47
+ afterReply.push(line);
48
+ else
49
+ messages.push(line);
22
50
  };
23
51
  for (const e of events) {
24
52
  // Subagents report through their parent's tool call; the main thread is what the person reads.
@@ -90,6 +118,21 @@ export function fromClaudeAgentEvents(events, options) {
90
118
  else if (e.subtype === "api_retry") {
91
119
  notice = `Claude’s API didn’t answer${e.error_status ? ` (${e.error_status})` : ""}; trying again, attempt ${e.attempt} of ${e.max_retries}.`;
92
120
  }
121
+ else if (e.subtype === "compact_boundary") {
122
+ // Without this line the person can't tell the agent no longer has the earlier messages word for word.
123
+ const trigger = e.compact_metadata.trigger;
124
+ systemLine(`compact-${e.uuid}`, options.eventText?.compacted?.(trigger) ?? compactedText(trigger));
125
+ }
126
+ else if (e.subtype === "memory_recall") {
127
+ const found = e.memories.map((m) => ({ path: m.path, scope: m.scope, ...(m.content !== undefined && { content: m.content }) }));
128
+ if (reply)
129
+ reply.memories.push(...found);
130
+ else
131
+ recalling.push(...found);
132
+ }
133
+ break;
134
+ case "conversation_reset":
135
+ systemLine(`reset-${e.uuid}`, options.eventText?.cleared ?? clearedText);
93
136
  break;
94
137
  case "stream_event": {
95
138
  const r = current(e.uuid);
@@ -170,6 +213,7 @@ export function fromClaudeAgentEvents(events, options) {
170
213
  usage,
171
214
  notice: options.running ? notice : undefined,
172
215
  permissionMode,
216
+ recalled,
173
217
  };
174
218
  }
175
219
  /** Adds a source once (by key) and returns its id. Markers count up in the order sources first appear. */
@@ -13,6 +13,7 @@ export declare const composerStrings: {
13
13
  hintSend: string;
14
14
  hintNewLine: string;
15
15
  hintMention: string;
16
+ hintCommand: string;
16
17
  stop: string;
17
18
  send: string;
18
19
  dropFiles: string;
@@ -42,7 +43,7 @@ export type ComposerState = "ready" | "sending" | "streaming" | {
42
43
  * (`{ kind: "held", reason }`) keeps typing open and says why sending waits.
43
44
  * `textareaRef` focuses the box from outside, e.g. after a new project starts.
44
45
  */
45
- declare function Composer({ value, onChange, onSubmit, onStop, state, scope, onScopeClick, mentions, commands, references, onReferencesChange, attachments, onAttach, accept, onRemoveAttachment, placeholder: placeholderProp, label: labelProp, tools, textareaRef, sendShape, className, }: {
46
+ declare function Composer({ value, onChange, onSubmit, onStop, state, scope, onScopeClick, mentions, commands, references, onReferencesChange, attachments, onAttach, accept, onRemoveAttachment, placeholder: placeholderProp, label: labelProp, tools, textareaRef, sendShape, hints, className, }: {
46
47
  /** The text in the box. */
47
48
  value: string;
48
49
  /** Called with the new text as the person types. */
@@ -82,6 +83,11 @@ declare function Composer({ value, onChange, onSubmit, onStop, state, scope, onS
82
83
  label?: string | undefined;
83
84
  /** Your own controls in the toolbar, after the attach button: a dictation button, a model picker. */
84
85
  tools?: React.ReactNode | undefined;
86
+ /**
87
+ * The keyboard hints shown at wide widths ("↵ send · ⇧↵ new line"), naming @ and / only when `mentions` or
88
+ * `commands` are given. Defaults to true; false hides them.
89
+ */
90
+ hints?: boolean | undefined;
85
91
  /** The text box, to focus it or read its selection from outside. */
86
92
  textareaRef?: React.Ref<HTMLTextAreaElement> | undefined;
87
93
  /** The send button's shape: "square" (the default, rounded corners) or "round". */
@@ -20,6 +20,7 @@ export const composerStrings = {
20
20
  hintSend: "send",
21
21
  hintNewLine: "new line",
22
22
  hintMention: "mention",
23
+ hintCommand: "command",
23
24
  stop: "Stop",
24
25
  send: "Send",
25
26
  dropFiles: "Drop files to attach",
@@ -52,7 +53,7 @@ const hasFiles = (e) => Array.from(e.dataTransfer?.types ?? []).includes("Files"
52
53
  * (`{ kind: "held", reason }`) keeps typing open and says why sending waits.
53
54
  * `textareaRef` focuses the box from outside, e.g. after a new project starts.
54
55
  */
55
- function Composer({ value, onChange, onSubmit, onStop, state = "ready", scope, onScopeClick, mentions = [], commands = [], references = [], onReferencesChange, attachments = [], onAttach, accept, onRemoveAttachment, placeholder: placeholderProp, label: labelProp, tools, textareaRef, sendShape = "square", className, }) {
56
+ function Composer({ value, onChange, onSubmit, onStop, state = "ready", scope, onScopeClick, mentions = [], commands = [], references = [], onReferencesChange, attachments = [], onAttach, accept, onRemoveAttachment, placeholder: placeholderProp, label: labelProp, tools, textareaRef, sendShape = "square", hints = true, className, }) {
56
57
  const kindOf = useEntityKinds();
57
58
  const t = useStrings("composer", composerStrings);
58
59
  const placeholder = placeholderProp ?? t("placeholder");
@@ -216,6 +217,8 @@ function Composer({ value, onChange, onSubmit, onStop, state = "ready", scope, o
216
217
  if (files.length)
217
218
  onAttach(files);
218
219
  e.target.value = "";
219
- } }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("attachFiles"), disabled: Boolean(locked), onClick: () => fileInput.current?.click(), className: "flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:opacity-40", children: _jsx(Paperclip, { className: "size-4" }) }) }), _jsx(TooltipContent, { children: t("attachFiles") })] })] })), tools, scope && (_jsxs("button", { type: "button", onClick: onScopeClick, disabled: !onScopeClick, className: cn("inline-flex min-w-0 items-center gap-1.5 rounded-lg px-2 py-1 text-xs transition-colors duration-150 enabled:hover:bg-accent", production ? "bg-warning-weak text-warning-text enabled:hover:bg-warning-weak" : "text-muted-foreground"), children: [production ? _jsx(TriangleAlert, { "aria-hidden": true, className: "size-3 shrink-0" }) : _jsx("span", { "aria-hidden": true, className: "size-1.5 shrink-0 rounded-full bg-success" }), _jsxs("span", { className: "truncate", children: [scope.environment, " \u00B7 ", production ? t("production") : (scope.detail ?? t("nonProduction"))] })] })), _jsxs("span", { className: "ms-auto hidden text-2xs text-faint-foreground @lg:inline", children: [_jsx("kbd", { className: "font-sans", children: "\u21B5" }), " ", t("hintSend"), " \u00B7 ", _jsx("kbd", { className: "font-sans", children: "\u21E7\u21B5" }), " ", t("hintNewLine"), " \u00B7 ", _jsx("kbd", { className: "font-sans", children: "@" }), " ", t("hintMention")] }), state === "streaming" && onStop ? (_jsx("button", { type: "button", "aria-label": t("stop"), onClick: onStop, className: cn("ms-auto flex size-8 items-center justify-center bg-foreground text-background transition-transform duration-150 active:scale-95 @lg:ms-1", sendShape === "round" ? "rounded-full" : "rounded-lg"), children: _jsx(Square, { className: "size-3 fill-current" }) })) : (_jsx("button", { type: "button", "aria-label": t("send"), onClick: submit, disabled: !canSend, "aria-busy": state === "sending", className: cn("ms-auto flex size-8 items-center justify-center bg-primary text-primary-foreground transition-[background-color,opacity,transform] duration-150 enabled:hover:bg-primary-hover active:scale-95 disabled:opacity-30 @lg:ms-1", sendShape === "round" ? "rounded-full" : "rounded-lg"), children: state === "sending" ? _jsx(LoaderCircle, { className: "size-4 animate-spin" }) : _jsx(ArrowUp, { className: "size-4", strokeWidth: 2.5 }) }))] })] })] }));
220
+ } }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("attachFiles"), disabled: Boolean(locked), onClick: () => fileInput.current?.click(), className: "flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:opacity-40", children: _jsx(Paperclip, { className: "size-4" }) }) }), _jsx(TooltipContent, { children: t("attachFiles") })] })] })), tools, scope && (_jsxs("button", { type: "button", onClick: onScopeClick, disabled: !onScopeClick, className: cn("inline-flex min-w-0 items-center gap-1.5 rounded-lg px-2 py-1 text-xs transition-colors duration-150 enabled:hover:bg-accent", production ? "bg-warning-weak text-warning-text enabled:hover:bg-warning-weak" : "text-muted-foreground"), children: [production ? _jsx(TriangleAlert, { "aria-hidden": true, className: "size-3 shrink-0" }) : _jsx("span", { "aria-hidden": true, className: "size-1.5 shrink-0 rounded-full bg-success" }), _jsxs("span", { className: "truncate", children: [scope.environment, " \u00B7 ", production ? t("production") : (scope.detail ?? t("nonProduction"))] })] })), hints && (
221
+ // Only what this composer can do: @ and / are named only when there's something for them to offer.
222
+ _jsxs("span", { className: "ms-auto hidden text-2xs text-faint-foreground @lg:inline", children: [_jsx("kbd", { className: "font-sans", children: "\u21B5" }), " ", t("hintSend"), " \u00B7 ", _jsx("kbd", { className: "font-sans", children: "\u21E7\u21B5" }), " ", t("hintNewLine"), mentions.length > 0 && (_jsxs(_Fragment, { children: [" · ", _jsx("kbd", { className: "font-sans", children: "@" }), " ", t("hintMention")] })), commands.length > 0 && (_jsxs(_Fragment, { children: [" · ", _jsx("kbd", { className: "font-sans", children: "/" }), " ", t("hintCommand")] }))] })), state === "streaming" && onStop ? (_jsx("button", { type: "button", "aria-label": t("stop"), onClick: onStop, className: cn("ms-auto flex size-8 items-center justify-center bg-foreground text-background transition-transform duration-150 active:scale-95", hints && "@lg:ms-1", sendShape === "round" ? "rounded-full" : "rounded-lg"), children: _jsx(Square, { className: "size-3 fill-current" }) })) : (_jsx("button", { type: "button", "aria-label": t("send"), onClick: submit, disabled: !canSend, "aria-busy": state === "sending", className: cn("ms-auto flex size-8 items-center justify-center bg-primary text-primary-foreground transition-[background-color,opacity,transform] duration-150 enabled:hover:bg-primary-hover active:scale-95 disabled:opacity-30", hints && "@lg:ms-1", sendShape === "round" ? "rounded-full" : "rounded-lg"), children: state === "sending" ? _jsx(LoaderCircle, { className: "size-4 animate-spin" }) : _jsx(ArrowUp, { className: "size-4", strokeWidth: 2.5 }) }))] })] })] }));
220
223
  }
221
224
  export { Composer };
package/dist/index.d.ts CHANGED
@@ -42,6 +42,7 @@ export * from "./lib/know.js";
42
42
  export * from "./lib/render-prop.js";
43
43
  export * from "./chat/ai-disclosure.js";
44
44
  export * from "./chat/permission-prompt.js";
45
+ export * from "./lib/mcp-input.js";
45
46
  export * from "./lib/utils.js";
46
47
  export { ToolCall } from "./chat/tool-call.js";
47
48
  export type { ToolCall as ToolCallData } from "./lib/chat.js";
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ export * from "./lib/know.js";
42
42
  export * from "./lib/render-prop.js";
43
43
  export * from "./chat/ai-disclosure.js";
44
44
  export * from "./chat/permission-prompt.js";
45
+ export * from "./lib/mcp-input.js";
45
46
  export * from "./lib/utils.js";
46
47
  export { ToolCall } from "./chat/tool-call.js";
47
48
  export { Message } from "./chat/message.js";
@@ -0,0 +1,316 @@
1
+ /**
2
+ * MCP's "input required" results (spec 2026-07-28, multi round-trip requests) as Candor data: a server's form becomes
3
+ * questions for ClarifyingQuestions, a plain confirmation or a link to open becomes a two-answer Decision for
4
+ * ApprovalDecision, and the person's answers become the `inputResponses` the client sends when it retries the call.
5
+ * Requests it can't show (sampling, roots, anything newer) stay listed as not supported, never dropped.
6
+ *
7
+ * Pure functions on plain data, so they run in the browser or on the server, with any MCP client.
8
+ *
9
+ * Checked against https://modelcontextprotocol.io/specification/2026-07-28 (basic/patterns/mrtr, client/elicitation
10
+ * and the schema): `resultType: "input_required"`, `inputRequests` as a map keyed by the server's ids, `requestState`
11
+ * echoed back unchanged, and the retry's `inputResponses` keyed the same way. Elicitation results are
12
+ * `{ action: "accept" | "decline" | "cancel", content? }`.
13
+ */
14
+ /** A form field's schema, as MCP restricts it: a string, number, boolean or enum. Unknown keys pass through. */
15
+ export type McpFieldSchema = {
16
+ /** "string", "number", "integer", "boolean" or "array" (a multi-select enum). */
17
+ type?: string | undefined;
18
+ /** A short name for the field. */
19
+ title?: string | undefined;
20
+ /** What the field is for, in a sentence. */
21
+ description?: string | undefined;
22
+ /** The server's default. Shown as the server's suggestion; Candor never fills it in for the person. */
23
+ default?: string | number | boolean | string[] | undefined;
24
+ /** A single-select enum without titles. */
25
+ enum?: string[] | undefined;
26
+ /** Display names for `enum`, in the same order (the legacy form, still sent by older servers). */
27
+ enumNames?: string[] | undefined;
28
+ /** A single-select enum with titles. */
29
+ oneOf?: {
30
+ const: string;
31
+ title?: string | undefined;
32
+ }[] | undefined;
33
+ /** A multi-select enum's items: `enum` without titles or `anyOf` with them. */
34
+ items?: {
35
+ type?: string | undefined;
36
+ enum?: string[] | undefined;
37
+ anyOf?: {
38
+ const: string;
39
+ title?: string | undefined;
40
+ }[] | undefined;
41
+ } | undefined;
42
+ /** At least this many choices, for a multi-select. */
43
+ minItems?: number | undefined;
44
+ /** At most this many choices, for a multi-select. */
45
+ maxItems?: number | undefined;
46
+ /** At least this many characters. */
47
+ minLength?: number | undefined;
48
+ /** At most this many characters. */
49
+ maxLength?: number | undefined;
50
+ /** "email", "uri", "date" or "date-time". */
51
+ format?: string | undefined;
52
+ /** The smallest number allowed. */
53
+ minimum?: number | undefined;
54
+ /** The largest number allowed. */
55
+ maximum?: number | undefined;
56
+ };
57
+ /** An `elicitation/create` request in form mode: a message and a flat schema of primitive fields. */
58
+ export type McpElicitFormRequest = {
59
+ method: "elicitation/create";
60
+ params: {
61
+ /** "form", or left out (which means form). */
62
+ mode?: "form" | undefined;
63
+ /** Why the server asks, in its own words. */
64
+ message: string;
65
+ requestedSchema: {
66
+ type: "object";
67
+ properties: Record<string, McpFieldSchema>;
68
+ required?: string[] | undefined;
69
+ };
70
+ };
71
+ };
72
+ /** An `elicitation/create` request in URL mode: a page the person opens outside the client (sign-in, payment). */
73
+ export type McpElicitUrlRequest = {
74
+ method: "elicitation/create";
75
+ params: {
76
+ mode: "url";
77
+ /** Why the server asks, in its own words. */
78
+ message: string;
79
+ /** The page to open, only once the person agrees. */
80
+ url: string;
81
+ };
82
+ };
83
+ /**
84
+ * A result that asks for more input before the call can finish. At least one of `inputRequests` and `requestState`
85
+ * is present. Values in `inputRequests` are left `unknown`: a server may send kinds this file doesn't know.
86
+ */
87
+ export type McpInputRequiredResult = {
88
+ resultType: "input_required";
89
+ /** The server's requests, keyed by its own ids. */
90
+ inputRequests?: Record<string, unknown> | undefined;
91
+ /** Opaque to the client: sent back exactly as received, never read or changed. */
92
+ requestState?: string | undefined;
93
+ _meta?: Record<string, unknown> | undefined;
94
+ };
95
+ /** The answer to one elicitation, as MCP takes it back. `content` only when accepted in form mode. */
96
+ export type McpElicitResult = {
97
+ action: "accept" | "decline" | "cancel";
98
+ content?: Record<string, string | number | boolean | string[]> | undefined;
99
+ };
100
+ /**
101
+ * A question in ClarifyingQuestions' shape (`ClarifyingQuestion`), defined here so this free file needs nothing from
102
+ * a paid one. The question's id is the form field's name.
103
+ */
104
+ export type McpQuestion = {
105
+ id: string;
106
+ kind: "choice";
107
+ prompt: string;
108
+ options: {
109
+ id: string;
110
+ label: string;
111
+ detail?: string | undefined;
112
+ }[];
113
+ multiple?: boolean | undefined;
114
+ /** The server's default, marked as a suggestion and never pre-selected. */
115
+ recommended?: string | undefined;
116
+ /** A short label shown above the question: the field's title when it also has a description. */
117
+ tag?: string | undefined;
118
+ /** Always false: an MCP enum takes only its own values. */
119
+ other?: boolean | undefined;
120
+ } | {
121
+ id: string;
122
+ kind: "text";
123
+ prompt: string;
124
+ /** A hint for the expected format, or the server's default, never filled in. */
125
+ placeholder?: string | undefined;
126
+ /** The field isn't required: left empty, it isn't sent. */
127
+ optional?: boolean | undefined;
128
+ /** A short label shown above the question. */
129
+ tag?: string | undefined;
130
+ };
131
+ /** A person's answer, in ClarifyingQuestions' shape (`ClarifyingAnswer`). */
132
+ export type McpQuestionAnswer = {
133
+ questionId: string;
134
+ choice?: string[] | undefined;
135
+ text?: string | undefined;
136
+ };
137
+ /**
138
+ * A two-answer confirmation in ApprovalDecision's shape (`Decision`), defined here so this free file needs nothing
139
+ * from a paid one. The choices' ids are "accept" and "decline", which is what the retry sends.
140
+ */
141
+ export type McpDecision = {
142
+ id: string;
143
+ question: string;
144
+ /** What to decide on, as label and value: the page's host and its full address. */
145
+ facts?: {
146
+ label: string;
147
+ value: string;
148
+ }[] | undefined;
149
+ /** Accept first, decline second. */
150
+ choices: {
151
+ id: "accept" | "decline";
152
+ label: string;
153
+ detail?: string | undefined;
154
+ }[];
155
+ state: "open";
156
+ };
157
+ /** One of the server's requests, as Candor shows it. `key` is the server's id for it. */
158
+ export type McpInputItem =
159
+ /**
160
+ * A form: answer it with ClarifyingQuestions. `fields` and `required` are the server's schema, which
161
+ * `mcpInputResponses` checks the answers against.
162
+ */
163
+ {
164
+ key: string;
165
+ kind: "questions";
166
+ message: string;
167
+ questions: McpQuestion[];
168
+ fields: Record<string, McpFieldSchema>;
169
+ required: string[];
170
+ }
171
+ /** A form with no fields: the server asks to go ahead. Answer it with ApprovalDecision. */
172
+ | {
173
+ key: string;
174
+ kind: "confirm";
175
+ message: string;
176
+ decision: McpDecision;
177
+ }
178
+ /**
179
+ * A page to open outside the client. Show the full `url` and its `host` before the person agrees, and open it only
180
+ * once they accept (`decision`). `warnings` say why the address deserves a second look.
181
+ */
182
+ | {
183
+ key: string;
184
+ kind: "url";
185
+ message: string;
186
+ url: string;
187
+ host: string;
188
+ warnings: string[];
189
+ decision: McpDecision;
190
+ }
191
+ /** A request Candor can't show: its method (when it has one) and why. Show it; don't drop it. */
192
+ | {
193
+ key: string;
194
+ kind: "unsupported";
195
+ method?: string | undefined;
196
+ reason: string;
197
+ request: unknown;
198
+ };
199
+ /** An input-required result, ready to show. */
200
+ export type McpInputView = {
201
+ /** One per request, in the server's order. */
202
+ items: McpInputItem[];
203
+ /** Sent back exactly as received; absent when the server sent none. */
204
+ requestState?: string | undefined;
205
+ /** The server's name as your app knows it (passed in), so the person can tell which server asks. */
206
+ server?: string | undefined;
207
+ /** The name the server gives itself in the result's `_meta`. Its own claim: show it as such, if at all. */
208
+ serverClaims?: string | undefined;
209
+ };
210
+ /** A person's reply to one item, by its key. */
211
+ export type McpInputReply =
212
+ /** A form, answered: the answers from ClarifyingQuestions. */
213
+ {
214
+ key: string;
215
+ action: "accept";
216
+ answers?: McpQuestionAnswer[] | undefined;
217
+ }
218
+ /** Declined outright, or dismissed without a choice. */
219
+ | {
220
+ key: string;
221
+ action: "decline" | "cancel";
222
+ };
223
+ /** Why an answer can't be sent yet. */
224
+ export type McpInputProblem = {
225
+ /** The item's key. */
226
+ key: string;
227
+ /** The field, for a form. */
228
+ questionId?: string | undefined;
229
+ /** Which rule it broke. */
230
+ code: "unanswered" | "required" | "notAnOption" | "notANumber" | "notAnInteger" | "tooSmall" | "tooLarge" | "tooShort" | "tooLong" | "format" | "tooFew" | "tooMany";
231
+ /** The same in words, from `mcpInputWords`. */
232
+ message: string;
233
+ };
234
+ /** What a retry sends, or why it can't be sent yet. */
235
+ export type McpInputResponses = {
236
+ ok: true;
237
+ /** The retry's `inputResponses`, keyed by the server's ids. */
238
+ inputResponses: Record<string, unknown>;
239
+ /** Keys of requests nothing was sent for (not supported and not answered by your app). The server may ask again. */
240
+ notSent: string[];
241
+ } | {
242
+ ok: false;
243
+ problems: McpInputProblem[];
244
+ };
245
+ /** The words this file writes into questions, decisions and problems. Pass your own to `mcpInputView` or `mcpInputResponses`. */
246
+ export declare const mcpInputWords: {
247
+ yes: string;
248
+ no: string;
249
+ /** The extra option on a choice the server doesn't require. */
250
+ skip: string;
251
+ defaultHint: string;
252
+ formatEmail: string;
253
+ formatUri: string;
254
+ formatDate: string;
255
+ formatDateTime: string;
256
+ confirm: string;
257
+ openPage: string;
258
+ decline: string;
259
+ host: string;
260
+ address: string;
261
+ punycode: string;
262
+ notHttps: string;
263
+ badUrl: string;
264
+ unsupportedSampling: string;
265
+ unsupportedRoots: string;
266
+ unsupportedMethod: string;
267
+ unsupportedShape: string;
268
+ unsupportedField: string;
269
+ unanswered: string;
270
+ required: string;
271
+ notAnOption: string;
272
+ notANumber: string;
273
+ notAnInteger: string;
274
+ tooSmall: string;
275
+ tooLarge: string;
276
+ tooShort: string;
277
+ tooLong: string;
278
+ format: string;
279
+ tooFew: string;
280
+ tooMany: string;
281
+ };
282
+ /** The words, with any you pass in place of the defaults. */
283
+ export type McpInputWords = Partial<typeof mcpInputWords>;
284
+ /**
285
+ * Whether a tools/call (or prompts/get, resources/read) result asks for more input. A result without `resultType`
286
+ * comes from an older server and counts as complete, as the spec says.
287
+ */
288
+ export declare function isMcpInputRequired(result: unknown): result is McpInputRequiredResult;
289
+ /**
290
+ * An input-required result as items to show: forms as questions, confirmations and links as decisions, and anything
291
+ * else as a visible "not supported". Undefined when the result doesn't ask for input. `server` is your app's own name
292
+ * for the server, which the person should see beside every request.
293
+ */
294
+ export declare function mcpInputView(result: unknown, options?: {
295
+ server?: string | undefined;
296
+ words?: McpInputWords | undefined;
297
+ }): McpInputView | undefined;
298
+ /**
299
+ * The retry's `inputResponses` for the person's replies, checked against each form's schema first (the spec asks
300
+ * clients to validate). Every form, confirmation and link needs a reply; a form is sent only once every required
301
+ * field has a valid answer. `own` holds responses your app made itself, for requests Candor doesn't show (a sampling
302
+ * or roots answer), keyed by the server's ids; anything left is listed in `notSent`.
303
+ */
304
+ export declare function mcpInputResponses(view: McpInputView, replies: McpInputReply[], options?: {
305
+ own?: Record<string, unknown> | undefined;
306
+ words?: McpInputWords | undefined;
307
+ }): McpInputResponses;
308
+ /**
309
+ * The params for retrying the original request: its own params (name and arguments for tools/call) with
310
+ * `inputResponses` and the result's `requestState` exactly as received. A `requestState` from an earlier round is
311
+ * removed when this result has none, as the spec requires. Send it as a new request with a new JSON-RPC id.
312
+ */
313
+ export declare function mcpRetryParams<P extends Record<string, unknown>>(original: P, view: Pick<McpInputView, "requestState">, inputResponses: Record<string, unknown>): Omit<P, "inputResponses" | "requestState"> & {
314
+ inputResponses: Record<string, unknown>;
315
+ requestState?: string | undefined;
316
+ };
@@ -0,0 +1,369 @@
1
+ /**
2
+ * MCP's "input required" results (spec 2026-07-28, multi round-trip requests) as Candor data: a server's form becomes
3
+ * questions for ClarifyingQuestions, a plain confirmation or a link to open becomes a two-answer Decision for
4
+ * ApprovalDecision, and the person's answers become the `inputResponses` the client sends when it retries the call.
5
+ * Requests it can't show (sampling, roots, anything newer) stay listed as not supported, never dropped.
6
+ *
7
+ * Pure functions on plain data, so they run in the browser or on the server, with any MCP client.
8
+ *
9
+ * Checked against https://modelcontextprotocol.io/specification/2026-07-28 (basic/patterns/mrtr, client/elicitation
10
+ * and the schema): `resultType: "input_required"`, `inputRequests` as a map keyed by the server's ids, `requestState`
11
+ * echoed back unchanged, and the retry's `inputResponses` keyed the same way. Elicitation results are
12
+ * `{ action: "accept" | "decline" | "cancel", content? }`.
13
+ */
14
+ /** The words this file writes into questions, decisions and problems. Pass your own to `mcpInputView` or `mcpInputResponses`. */
15
+ export const mcpInputWords = {
16
+ yes: "Yes",
17
+ no: "No",
18
+ /** The extra option on a choice the server doesn't require. */
19
+ skip: "Leave unanswered",
20
+ defaultHint: "Server’s default: {value}",
21
+ formatEmail: "name@example.com",
22
+ formatUri: "https://…",
23
+ formatDate: "YYYY-MM-DD",
24
+ formatDateTime: "YYYY-MM-DDTHH:MM",
25
+ confirm: "Go ahead",
26
+ openPage: "Open the page",
27
+ decline: "Decline",
28
+ host: "Opens",
29
+ address: "Full address",
30
+ punycode: "The address uses an encoded name (xn--), which can imitate another site.",
31
+ notHttps: "The page isn’t on https.",
32
+ badUrl: "The server sent an address that isn’t a valid URL, so it can’t be opened.",
33
+ unsupportedSampling: "The server asks the client to run a model prompt (sampling), which this app doesn’t do here.",
34
+ unsupportedRoots: "The server asks for the client’s folders (roots), which this app doesn’t answer here.",
35
+ unsupportedMethod: "The server asks for something this app can’t show yet: {method}.",
36
+ unsupportedShape: "The server’s request isn’t in a shape this app recognises.",
37
+ unsupportedField: "The form has a field this app can’t show: {field}.",
38
+ unanswered: "Not answered yet.",
39
+ required: "An answer is needed.",
40
+ notAnOption: "That isn’t one of the choices.",
41
+ notANumber: "That isn’t a number.",
42
+ notAnInteger: "That needs to be a whole number.",
43
+ tooSmall: "That needs to be at least {min}.",
44
+ tooLarge: "That needs to be at most {max}.",
45
+ tooShort: "That needs at least {min} characters.",
46
+ tooLong: "That allows at most {max} characters.",
47
+ format: "That doesn’t look like {format}.",
48
+ tooFew: "Choose at least {min}.",
49
+ tooMany: "Choose at most {max}.",
50
+ };
51
+ const fill = (s, vars) => s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
52
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
53
+ /** The id of "Leave unanswered" on a choice the server doesn't require; no enum value or "true"/"false" can clash with it. */
54
+ const SKIP = "\u0000skip";
55
+ /* ---------------------------------------------------------------- result → view */
56
+ /**
57
+ * Whether a tools/call (or prompts/get, resources/read) result asks for more input. A result without `resultType`
58
+ * comes from an older server and counts as complete, as the spec says.
59
+ */
60
+ export function isMcpInputRequired(result) {
61
+ return isObject(result) && result.resultType === "input_required";
62
+ }
63
+ /**
64
+ * An input-required result as items to show: forms as questions, confirmations and links as decisions, and anything
65
+ * else as a visible "not supported". Undefined when the result doesn't ask for input. `server` is your app's own name
66
+ * for the server, which the person should see beside every request.
67
+ */
68
+ export function mcpInputView(result, options = {}) {
69
+ if (!isMcpInputRequired(result))
70
+ return undefined;
71
+ const w = { ...mcpInputWords, ...options.words };
72
+ const requests = isObject(result.inputRequests) ? result.inputRequests : {};
73
+ const items = Object.entries(requests).map(([key, request]) => itemOf(key, request, w));
74
+ const info = isObject(result._meta) ? result._meta["io.modelcontextprotocol/serverInfo"] : undefined;
75
+ const claims = isObject(info) ? (typeof info.title === "string" && info.title) || (typeof info.name === "string" && info.name) || undefined : undefined;
76
+ return {
77
+ items,
78
+ ...(typeof result.requestState === "string" && { requestState: result.requestState }),
79
+ ...(options.server && { server: options.server }),
80
+ ...(claims && { serverClaims: claims }),
81
+ };
82
+ }
83
+ function itemOf(key, request, w) {
84
+ const unsupported = (reason, method) => ({ key, kind: "unsupported", ...(method && { method }), reason, request });
85
+ if (!isObject(request) || typeof request.method !== "string")
86
+ return unsupported(w.unsupportedShape);
87
+ const method = request.method;
88
+ if (method === "sampling/createMessage")
89
+ return unsupported(w.unsupportedSampling, method);
90
+ if (method === "roots/list")
91
+ return unsupported(w.unsupportedRoots, method);
92
+ if (method !== "elicitation/create")
93
+ return unsupported(fill(w.unsupportedMethod, { method }), method);
94
+ const params = request.params;
95
+ if (!isObject(params) || typeof params.message !== "string")
96
+ return unsupported(w.unsupportedShape, method);
97
+ const message = params.message;
98
+ const mode = params.mode ?? "form";
99
+ if (mode === "url") {
100
+ if (typeof params.url !== "string")
101
+ return unsupported(w.unsupportedShape, method);
102
+ let parsed;
103
+ try {
104
+ parsed = new URL(params.url);
105
+ }
106
+ catch {
107
+ return unsupported(w.badUrl, method);
108
+ }
109
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
110
+ return unsupported(w.badUrl, method);
111
+ const warnings = [];
112
+ if (parsed.hostname.split(".").some((part) => part.startsWith("xn--")))
113
+ warnings.push(w.punycode);
114
+ if (parsed.protocol !== "https:")
115
+ warnings.push(w.notHttps);
116
+ return {
117
+ key,
118
+ kind: "url",
119
+ message,
120
+ url: params.url,
121
+ host: parsed.host,
122
+ warnings,
123
+ decision: {
124
+ id: key,
125
+ question: message,
126
+ facts: [
127
+ { label: w.host, value: parsed.host },
128
+ { label: w.address, value: params.url },
129
+ ],
130
+ choices: [
131
+ { id: "accept", label: w.openPage },
132
+ { id: "decline", label: w.decline },
133
+ ],
134
+ state: "open",
135
+ },
136
+ };
137
+ }
138
+ if (mode !== "form")
139
+ return unsupported(fill(w.unsupportedMethod, { method: `${method} (${String(mode)})` }), method);
140
+ const schema = params.requestedSchema;
141
+ if (!isObject(schema) || (schema.properties !== undefined && !isObject(schema.properties)))
142
+ return unsupported(w.unsupportedShape, method);
143
+ const properties = (schema.properties ?? {});
144
+ const required = new Set(Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : []);
145
+ const names = Object.keys(properties);
146
+ if (!names.length) {
147
+ return {
148
+ key,
149
+ kind: "confirm",
150
+ message,
151
+ decision: {
152
+ id: key,
153
+ question: message,
154
+ choices: [
155
+ { id: "accept", label: w.confirm },
156
+ { id: "decline", label: w.decline },
157
+ ],
158
+ state: "open",
159
+ },
160
+ };
161
+ }
162
+ const questions = [];
163
+ for (const name of names) {
164
+ const q = questionOf(name, properties[name], required.has(name), w);
165
+ // One field it can't show makes the whole form unsupported: answering only part of it would send a half-truth.
166
+ if (!q)
167
+ return unsupported(fill(w.unsupportedField, { field: name }), method);
168
+ questions.push(q);
169
+ }
170
+ return { key, kind: "questions", message, questions, fields: properties, required: [...required] };
171
+ }
172
+ /** A field's options, when it is an enum (single or multiple); undefined otherwise. */
173
+ function optionsOf(s) {
174
+ const titled = (list) => Array.isArray(list) && list.every((o) => isObject(o) && typeof o.const === "string")
175
+ ? list.map((o) => ({ id: o.const, label: typeof o.title === "string" && o.title ? o.title : o.const }))
176
+ : undefined;
177
+ const plain = (list, names) => Array.isArray(list) && list.every((v) => typeof v === "string")
178
+ ? list.map((v, i) => ({ id: v, label: Array.isArray(names) && typeof names[i] === "string" && names[i] ? names[i] : v }))
179
+ : undefined;
180
+ if (s.type === "array") {
181
+ const items = isObject(s.items) ? s.items : undefined;
182
+ const options = items ? (items.anyOf !== undefined ? titled(items.anyOf) : plain(items.enum)) : undefined;
183
+ return options ? { options, multiple: true } : undefined;
184
+ }
185
+ if (s.type === "string" || s.type === undefined) {
186
+ if (s.oneOf !== undefined) {
187
+ const options = titled(s.oneOf);
188
+ return options ? { options, multiple: false } : undefined;
189
+ }
190
+ if (s.enum !== undefined) {
191
+ const options = plain(s.enum, s.enumNames);
192
+ return options ? { options, multiple: false } : undefined;
193
+ }
194
+ }
195
+ return undefined;
196
+ }
197
+ const formatHint = (format, w) => format === "email" ? w.formatEmail : format === "uri" ? w.formatUri : format === "date" ? w.formatDate : format === "date-time" ? w.formatDateTime : undefined;
198
+ function questionOf(name, raw, required, w) {
199
+ if (!isObject(raw))
200
+ return undefined;
201
+ const s = raw;
202
+ const title = typeof s.title === "string" && s.title ? s.title : undefined;
203
+ const description = typeof s.description === "string" && s.description ? s.description : undefined;
204
+ const prompt = description ?? title ?? name;
205
+ const tag = description && title ? title : undefined;
206
+ const head = { id: name, prompt, ...(tag && { tag }) };
207
+ const enumOf = optionsOf(s);
208
+ if (enumOf) {
209
+ const options = !required && !enumOf.multiple ? [...enumOf.options, { id: SKIP, label: w.skip }] : enumOf.options;
210
+ const recommended = typeof s.default === "string" && enumOf.options.some((o) => o.id === s.default) ? s.default : undefined;
211
+ return { ...head, kind: "choice", options, multiple: enumOf.multiple, other: false, ...(recommended && { recommended }) };
212
+ }
213
+ if (s.type === "boolean") {
214
+ const options = [
215
+ { id: "true", label: w.yes },
216
+ { id: "false", label: w.no },
217
+ ...(required ? [] : [{ id: SKIP, label: w.skip }]),
218
+ ];
219
+ const recommended = typeof s.default === "boolean" ? String(s.default) : undefined;
220
+ return { ...head, kind: "choice", options, multiple: false, other: false, ...(recommended && { recommended }) };
221
+ }
222
+ if (s.type === "string" || s.type === "number" || s.type === "integer") {
223
+ const hint = s.default !== undefined && s.default !== null && typeof s.default !== "object" ? fill(w.defaultHint, { value: String(s.default) }) : formatHint(s.format, w);
224
+ return { ...head, kind: "text", ...(hint && { placeholder: hint }), ...(!required && { optional: true }) };
225
+ }
226
+ return undefined;
227
+ }
228
+ /* ---------------------------------------------------------------- answers → retry */
229
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
230
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
231
+ const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/;
232
+ function formatOk(format, text) {
233
+ if (format === "email")
234
+ return EMAIL.test(text);
235
+ if (format === "date")
236
+ return DATE.test(text) && !Number.isNaN(Date.parse(text));
237
+ if (format === "date-time")
238
+ return DATE_TIME.test(text) && !Number.isNaN(Date.parse(text));
239
+ if (format === "uri") {
240
+ try {
241
+ return Boolean(new URL(text));
242
+ }
243
+ catch {
244
+ return false;
245
+ }
246
+ }
247
+ return true;
248
+ }
249
+ /**
250
+ * The retry's `inputResponses` for the person's replies, checked against each form's schema first (the spec asks
251
+ * clients to validate). Every form, confirmation and link needs a reply; a form is sent only once every required
252
+ * field has a valid answer. `own` holds responses your app made itself, for requests Candor doesn't show (a sampling
253
+ * or roots answer), keyed by the server's ids; anything left is listed in `notSent`.
254
+ */
255
+ export function mcpInputResponses(view, replies, options = {}) {
256
+ const w = { ...mcpInputWords, ...options.words };
257
+ const problems = [];
258
+ // Keys are the server's ids, so the maps have no prototype: "__proto__" or "toString" is just another key.
259
+ const inputResponses = Object.create(null);
260
+ const notSent = [];
261
+ const problem = (key, code, vars = {}, questionId) => problems.push({ key, ...(questionId !== undefined && { questionId }), code, message: fill(w[code], vars) });
262
+ for (const item of view.items) {
263
+ if (item.kind === "unsupported") {
264
+ if (options.own && Object.hasOwn(options.own, item.key))
265
+ inputResponses[item.key] = options.own[item.key];
266
+ else
267
+ notSent.push(item.key);
268
+ continue;
269
+ }
270
+ const reply = replies.find((r) => r.key === item.key);
271
+ if (!reply) {
272
+ problem(item.key, "unanswered");
273
+ continue;
274
+ }
275
+ if (reply.action !== "accept") {
276
+ inputResponses[item.key] = { action: reply.action };
277
+ continue;
278
+ }
279
+ if (item.kind !== "questions") {
280
+ // A confirmation or a link: accepting sends no content. For a link, open it now; the server finds out on retry.
281
+ inputResponses[item.key] = { action: "accept" };
282
+ continue;
283
+ }
284
+ const content = Object.create(null);
285
+ const before = problems.length;
286
+ for (const q of item.questions) {
287
+ const s = item.fields[q.id] ?? {};
288
+ const required = item.required.includes(q.id);
289
+ const a = reply.answers?.find((x) => x.questionId === q.id);
290
+ if (q.kind === "choice") {
291
+ const picked = (a?.choice ?? []).filter((c) => c !== SKIP);
292
+ if (picked.some((c) => !q.options.some((o) => o.id === c))) {
293
+ problem(item.key, "notAnOption", {}, q.id);
294
+ continue;
295
+ }
296
+ if (q.multiple) {
297
+ if (!picked.length && (!required || s.minItems === 0)) {
298
+ if (required)
299
+ content[q.id] = [];
300
+ continue;
301
+ }
302
+ if (!picked.length && s.minItems === undefined)
303
+ problem(item.key, "required", {}, q.id);
304
+ else if (typeof s.minItems === "number" && picked.length < s.minItems)
305
+ problem(item.key, "tooFew", { min: s.minItems }, q.id);
306
+ else if (typeof s.maxItems === "number" && picked.length > s.maxItems)
307
+ problem(item.key, "tooMany", { max: s.maxItems }, q.id);
308
+ else
309
+ content[q.id] = picked;
310
+ continue;
311
+ }
312
+ const value = picked[0];
313
+ if (value === undefined) {
314
+ if (required)
315
+ problem(item.key, "required", {}, q.id);
316
+ continue;
317
+ }
318
+ content[q.id] = s.type === "boolean" ? value === "true" : value;
319
+ continue;
320
+ }
321
+ const text = a?.text?.trim() ?? "";
322
+ if (!text) {
323
+ if (!q.optional)
324
+ problem(item.key, "required", {}, q.id);
325
+ continue;
326
+ }
327
+ if (s.type === "number" || s.type === "integer") {
328
+ const n = Number(text);
329
+ if (!Number.isFinite(n))
330
+ problem(item.key, "notANumber", {}, q.id);
331
+ else if (s.type === "integer" && !Number.isInteger(n))
332
+ problem(item.key, "notAnInteger", {}, q.id);
333
+ else if (typeof s.minimum === "number" && n < s.minimum)
334
+ problem(item.key, "tooSmall", { min: s.minimum }, q.id);
335
+ else if (typeof s.maximum === "number" && n > s.maximum)
336
+ problem(item.key, "tooLarge", { max: s.maximum }, q.id);
337
+ else
338
+ content[q.id] = n;
339
+ continue;
340
+ }
341
+ if (typeof s.minLength === "number" && text.length < s.minLength)
342
+ problem(item.key, "tooShort", { min: s.minLength }, q.id);
343
+ else if (typeof s.maxLength === "number" && text.length > s.maxLength)
344
+ problem(item.key, "tooLong", { max: s.maxLength }, q.id);
345
+ else if (!formatOk(s.format, text))
346
+ problem(item.key, "format", { format: formatHint(s.format, w) ?? String(s.format) }, q.id);
347
+ else
348
+ content[q.id] = text;
349
+ }
350
+ if (problems.length === before)
351
+ inputResponses[item.key] = { action: "accept", content };
352
+ }
353
+ return problems.length ? { ok: false, problems } : { ok: true, inputResponses, notSent };
354
+ }
355
+ /**
356
+ * The params for retrying the original request: its own params (name and arguments for tools/call) with
357
+ * `inputResponses` and the result's `requestState` exactly as received. A `requestState` from an earlier round is
358
+ * removed when this result has none, as the spec requires. Send it as a new request with a new JSON-RPC id.
359
+ */
360
+ export function mcpRetryParams(original, view, inputResponses) {
361
+ const rest = { ...original };
362
+ delete rest.inputResponses;
363
+ delete rest.requestState;
364
+ return {
365
+ ...rest,
366
+ inputResponses,
367
+ ...(view.requestState !== undefined && { requestState: view.requestState }),
368
+ };
369
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@candor-ui/react",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Candor UI's free tier as an npm package: React components for agents that do real work, on shadcn/ui and Tailwind 4.",
5
5
  "license": "MIT",
6
6
  "author": "Methodic LLC",