@assistant-ui/react-mcp 0.0.21 → 0.1.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 (94) hide show
  1. package/dist/auth/createOAuthProvider.d.ts +1 -1
  2. package/dist/auth/createOAuthProvider.js.map +1 -1
  3. package/dist/auth/types.d.ts +1 -1
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.js +2 -1
  6. package/dist/mcp-scope.d.ts +32 -1
  7. package/dist/mcp-scope.d.ts.map +1 -1
  8. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts +13 -0
  9. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts.map +1 -0
  10. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js +106 -0
  11. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js.map +1 -0
  12. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts +13 -0
  13. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts.map +1 -0
  14. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js +43 -0
  15. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js.map +1 -0
  16. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts +13 -0
  17. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts.map +1 -0
  18. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js +43 -0
  19. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js.map +1 -0
  20. package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts +13 -0
  21. package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts.map +1 -0
  22. package/dist/primitives/elicitation/McpElicitationPrimitiveError.js +38 -0
  23. package/dist/primitives/elicitation/McpElicitationPrimitiveError.js.map +1 -0
  24. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts +12 -0
  25. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts.map +1 -0
  26. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js +53 -0
  27. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js.map +1 -0
  28. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts +13 -0
  29. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts.map +1 -0
  30. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js +96 -0
  31. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js.map +1 -0
  32. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts +13 -0
  33. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts.map +1 -0
  34. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js +29 -0
  35. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js.map +1 -0
  36. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts +13 -0
  37. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts.map +1 -0
  38. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js +28 -0
  39. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js.map +1 -0
  40. package/dist/primitives/elicitation/context.d.ts +21 -0
  41. package/dist/primitives/elicitation/context.d.ts.map +1 -0
  42. package/dist/primitives/elicitation/context.js +21 -0
  43. package/dist/primitives/elicitation/context.js.map +1 -0
  44. package/dist/primitives/elicitation/initialElicitationDraft.d.ts +5 -0
  45. package/dist/primitives/elicitation/initialElicitationDraft.d.ts.map +1 -0
  46. package/dist/primitives/elicitation/initialElicitationDraft.js +27 -0
  47. package/dist/primitives/elicitation/initialElicitationDraft.js.map +1 -0
  48. package/dist/primitives/elicitation/prepareElicitationContent.d.ts +9 -0
  49. package/dist/primitives/elicitation/prepareElicitationContent.d.ts.map +1 -0
  50. package/dist/primitives/elicitation/prepareElicitationContent.js +46 -0
  51. package/dist/primitives/elicitation/prepareElicitationContent.js.map +1 -0
  52. package/dist/primitives/elicitation.d.ts +15 -0
  53. package/dist/primitives/elicitation.d.ts.map +1 -0
  54. package/dist/primitives/elicitation.js +27 -0
  55. package/dist/primitives/elicitation.js.map +1 -0
  56. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  57. package/dist/resources/McpManagerResource.js +17 -3
  58. package/dist/resources/McpManagerResource.js.map +1 -1
  59. package/dist/resources/McpServerResource.d.ts +4 -0
  60. package/dist/resources/McpServerResource.d.ts.map +1 -1
  61. package/dist/resources/McpServerResource.js +153 -42
  62. package/dist/resources/McpServerResource.js.map +1 -1
  63. package/dist/resources/storage/McpLocalStorage.js +1 -1
  64. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  65. package/dist/resources/validateElicitationContent.d.ts +9 -0
  66. package/dist/resources/validateElicitationContent.d.ts.map +1 -0
  67. package/dist/resources/validateElicitationContent.js +47 -0
  68. package/dist/resources/validateElicitationContent.js.map +1 -0
  69. package/package.json +7 -6
  70. package/src/auth/createOAuthProvider.ts +2 -2
  71. package/src/auth/types.ts +1 -1
  72. package/src/index.ts +4 -0
  73. package/src/mcp-scope.ts +32 -0
  74. package/src/primitives/elicitation/McpElicitationPrimitiveAccept.tsx +48 -0
  75. package/src/primitives/elicitation/McpElicitationPrimitiveCancel.tsx +37 -0
  76. package/src/primitives/elicitation/McpElicitationPrimitiveDecline.tsx +37 -0
  77. package/src/primitives/elicitation/McpElicitationPrimitiveError.tsx +31 -0
  78. package/src/primitives/elicitation/McpElicitationPrimitiveFields.tsx +56 -0
  79. package/src/primitives/elicitation/McpElicitationPrimitiveItems.tsx +58 -0
  80. package/src/primitives/elicitation/McpElicitationPrimitiveMessage.tsx +26 -0
  81. package/src/primitives/elicitation/McpElicitationPrimitiveRoot.tsx +24 -0
  82. package/src/primitives/elicitation/context.tsx +45 -0
  83. package/src/primitives/elicitation/initialElicitationDraft.test.ts +34 -0
  84. package/src/primitives/elicitation/initialElicitationDraft.ts +39 -0
  85. package/src/primitives/elicitation/prepareElicitationContent.test.ts +415 -0
  86. package/src/primitives/elicitation/prepareElicitationContent.ts +116 -0
  87. package/src/primitives/elicitation.ts +14 -0
  88. package/src/resources/McpManagerResource.test.ts +173 -3
  89. package/src/resources/McpManagerResource.ts +37 -3
  90. package/src/resources/McpServerResource.test.ts +735 -4
  91. package/src/resources/McpServerResource.ts +237 -29
  92. package/src/resources/storage/McpLocalStorage.ts +1 -1
  93. package/src/resources/validateElicitationContent.test.ts +123 -0
  94. package/src/resources/validateElicitationContent.ts +79 -0
