elicitation example

This commit is contained in:
ihrpr 2025-05-20 15:10:40 +01:00 • committed by Basil Hosmer
commit 04cbbfbb0d
No known key found for this signature in database
2 changed files with 891 additions and 2 deletions

View file

@ -10,6 +10,7 @@ import {
LATEST_PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS,
CreateMessageRequestSchema,
ElicitRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
@ -267,6 +268,318 @@ test("should respect client capabilities", async () => {
await expect(server.listRoots()).rejects.toThrow(/^Client does not support/);
});
test("should respect client elicitation capabilities", async () => {
const server = new Server(
{
name: "test server",
version: "1.0",
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {},
},
enforceStrictCapabilities: true,
},
);
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
elicitation: {},
},
},
);
client.setRequestHandler(ElicitRequestSchema, (params) => ({
action: "accept",
content: {
username: params.params.message.includes("username") ? "test-user" : undefined,
confirmed: true,
},
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
expect(server.getClientCapabilities()).toEqual({ elicitation: {} });
// This should work because elicitation is supported by the client
await expect(
server.elicitInput({
message: "Please provide your username",
requestedSchema: {
type: "object",
properties: {
username: {
type: "string",
title: "Username",
description: "Your username",
},
confirmed: {
type: "boolean",
title: "Confirm",
description: "Please confirm",
default: false,
},
},
required: ["username"],
},
}),
).resolves.toEqual({
action: "accept",
content: {
username: "test-user",
confirmed: true,
},
});
// This should still throw because sampling is not supported by the client
await expect(
server.createMessage({
messages: [],
maxTokens: 10,
}),
).rejects.toThrow(/^Client does not support/);
});
test("should validate elicitation response against requested schema", async () => {
const server = new Server(
{
name: "test server",
version: "1.0",
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {},
},
enforceStrictCapabilities: true,
},
);
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
elicitation: {},
},
},
);
// Set up client to return valid response
client.setRequestHandler(ElicitRequestSchema, (request) => ({
action: "accept",
content: {
name: "John Doe",
email: "john@example.com",
age: 30,
},
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// Test with valid response
await expect(
server.elicitInput({
message: "Please provide your information",
requestedSchema: {
type: "object",
properties: {
name: {
type: "string",
minLength: 1,
},
email: {
type: "string",
minLength: 1,
},
age: {
type: "integer",
minimum: 0,
maximum: 150,
},
},
required: ["name", "email"],
},
}),
).resolves.toEqual({
action: "accept",
content: {
name: "John Doe",
email: "john@example.com",
age: 30,
},
});
});
test("should reject elicitation response with invalid data", async () => {
const server = new Server(
{
name: "test server",
version: "1.0",
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {},
},
enforceStrictCapabilities: true,
},
);
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
elicitation: {},
},
},
);
// Set up client to return invalid response (missing required field, invalid age)
client.setRequestHandler(ElicitRequestSchema, (request) => ({
action: "accept",
content: {
email: "", // Invalid - too short
age: -5, // Invalid age
},
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
// Test with invalid response
await expect(
server.elicitInput({
message: "Please provide your information",
requestedSchema: {
type: "object",
properties: {
name: {
type: "string",
minLength: 1,
},
email: {
type: "string",
minLength: 1,
},
age: {
type: "integer",
minimum: 0,
maximum: 150,
},
},
required: ["name", "email"],
},
}),
).rejects.toThrow(/does not match requested schema/);
});
test("should allow elicitation decline and cancel without validation", async () => {
const server = new Server(
{
name: "test server",
version: "1.0",
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {},
},
enforceStrictCapabilities: true,
},
);
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
elicitation: {},
},
},
);
let requestCount = 0;
client.setRequestHandler(ElicitRequestSchema, (request) => {
requestCount++;
if (requestCount === 1) {
return { action: "decline" };
} else {
return { action: "cancel" };
}
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const schema = {
type: "object" as const,
properties: {
name: { type: "string" as const },
},
required: ["name"],
};
// Test decline - should not validate
await expect(
server.elicitInput({
message: "Please provide your name",
requestedSchema: schema,
}),
).resolves.toEqual({
action: "decline",
});
// Test cancel - should not validate
await expect(
server.elicitInput({
message: "Please provide your name",
requestedSchema: schema,
}),
).resolves.toEqual({
action: "cancel",
});
});
test("should respect server notification capabilities", async () => {
const server = new Server(
{

View file

@ -8,6 +8,9 @@ import {
ClientCapabilities,
CreateMessageRequest,
CreateMessageResultSchema,
ElicitRequest,
ElicitResult,
ElicitResultSchema,
EmptyResultSchema,
Implementation,
InitializedNotificationSchema,
@ -18,6 +21,8 @@ import {
ListRootsRequest,
ListRootsResultSchema,
LoggingMessageNotification,
McpError,
ErrorCode,
Notification,
Request,
ResourceUpdatedNotification,
@ -28,6 +33,7 @@ import {
ServerResult,
SUPPORTED_PROTOCOL_VERSIONS,
} from "../types.js";
import { Ajv } from "ajv";
export type ServerOptions = ProtocolOptions & {
/**
@ -129,6 +135,14 @@ export class Server<
}
break;
case "elicitation/create":
if (!this._clientCapabilities?.elicitation) {
throw new Error(
`Client does not support elicitation (required for ${method})`,
);
}
break;
case "roots/list":
if (!this._clientCapabilities?.roots) {
throw new Error(
@ -294,6 +308,44 @@ export class Server<
);
}
async elicitInput(
params: ElicitRequest["params"],
options?: RequestOptions,
): Promise<ElicitResult> {
const result = await this.request(
{ method: "elicitation/create", params },
ElicitResultSchema,
options,
);
// Validate the response content against the requested schema if action is "accept"
if (result.action === "accept" && result.content) {
try {
const ajv = new Ajv({ strict: false, validateFormats: true });
const validate = ajv.compile(params.requestedSchema);
const isValid = validate(result.content);
if (!isValid) {
throw new McpError(
ErrorCode.InvalidParams,
`Elicitation response content does not match requested schema: ${ajv.errorsText(validate.errors)}`,
);
}
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Error validating elicitation response: ${error}`,
);
}
}
return result;
}
async listRoots(
params?: ListRootsRequest["params"],
options?: RequestOptions,