@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.
- package/dist/auth/createOAuthProvider.d.ts +1 -1
- package/dist/auth/createOAuthProvider.js.map +1 -1
- package/dist/auth/types.d.ts +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/mcp-scope.d.ts +32 -1
- package/dist/mcp-scope.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js +106 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js +43 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js +43 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.js +38 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts +12 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js +53 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js +96 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js +29 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js +28 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js.map +1 -0
- package/dist/primitives/elicitation/context.d.ts +21 -0
- package/dist/primitives/elicitation/context.d.ts.map +1 -0
- package/dist/primitives/elicitation/context.js +21 -0
- package/dist/primitives/elicitation/context.js.map +1 -0
- package/dist/primitives/elicitation/initialElicitationDraft.d.ts +5 -0
- package/dist/primitives/elicitation/initialElicitationDraft.d.ts.map +1 -0
- package/dist/primitives/elicitation/initialElicitationDraft.js +27 -0
- package/dist/primitives/elicitation/initialElicitationDraft.js.map +1 -0
- package/dist/primitives/elicitation/prepareElicitationContent.d.ts +9 -0
- package/dist/primitives/elicitation/prepareElicitationContent.d.ts.map +1 -0
- package/dist/primitives/elicitation/prepareElicitationContent.js +46 -0
- package/dist/primitives/elicitation/prepareElicitationContent.js.map +1 -0
- package/dist/primitives/elicitation.d.ts +15 -0
- package/dist/primitives/elicitation.d.ts.map +1 -0
- package/dist/primitives/elicitation.js +27 -0
- package/dist/primitives/elicitation.js.map +1 -0
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js +17 -3
- package/dist/resources/McpManagerResource.js.map +1 -1
- package/dist/resources/McpServerResource.d.ts +4 -0
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +153 -42
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +1 -1
- package/dist/resources/storage/McpLocalStorage.js.map +1 -1
- package/dist/resources/validateElicitationContent.d.ts +9 -0
- package/dist/resources/validateElicitationContent.d.ts.map +1 -0
- package/dist/resources/validateElicitationContent.js +47 -0
- package/dist/resources/validateElicitationContent.js.map +1 -0
- package/package.json +7 -6
- package/src/auth/createOAuthProvider.ts +2 -2
- package/src/auth/types.ts +1 -1
- package/src/index.ts +4 -0
- package/src/mcp-scope.ts +32 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveAccept.tsx +48 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveCancel.tsx +37 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveDecline.tsx +37 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveError.tsx +31 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveFields.tsx +56 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveItems.tsx +58 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveMessage.tsx +26 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveRoot.tsx +24 -0
- package/src/primitives/elicitation/context.tsx +45 -0
- package/src/primitives/elicitation/initialElicitationDraft.test.ts +34 -0
- package/src/primitives/elicitation/initialElicitationDraft.ts +39 -0
- package/src/primitives/elicitation/prepareElicitationContent.test.ts +415 -0
- package/src/primitives/elicitation/prepareElicitationContent.ts +116 -0
- package/src/primitives/elicitation.ts +14 -0
- package/src/resources/McpManagerResource.test.ts +173 -3
- package/src/resources/McpManagerResource.ts +37 -3
- package/src/resources/McpServerResource.test.ts +735 -4
- package/src/resources/McpServerResource.ts +237 -29
- package/src/resources/storage/McpLocalStorage.ts +1 -1
- package/src/resources/validateElicitationContent.test.ts +123 -0
- 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/
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
|
190
|
-
|
|
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/
|
|
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
|
+
};
|