Merge branch 'main' into request-propagation-in-tools

This commit is contained in:
Konstantin Konstantinov 2025-06-24 17:21:09 +03:00 • committed by GitHub
commit 96ff8573cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2180 additions and 134 deletions

View file

@ -276,6 +276,34 @@ describe('Authorization Handler', () => {
});
});
describe('Resource parameter validation', () => {
it('propagates resource parameter', async () => {
const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize');
const response = await supertest(app)
.get('/authorize')
.query({
client_id: 'valid-client',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256',
resource: 'https://api.example.com/resource'
});
expect(response.status).toBe(302);
expect(mockProviderWithResource).toHaveBeenCalledWith(
validClient,
expect.objectContaining({
resource: new URL('https://api.example.com/resource'),
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge123'
}),
expect.any(Object)
);
});
});
describe('Successful authorization', () => {
it('handles successful authorization with all parameters', async () => {
const response = await supertest(app)

View file

@ -35,6 +35,7 @@ const RequestAuthorizationParamsSchema = z.object({
code_challenge_method: z.literal("S256"),
scope: z.string().optional(),
state: z.string().optional(),
resource: z.string().url().optional(),
});
export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
@ -98,7 +99,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
console.error("Unexpected error looking up client:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}
@ -115,7 +115,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
throw new InvalidRequestError(parseResult.error.message);
}
const { scope, code_challenge } = parseResult.data;
const { scope, code_challenge, resource } = parseResult.data;
state = parseResult.data.state;
// Validate scopes
@ -138,13 +138,13 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
scopes: requestedScopes,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
resource: resource ? new URL(resource) : undefined,
}, res);
} catch (error) {
// Post-redirect errors - redirect with error parameters
if (error instanceof OAuthError) {
res.redirect(302, createErrorRedirect(redirect_uri, error, state));
} else {
console.error("Unexpected error during authorization:", error);
const serverError = new ServerError("Internal Server Error");
res.redirect(302, createErrorRedirect(redirect_uri, serverError, state));
}

View file

@ -104,7 +104,6 @@ export function clientRegistrationHandler({
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
console.error("Unexpected error registering client:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}

View file

@ -9,7 +9,7 @@ import {
InvalidRequestError,
ServerError,
TooManyRequestsError,
OAuthError
OAuthError,
} from "../errors.js";
export type RevocationHandlerOptions = {
@ -21,7 +21,10 @@ export type RevocationHandlerOptions = {
rateLimit?: Partial<RateLimitOptions> | false;
};
export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler {
export function revocationHandler({
provider,
rateLimit: rateLimitConfig,
}: RevocationHandlerOptions): RequestHandler {
if (!provider.revokeToken) {
throw new Error("Auth provider does not support revoking tokens");
}
@ -37,21 +40,25 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
// Apply rate limiting unless explicitly disabled
if (rateLimitConfig !== false) {
router.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50, // 50 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(),
...rateLimitConfig
}));
router.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50, // 50 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
message: new TooManyRequestsError(
"You have exceeded the rate limit for token revocation requests"
).toResponseObject(),
...rateLimitConfig,
})
);
}
// Authenticate and extract client details
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
router.post("/", async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.setHeader("Cache-Control", "no-store");
try {
const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body);
@ -62,7 +69,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
const client = req.client;
if (!client) {
// This should never happen
console.error("Missing client information after authentication");
throw new ServerError("Internal Server Error");
}
@ -73,7 +79,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
console.error("Unexpected error revoking token:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}

View file

@ -264,12 +264,14 @@ describe('Token Handler', () => {
});
it('returns tokens for valid code exchange', async () => {
const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode');
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
resource: 'https://api.example.com/resource',
grant_type: 'authorization_code',
code: 'valid_code',
code_verifier: 'valid_verifier'
@ -280,6 +282,13 @@ describe('Token Handler', () => {
expect(response.body.token_type).toBe('bearer');
expect(response.body.expires_in).toBe(3600);
expect(response.body.refresh_token).toBe('mock_refresh_token');
expect(mockExchangeCode).toHaveBeenCalledWith(
validClient,
'valid_code',
undefined, // code_verifier is undefined after PKCE validation
undefined, // redirect_uri
new URL('https://api.example.com/resource') // resource parameter
);
});
it('passes through code verifier when using proxy provider', async () => {
@ -440,12 +449,14 @@ describe('Token Handler', () => {
});
it('returns new tokens for valid refresh token', async () => {
const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken');
const response = await supertest(app)
.post('/token')
.type('form')
.send({
client_id: 'valid-client',
client_secret: 'valid-secret',
resource: 'https://api.example.com/resource',
grant_type: 'refresh_token',
refresh_token: 'valid_refresh_token'
});
@ -455,6 +466,12 @@ describe('Token Handler', () => {
expect(response.body.token_type).toBe('bearer');
expect(response.body.expires_in).toBe(3600);
expect(response.body.refresh_token).toBe('new_mock_refresh_token');
expect(mockExchangeRefresh).toHaveBeenCalledWith(
validClient,
'valid_refresh_token',
undefined, // scopes
new URL('https://api.example.com/resource') // resource parameter
);
});
it('respects requested scopes on refresh', async () => {

View file

@ -32,11 +32,13 @@ const AuthorizationCodeGrantSchema = z.object({
code: z.string(),
code_verifier: z.string(),
redirect_uri: z.string().optional(),
resource: z.string().url().optional(),
});
const RefreshTokenGrantSchema = z.object({
refresh_token: z.string(),
scope: z.string().optional(),
resource: z.string().url().optional(),
});
export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler {
@ -78,7 +80,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
const client = req.client;
if (!client) {
// This should never happen
console.error("Missing client information after authentication");
throw new ServerError("Internal Server Error");
}
@ -89,7 +90,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
throw new InvalidRequestError(parseResult.error.message);
}
const { code, code_verifier, redirect_uri } = parseResult.data;
const { code, code_verifier, redirect_uri, resource } = parseResult.data;
const skipLocalPkceValidation = provider.skipLocalPkceValidation;
@ -107,7 +108,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
client,
code,
skipLocalPkceValidation ? code_verifier : undefined,
redirect_uri
redirect_uri,
resource ? new URL(resource) : undefined
);
res.status(200).json(tokens);
break;
@ -119,10 +121,10 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
throw new InvalidRequestError(parseResult.error.message);
}
const { refresh_token, scope } = parseResult.data;
const { refresh_token, scope, resource } = parseResult.data;
const scopes = scope?.split(" ");
const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes);
const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource ? new URL(resource) : undefined);
res.status(200).json(tokens);
break;
}
@ -140,7 +142,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
console.error("Unexpected error exchanging token:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}

View file

@ -88,7 +88,6 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad
} else if (error instanceof OAuthError) {
res.status(400).json(error.toResponseObject());
} else {
console.error("Unexpected error authenticating bearer token:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}

View file

@ -64,7 +64,6 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
console.error("Unexpected error authenticating client:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}

View file

@ -8,6 +8,7 @@ export type AuthorizationParams = {
scopes?: string[];
codeChallenge: string;
redirectUri: string;
resource?: URL;
};
/**
@ -40,13 +41,14 @@ export interface OAuthServerProvider {
client: OAuthClientInformationFull,
authorizationCode: string,
codeVerifier?: string,
redirectUri?: string
redirectUri?: string,
resource?: URL
): Promise<OAuthTokens>;
/**
* Exchanges a refresh token for an access token.
*/
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens>;
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise<OAuthTokens>;
/**
* Verifies an access token and returns information about it.

View file

@ -88,6 +88,7 @@ describe("Proxy OAuth Server Provider", () => {
codeChallenge: "test-challenge",
state: "test-state",
scopes: ["read", "write"],
resource: new URL('https://api.example.com/resource'),
},
mockResponse
);
@ -100,6 +101,7 @@ describe("Proxy OAuth Server Provider", () => {
expectedUrl.searchParams.set("code_challenge_method", "S256");
expectedUrl.searchParams.set("state", "test-state");
expectedUrl.searchParams.set("scope", "read write");
expectedUrl.searchParams.set('resource', 'https://api.example.com/resource');
expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString());
});
@ -164,6 +166,41 @@ describe("Proxy OAuth Server Provider", () => {
expect(tokens).toEqual(mockTokenResponse);
});
it('includes resource parameter in authorization code exchange', async () => {
const tokens = await provider.exchangeAuthorizationCode(
validClient,
'test-code',
'test-verifier',
'https://example.com/callback',
new URL('https://api.example.com/resource')
);
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
})
);
expect(tokens).toEqual(mockTokenResponse);
});
it('handles authorization code exchange without resource parameter', async () => {
const tokens = await provider.exchangeAuthorizationCode(
validClient,
'test-code',
'test-verifier'
);
const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
const body = fetchCall[1].body as string;
expect(body).not.toContain('resource=');
expect(tokens).toEqual(mockTokenResponse);
});
it("exchanges refresh token for new tokens", async () => {
const tokens = await provider.exchangeRefreshToken(
validClient,
@ -184,6 +221,26 @@ describe("Proxy OAuth Server Provider", () => {
expect(tokens).toEqual(mockTokenResponse);
});
it('includes resource parameter in refresh token exchange', async () => {
const tokens = await provider.exchangeRefreshToken(
validClient,
'test-refresh-token',
['read', 'write'],
new URL('https://api.example.com/resource')
);
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
})
);
expect(tokens).toEqual(mockTokenResponse);
});
});
describe("client registration", () => {

View file

@ -134,6 +134,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
// Add optional standard OAuth parameters
if (params.state) searchParams.set("state", params.state);
if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" "));
if (params.resource) searchParams.set("resource", params.resource.href);
targetUrl.search = searchParams.toString();
res.redirect(targetUrl.toString());
@ -152,7 +153,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
client: OAuthClientInformationFull,
authorizationCode: string,
codeVerifier?: string,
redirectUri?: string
redirectUri?: string,
resource?: URL
): Promise<OAuthTokens> {
const params = new URLSearchParams({
grant_type: "authorization_code",
@ -172,6 +174,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
params.append("redirect_uri", redirectUri);
}
if (resource) {
params.append("resource", resource.href);
}
const response = await fetch(this._endpoints.tokenUrl, {
method: "POST",
headers: {
@ -192,7 +198,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
async exchangeRefreshToken(
client: OAuthClientInformationFull,
refreshToken: string,
scopes?: string[]
scopes?: string[],
resource?: URL
): Promise<OAuthTokens> {
const params = new URLSearchParams({
@ -209,6 +216,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
params.set("scope", scopes.join(" "));
}
if (resource) {
params.set("resource", resource.href);
}
const response = await fetch(this._endpoints.tokenUrl, {
method: "POST",
headers: {

View file

@ -22,6 +22,12 @@ export interface AuthInfo {
*/
expiresAt?: number;
/**
* The RFC 8707 resource server identifier for which this token is valid.
* If set, this MUST match the MCP server's resource identifier (minus hash fragment).
*/
resource?: URL;
/**
* Additional data associated with the token.
* This field should be used for any additional data that needs to be attached to the auth info.

View file

@ -10,6 +10,7 @@ import {
LATEST_PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS,
CreateMessageRequestSchema,
ElicitRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
@ -275,6 +276,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 reject 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: "reject" };
} 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 reject - should not validate
await expect(
server.elicitInput({
message: "Please provide your name",
requestedSchema: schema,
}),
).resolves.toEqual({
action: "reject",
});
// 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(
@ -296,6 +310,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();
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,

View file

@ -14,6 +14,7 @@ import {
LoggingMessageNotificationSchema,
Notification,
TextContent,
ElicitRequestSchema,
} from "../types.js";
import { ResourceTemplate } from "./mcp.js";
import { completable } from "./completable.js";
@ -4017,3 +4018,222 @@ describe("Tool title precedence", () => {
expect(result4.completion.values).toEqual(["Guest"]);
});
});
describe("elicitInput()", () => {
const checkAvailability = jest.fn().mockResolvedValue(false);
const findAlternatives = jest.fn().mockResolvedValue([]);
const makeBooking = jest.fn().mockResolvedValue("BOOKING-123");
let mcpServer: McpServer;
let client: Client;
beforeEach(() => {
jest.clearAllMocks();
// Create server with restaurant booking tool
mcpServer = new McpServer({
name: "restaurant-booking-server",
version: "1.0.0",
});
// Register the restaurant booking tool from README example
mcpServer.tool(
"book-restaurant",
{
restaurant: z.string(),
date: z.string(),
partySize: z.number()
},
async ({ restaurant, date, partySize }) => {
// Check availability
const available = await checkAvailability(restaurant, date, partySize);
if (!available) {
// Ask user if they want to try alternative dates
const result = await mcpServer.server.elicitInput({
message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`,
requestedSchema: {
type: "object",
properties: {
checkAlternatives: {
type: "boolean",
title: "Check alternative dates",
description: "Would you like me to check other dates?"
},
flexibleDates: {
type: "string",
title: "Date flexibility",
description: "How flexible are your dates?",
enum: ["next_day", "same_week", "next_week"],
enumNames: ["Next day", "Same week", "Next week"]
}
},
required: ["checkAlternatives"]
}
});
if (result.action === "accept" && result.content?.checkAlternatives) {
const alternatives = await findAlternatives(
restaurant,
date,
partySize,
result.content.flexibleDates as string
);
return {
content: [{
type: "text",
text: `Found these alternatives: ${alternatives.join(", ")}`
}]
};
}
return {
content: [{
type: "text",
text: "No booking made. Original date not available."
}]
};
}
await makeBooking(restaurant, date, partySize);
return {
content: [{
type: "text",
text: `Booked table for ${partySize} at ${restaurant} on ${date}`
}]
};
}
);
// Create client with elicitation capability
client = new Client(
{
name: "test-client",
version: "1.0.0",
},
{
capabilities: {
elicitation: {},
},
}
);
});
test("should successfully elicit additional information", async () => {
// Mock availability check to return false
checkAvailability.mockResolvedValue(false);
findAlternatives.mockResolvedValue(["2024-12-26", "2024-12-27", "2024-12-28"]);
// Set up client to accept alternative date checking
client.setRequestHandler(ElicitRequestSchema, async (request) => {
expect(request.params.message).toContain("No tables available at ABC Restaurant on 2024-12-25");
return {
action: "accept",
content: {
checkAlternatives: true,
flexibleDates: "same_week"
}
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// Call the tool
const result = await client.callTool({
name: "book-restaurant",
arguments: {
restaurant: "ABC Restaurant",
date: "2024-12-25",
partySize: 2
}
});
expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2);
expect(findAlternatives).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2, "same_week");
expect(result.content).toEqual([{
type: "text",
text: "Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28"
}]);
});
test("should handle user declining to elicitation request", async () => {
// Mock availability check to return false
checkAvailability.mockResolvedValue(false);
// Set up client to reject alternative date checking
client.setRequestHandler(ElicitRequestSchema, async () => {
return {
action: "accept",
content: {
checkAlternatives: false
}
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// Call the tool
const result = await client.callTool({
name: "book-restaurant",
arguments: {
restaurant: "ABC Restaurant",
date: "2024-12-25",
partySize: 2
}
});
expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2);
expect(findAlternatives).not.toHaveBeenCalled();
expect(result.content).toEqual([{
type: "text",
text: "No booking made. Original date not available."
}]);
});
test("should handle user cancelling the elicitation", async () => {
// Mock availability check to return false
checkAvailability.mockResolvedValue(false);
// Set up client to cancel the elicitation
client.setRequestHandler(ElicitRequestSchema, async () => {
return {
action: "cancel"
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// Call the tool
const result = await client.callTool({
name: "book-restaurant",
arguments: {
restaurant: "ABC Restaurant",
date: "2024-12-25",
partySize: 2
}
});
expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2);
expect(findAlternatives).not.toHaveBeenCalled();
expect(result.content).toEqual([{
type: "text",
text: "No booking made. Original date not available."
}]);
});
});