@@ -1,20 +1,25 @@
1
1
  import { useState, useRef, useEffect, useMemo, useEffectEvent } from "react";
2
2
  import { resource } from "@assistant-ui/tap";
3
3
  import type { ClientOutput } from "@assistant-ui/store";
4
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
4
  import {
5
+ Client,
6
6
  StreamableHTTPClientTransport,
7
+ UnauthorizedError,
8
+ type ClientOptions,
9
+ type ElicitRequest,
10
+ type ElicitResult,
7
11
  type StreamableHTTPClientTransportOptions,
8
- } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
9
- import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
10
- import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
12
+ } from "@modelcontextprotocol/client";
11
13
  import { createOAuthProvider } from "../auth/createOAuthProvider";
12
14
  import { buildHeaders } from "../auth/buildHeaders";
13
15
  import { assertValidServerId } from "../utils/serverId";
16
+ import { validateElicitationContent } from "./validateElicitationContent";
14
17
  import type { MCPStorage } from "./storage/types";
15
18
  import type {
16
19
  MCPAuthConfig,
17
20
  MCPConnectionState,
21
+ MCPElicitation,
22
+ MCPElicitationResponse,
18
23
  MCPServerKind,
19
24
  MCPServerState,
20
25
  MCPToolInfo,
@@ -31,6 +36,8 @@ export type McpServerResourceProps = {
31
36
  redirectUri: string;
32
37
  autoConnect: boolean;
33
38
  connectionTimeout?: number | undefined;
39
+ cache?: { readonly defaultTtlMs?: number } | undefined;
40
+ readonly elicitation?: boolean;
34
41
  onRemove: () => Promise<void>;
35
42
  };
36
43
 
@@ -43,6 +50,9 @@ const useMcpServerResource = (
43
50
  const [tools, setTools] = useState<MCPToolInfo[]>([]);
44
51
  const [lastError, setLastError] = useState<{ message: string } | null>(null);
45
52
  const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);
53
+ const [pendingElicitations, setPendingElicitations] = useState<
54
+ MCPElicitation[]
55
+ >([]);
46
56
 
47
57
  const clientRef = useRef<Client | null>(null);
48
58
  const transportRef = useRef<StreamableHTTPClientTransport | null>(null);
@@ -51,6 +61,17 @@ const useMcpServerResource = (
51
61
  );
52
62
  const transportCloseQueueRef = useRef(Promise.resolve());
53
63
  const connectionGenerationRef = useRef(0);
64
+ const elicitationResolversRef = useRef(
65
+ new Map<
66
+ string,
67
+ {
68
+ resolve: (result: ElicitResult) => void;
69
+ signal: AbortSignal;
70
+ onAbort: () => void;
71
+ requestedSchema: unknown;
72
+ }
73
+ >(),
74
+ );
54
75
  const pendingDisposalRef = useRef<{ cancelled: boolean } | null>(null);
55
76
  const mountedRef = useRef(true);
56
77
 
@@ -80,7 +101,39 @@ const useMcpServerResource = (
80
101
  await closeQueuedTransports(transport ? [transport] : []);
81
102
  };
82
103
 
104
+ const resolvePendingElicitation = (id: string, result: ElicitResult) => {
105
+ const entry = elicitationResolversRef.current.get(id);
106
+ if (!entry) return false;
107
+ elicitationResolversRef.current.delete(id);
108
+ entry.signal.removeEventListener("abort", entry.onAbort);
109
+ setPendingElicitations((current) =>
110
+ current.filter((elicitation) => elicitation.id !== id),
111
+ );
112
+ entry.resolve(result);
113
+ return true;
114
+ };
115
+
116
+ const setPendingElicitationError = (
117
+ id: string,
118
+ error: NonNullable<MCPElicitation["error"]>,
119
+ ) => {
120
+ if (!elicitationResolversRef.current.has(id)) return false;
121
+ setPendingElicitations((current) =>
122
+ current.map((elicitation) =>
123
+ elicitation.id === id ? { ...elicitation, error } : elicitation,
124
+ ),
125
+ );
126
+ return true;
127
+ };
128
+
129
+ const cancelPendingElicitations = () => {
130
+ for (const [id] of elicitationResolversRef.current) {
131
+ resolvePendingElicitation(id, { action: "cancel" });
132
+ }
133
+ };
134
+
83
135
  const closeTransports = async () => {
136
+ cancelPendingElicitations();
84
137
  const pendingTransport = pendingTransportRef.current;
85
138
  const activeTransport = transportRef.current;
86
139
  pendingTransportRef.current = null;
@@ -162,22 +215,136 @@ const useMcpServerResource = (
162
215
  },
163
216
  );
164
217
 
218
+ const applyToolsList = useEffectEvent(
219
+ (list: {
220
+ tools: Array<{
221
+ name: string;
222
+ description?: string | undefined;
223
+ inputSchema: unknown;
224
+ }>;
225
+ }) => {
226
+ setTools(
227
+ list.tools.map((t) => {
228
+ const info: MCPToolInfo = {
229
+ name: t.name,
230
+ inputSchema: t.inputSchema,
231
+ };
232
+ if (t.description !== undefined) info.description = t.description;
233
+ return info;
234
+ }),
235
+ );
236
+ },
237
+ );
238
+
239
+ const syncTools = useEffectEvent(
240
+ async (
241
+ client: Client,
242
+ generation: number,
243
+ options?: { startedAt?: number | undefined },
244
+ ): Promise<boolean> => {
245
+ const listPromise = client.listTools();
246
+ const list =
247
+ options?.startedAt === undefined
248
+ ? await listPromise
249
+ : await withConnectionTimeout(
250
+ listPromise,
251
+ "listing tools",
252
+ options.startedAt,
253
+ );
254
+ if (!isCurrentConnection(generation)) return false;
255
+ applyToolsList(list);
256
+ return true;
257
+ },
258
+ );
259
+
165
260
  const finalizeConnect = useEffectEvent(
166
261
  async (
167
262
  transport: StreamableHTTPClientTransport,
168
263
  generation: number,
169
264
  ): Promise<boolean> => {
170
- const client = new Client({
171
- name: "assistant-ui-mcp",
172
- version: "0.0.0",
173
- });
265
+ const clientOptions: ClientOptions = {
266
+ ...(props.elicitation === false
267
+ ? {}
268
+ : { capabilities: { elicitation: {} } }),
269
+ listChanged: {
270
+ tools: {
271
+ autoRefresh: true,
272
+ debounceMs: 300,
273
+ onChanged: (error, items) => {
274
+ if (!isCurrentConnection(generation)) return;
275
+ if (error !== null) {
276
+ setLastError({ message: error.message });
277
+ return;
278
+ }
279
+ if (items === null) return;
280
+ setLastError(null);
281
+ applyToolsList({ tools: items });
282
+ },
283
+ },
284
+ },
285
+ };
286
+ if (props.cache?.defaultTtlMs !== undefined) {
287
+ clientOptions.defaultCacheTtlMs = props.cache.defaultTtlMs;
288
+ }
289
+ const client = new Client(
290
+ {
291
+ name: "assistant-ui-mcp",
292
+ version: "0.0.0",
293
+ },
294
+ clientOptions,
295
+ );
296
+ if (props.elicitation !== false) {
297
+ client.setRequestHandler(
298
+ "elicitation/create",
299
+ (request: ElicitRequest, context): Promise<ElicitResult> => {
300
+ if (!isCurrentConnection(generation)) {
301
+ return Promise.resolve({ action: "cancel" });
302
+ }
303
+ if (!("requestedSchema" in request.params)) {
304
+ return Promise.resolve({ action: "cancel" });
305
+ }
306
+ const { message, requestedSchema } = request.params;
307
+
308
+ const id =
309
+ typeof crypto !== "undefined" && "randomUUID" in crypto
310
+ ? crypto.randomUUID()
311
+ : `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
312
+ const promise = new Promise<ElicitResult>((resolve) => {
313
+ const onAbort = () => {
314
+ resolvePendingElicitation(id, { action: "cancel" });
315
+ };
316
+ elicitationResolversRef.current.set(id, {
317
+ resolve,
318
+ signal: context.signal,
319
+ onAbort,
320
+ requestedSchema,
321
+ });
322
+ });
323
+ setPendingElicitations((current) => [
324
+ ...current,
325
+ {
326
+ id,
327
+ message,
328
+ requestedSchema,
329
+ },
330
+ ]);
331
+ const entry = elicitationResolversRef.current.get(id);
332
+ if (entry) {
333
+ if (context.signal.aborted) {
334
+ entry.onAbort();
335
+ } else {
336
+ context.signal.addEventListener("abort", entry.onAbort, {
337
+ once: true,
338
+ });
339
+ }
340
+ }
341
+ return promise;
342
+ },
343
+ );
344
+ }
174
345
  const startedAt = Date.now();
175
- // SDK's StreamableHTTPClientTransport.sessionId is `string | undefined`
176
- // but Transport.sessionId is declared `string?` — under
177
- // exactOptionalPropertyTypes the SDK's own classes don't satisfy its
178
- // Transport interface. Cast to bridge the gap.
179
346
  await withConnectionTimeout(
180
- client.connect(transport as unknown as Transport),
347
+ client.connect(transport),
181
348
  "connecting",
182
349
  startedAt,
183
350
  );
@@ -186,26 +353,12 @@ const useMcpServerResource = (
186
353
  // post-connect failure leaves stale refs that `callTool()` would
187
354
  // happily walk into, producing confusing SDK errors instead of
188
355
  // "not connected".
189
- const list = await withConnectionTimeout(
190
- client.listTools(),
191
- "listing tools",
192
- startedAt,
193
- );
194
- if (!isCurrentConnection(generation)) return false;
356
+ const synced = await syncTools(client, generation, { startedAt });
357
+ if (!synced) return false;
195
358
 
196
359
  pendingTransportRef.current = null;
197
360
  clientRef.current = client;
198
361
  transportRef.current = transport;
199
- setTools(
200
- list.tools.map((t) => {
201
- const info: MCPToolInfo = {
202
- name: t.name,
203
- inputSchema: t.inputSchema,
204
- };
205
- if (t.description !== undefined) info.description = t.description;
206
- return info;
207
- }),
208
- );
209
362
  setConnectionState("connected");
210
363
  return true;
211
364
  },
@@ -246,6 +399,7 @@ const useMcpServerResource = (
246
399
  transportRef.current = transport;
247
400
  setConnectionState("authRequired");
248
401
  } else {
402
+ cancelPendingElicitations();
249
403
  if (transport) {
250
404
  pendingTransportRef.current = null;
251
405
  await closeQueuedTransports([transport]);
@@ -268,6 +422,7 @@ const useMcpServerResource = (
268
422
 
269
423
  const doCompleteAuth = useEffectEvent(async (callbackUrl: string) => {
270
424
  const generation = ++connectionGenerationRef.current;
425
+ cancelPendingElicitations();
271
426
  await closePendingTransport();
272
427
  if (!isCurrentConnection(generation)) throw createInterruptedAuthError();
273
428
 
@@ -362,6 +517,7 @@ const useMcpServerResource = (
362
517
  lastError,
363
518
  tools,
364
519
  authorizationUrl,
520
+ pendingElicitations,
365
521
  }),
366
522
  [
367
523
  props.id,
@@ -373,6 +529,7 @@ const useMcpServerResource = (
373
529
  lastError,
374
530
  tools,
375
531
  authorizationUrl,
532
+ pendingElicitations,
376
533
  ],
377
534
  );
378
535
 
@@ -410,6 +567,57 @@ const useMcpServerResource = (
410
567
  return await client.readResource({ uri });
411
568
  },
412
569
  completeAuth: doCompleteAuth,
570
+ answerElicitation: (
571
+ id: string,
572
+ response: MCPElicitationResponse,
573
+ ): readonly { property: string; message: string }[] | undefined => {
574
+ if (response.action === "accept") {
575
+ const entry = elicitationResolversRef.current.get(id);
576
+ if (!entry) return;
577
+
578
+ if (
579
+ typeof response.content !== "object" ||
580
+ response.content === null ||
581
+ Array.isArray(response.content)
582
+ ) {
583
+ const errors = [
584
+ {
585
+ property: "content",
586
+ message: "Response content must be an object.",
587
+ },
588
+ ];
589
+ setPendingElicitationError(id, {
590
+ message: "Invalid elicitation content: content.",
591
+ properties: ["content"],
592
+ });
593
+ return errors;
594
+ }
595
+
596
+ const errors = validateElicitationContent(
597
+ entry.requestedSchema,
598
+ response.content,
599
+ );
600
+ if (errors.length > 0) {
601
+ const properties = [
602
+ ...new Set(errors.map((error) => error.property)),
603
+ ];
604
+ setPendingElicitationError(id, {
605
+ message: `Invalid elicitation content: ${properties.join(", ")}.`,
606
+ properties,
607
+ });
608
+ return errors;
609
+ }
610
+
611
+ const result: ElicitResult = {
612
+ action: "accept",
613
+ content: response.content as ElicitResult["content"],
614
+ };
615
+ resolvePendingElicitation(id, result);
616
+ return;
617
+ }
618
+
619
+ resolvePendingElicitation(id, { action: response.action });
620
+ },
413
621
  };
414
622
  };
415
623
 
@@ -2,7 +2,7 @@ import { resource } from "@assistant-ui/tap";
2
2
  import {
3
3
  OAuthClientInformationFullSchema,
4
4
  OAuthTokensSchema,
5
- } from "@modelcontextprotocol/sdk/shared/auth.js";
5
+ } from "@modelcontextprotocol/core";
6
6
  import type { MCPAuthConfig, MCPCustomServerRecord } from "../../mcp-scope";
7
7
  import type { MCPPersistedAuthState } from "../../auth/types";
8
8
  import { assertValidServerId } from "../../utils/serverId";
@@ -0,0 +1,123 @@
1
+ // @vitest-environment node
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { validateElicitationContent } from "./validateElicitationContent";
5
+
6
+ describe("validateElicitationContent", () => {
7
+ it.each([
8
+ ["string", 1, "Expected a string."],
9
+ ["number", "1", "Expected a number."],
10
+ ["integer", "1", "Expected an integer."],
11
+ ["boolean", "true", "Expected a boolean."],
12
+ ])("rejects a %s type mismatch", (type, value, message) => {
13
+ expect(
14
+ validateElicitationContent(
15
+ {
16
+ type: "object",
17
+ properties: { value: { type } },
18
+ },
19
+ { value },
20
+ ),
21
+ ).toEqual([{ property: "value", message }]);
22
+ });
23
+
24
+ it("rejects a fractional integer", () => {
25
+ expect(
26
+ validateElicitationContent(
27
+ {
28
+ type: "object",
29
+ properties: { count: { type: "integer" } },
30
+ },
31
+ { count: 1.5 },
32
+ ),
33
+ ).toEqual([{ property: "count", message: "Expected an integer." }]);
34
+ });
35
+
36
+ it.each([
37
+ ["number", NaN, "Expected a number."],
38
+ ["number", Infinity, "Expected a number."],
39
+ ["number", -Infinity, "Expected a number."],
40
+ ["integer", NaN, "Expected an integer."],
41
+ ["integer", Infinity, "Expected an integer."],
42
+ ["integer", -Infinity, "Expected an integer."],
43
+ ])("rejects a non-finite %s", (type, value, message) => {
44
+ expect(
45
+ validateElicitationContent(
46
+ {
47
+ type: "object",
48
+ properties: { value: { type } },
49
+ },
50
+ { value },
51
+ ),
52
+ ).toEqual([{ property: "value", message }]);
53
+ });
54
+
55
+ it("rejects a value outside an enum", () => {
56
+ expect(
57
+ validateElicitationContent(
58
+ {
59
+ type: "object",
60
+ properties: { color: { enum: ["red", "blue"] } },
61
+ },
62
+ { color: "green" },
63
+ ),
64
+ ).toEqual([
65
+ {
66
+ property: "color",
67
+ message: "Must be one of the allowed values.",
68
+ },
69
+ ]);
70
+ });
71
+
72
+ it("rejects an absent required property", () => {
73
+ expect(
74
+ validateElicitationContent(
75
+ {
76
+ type: "object",
77
+ required: ["answer"],
78
+ properties: { answer: { type: "string" } },
79
+ },
80
+ {},
81
+ ),
82
+ ).toEqual([{ property: "answer", message: "This property is required." }]);
83
+ });
84
+
85
+ it("accepts an empty string as a present required property", () => {
86
+ expect(
87
+ validateElicitationContent(
88
+ {
89
+ type: "object",
90
+ required: ["answer"],
91
+ properties: { answer: { type: "string" } },
92
+ },
93
+ { answer: "" },
94
+ ),
95
+ ).toEqual([]);
96
+ });
97
+
98
+ it("allows unknown content properties", () => {
99
+ expect(
100
+ validateElicitationContent(
101
+ {
102
+ type: "object",
103
+ properties: { answer: { type: "string" } },
104
+ },
105
+ { extra: true },
106
+ ),
107
+ ).toEqual([]);
108
+ });
109
+
110
+ it("leaves constraints beyond the flat validation subset for the server", () => {
111
+ expect(
112
+ validateElicitationContent(
113
+ {
114
+ type: "object",
115
+ properties: {
116
+ answer: { type: "string", minLength: 5, format: "email" },
117
+ },
118
+ },
119
+ { answer: "x" },
120
+ ),
121
+ ).toEqual([]);
122
+ });
123
+ });
@@ -0,0 +1,79 @@
1
+ type ValidationError = {
2
+ property: string;
3
+ message: string;
4
+ };
5
+
6
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
7
+ typeof value === "object" && value !== null && !Array.isArray(value);
8
+
9
+ const getOwn = (value: Record<string, unknown>, key: string): unknown =>
10
+ Object.hasOwn(value, key) ? value[key] : undefined;
11
+
12
+ const matchesType = (type: unknown, value: unknown): boolean => {
13
+ switch (type) {
14
+ case "string":
15
+ return typeof value === "string";
16
+ case "number":
17
+ return typeof value === "number" && Number.isFinite(value);
18
+ case "integer":
19
+ return (
20
+ typeof value === "number" &&
21
+ Number.isFinite(value) &&
22
+ Number.isInteger(value)
23
+ );
24
+ case "boolean":
25
+ return typeof value === "boolean";
26
+ default:
27
+ return true;
28
+ }
29
+ };
30
+
31
+ const typeMessage = (type: string) =>
32
+ `Expected ${type === "integer" ? "an" : "a"} ${type}.`;
33
+
34
+ export const validateElicitationContent = (
35
+ requestedSchema: unknown,
36
+ content: Record<string, unknown>,
37
+ ): readonly ValidationError[] => {
38
+ if (!isRecord(requestedSchema)) return [];
39
+
40
+ const errors: ValidationError[] = [];
41
+ const properties = getOwn(requestedSchema, "properties");
42
+ if (isRecord(properties)) {
43
+ for (const property of Object.keys(properties)) {
44
+ if (!Object.hasOwn(content, property)) continue;
45
+
46
+ const schema = properties[property];
47
+ if (!isRecord(schema)) continue;
48
+
49
+ const value = content[property];
50
+ const type = getOwn(schema, "type");
51
+ if (typeof type === "string" && !matchesType(type, value)) {
52
+ errors.push({ property, message: typeMessage(type) });
53
+ }
54
+
55
+ const values = getOwn(schema, "enum");
56
+ if (
57
+ Array.isArray(values) &&
58
+ !values.some((enumValue) => Object.is(enumValue, value))
59
+ ) {
60
+ errors.push({
61
+ property,
62
+ message: "Must be one of the allowed values.",
63
+ });
64
+ }
65
+ }
66
+ }
67
+
68
+ const required = getOwn(requestedSchema, "required");
69
+ if (Array.isArray(required)) {
70
+ for (const property of required) {
71
+ if (typeof property !== "string" || Object.hasOwn(content, property)) {
72
+ continue;
73
+ }
74
+ errors.push({ property, message: "This property is required." });
75
+ }
76
+ }
77
+
78
+ return errors;
79
+ };