url elicitation

This commit is contained in:
evalstate
2026-02-14 18:48:51 +00:00
parent 618cf4867b
commit 224b6ee29c
7 changed files with 389 additions and 3 deletions

View File

@@ -54,7 +54,7 @@ describe('Registration Index Files', () => {
server: {
getClientCapabilities: vi.fn(() => ({
roots: {},
elicitation: {},
elicitation: { url: {} },
sampling: {},
})),
},
@@ -67,14 +67,16 @@ describe('Registration Index Files', () => {
registerConditionalTools(mockServerWithCapabilities);
// Should register 3 conditional tools + 3 task-based tools when all capabilities present
expect(mockServerWithCapabilities.registerTool).toHaveBeenCalledTimes(3);
// Should register 5 conditional tools + 3 task-based tools when all capabilities present
expect(mockServerWithCapabilities.registerTool).toHaveBeenCalledTimes(5);
const registeredTools = (
mockServerWithCapabilities.registerTool as any
).mock.calls.map((call: any[]) => call[0]);
expect(registeredTools).toContain('get-roots-list');
expect(registeredTools).toContain('trigger-elicitation-request');
expect(registeredTools).toContain('trigger-url-elicitation-request');
expect(registeredTools).toContain('trigger-url-elicitation-required-error');
expect(registeredTools).toContain('trigger-sampling-request');
// Task-based tools are registered via experimental.tasks.registerToolTask

View File

@@ -13,6 +13,8 @@ import { registerToggleSimulatedLoggingTool } from '../tools/toggle-simulated-lo
import { registerToggleSubscriberUpdatesTool } from '../tools/toggle-subscriber-updates.js';
import { registerTriggerSamplingRequestTool } from '../tools/trigger-sampling-request.js';
import { registerTriggerElicitationRequestTool } from '../tools/trigger-elicitation-request.js';
import { registerTriggerUrlElicitationRequestTool } from '../tools/trigger-url-elicitation-request.js';
import { registerTriggerUrlElicitationRequiredErrorTool } from '../tools/trigger-url-elicitation-required-error.js';
import { registerGetRootsListTool } from '../tools/get-roots-list.js';
import { registerGZipFileAsResourceTool } from '../tools/gzip-file-as-resource.js';
@@ -706,6 +708,175 @@ describe('Tools', () => {
});
});
describe('trigger-url-elicitation-request', () => {
it('should not register when client does not support URL elicitation', () => {
const handlers: Map<string, Function> = new Map();
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ elicitation: { form: {} } })),
},
} as unknown as McpServer;
registerTriggerUrlElicitationRequestTool(mockServer);
expect(mockServer.registerTool).not.toHaveBeenCalled();
});
it('should register when client supports URL elicitation', () => {
const handlers: Map<string, Function> = new Map();
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })),
createElicitationCompletionNotifier: vi.fn(() => vi.fn()),
},
} as unknown as McpServer;
registerTriggerUrlElicitationRequestTool(mockServer);
expect(mockServer.registerTool).toHaveBeenCalledWith(
'trigger-url-elicitation-request',
expect.objectContaining({
title: 'Trigger URL Elicitation Request Tool',
description: expect.stringContaining('URL elicitation'),
}),
expect.any(Function)
);
});
it('should send URL-mode elicitation request and notify completion when requested', async () => {
const handlers: Map<string, Function> = new Map();
const mockSendRequest = vi.fn().mockResolvedValue({
action: 'accept',
});
const mockNotifyComplete = vi.fn().mockResolvedValue(undefined);
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })),
createElicitationCompletionNotifier: vi
.fn()
.mockReturnValue(mockNotifyComplete),
},
} as unknown as McpServer;
registerTriggerUrlElicitationRequestTool(mockServer);
const handler = handlers.get('trigger-url-elicitation-request')!;
const result = await handler(
{
url: 'https://example.com/verify',
message: 'Open this page to verify your identity',
elicitationId: 'elicitation-123',
sendCompletionNotification: true,
},
{ sendRequest: mockSendRequest }
);
expect(mockSendRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'elicitation/create',
params: expect.objectContaining({
mode: 'url',
url: 'https://example.com/verify',
message: 'Open this page to verify your identity',
elicitationId: 'elicitation-123',
}),
}),
expect.anything(),
expect.anything()
);
expect(mockServer.server.createElicitationCompletionNotifier).toHaveBeenCalledWith(
'elicitation-123'
);
expect(mockNotifyComplete).toHaveBeenCalledTimes(1);
expect(result.content[0].text).toContain('URL elicitation action: accept');
});
});
describe('trigger-url-elicitation-required-error', () => {
it('should not register when client does not support URL elicitation', () => {
const handlers: Map<string, Function> = new Map();
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ elicitation: { form: {} } })),
},
} as unknown as McpServer;
registerTriggerUrlElicitationRequiredErrorTool(mockServer);
expect(mockServer.registerTool).not.toHaveBeenCalled();
});
it('should register when client supports URL elicitation', () => {
const handlers: Map<string, Function> = new Map();
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })),
},
} as unknown as McpServer;
registerTriggerUrlElicitationRequiredErrorTool(mockServer);
expect(mockServer.registerTool).toHaveBeenCalledWith(
'trigger-url-elicitation-required-error',
expect.objectContaining({
title: 'Trigger URL Elicitation Required Error Tool',
}),
expect.any(Function)
);
});
it('should throw MCP error -32042 with required URL elicitation data', async () => {
const handlers: Map<string, Function> = new Map();
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })),
},
} as unknown as McpServer;
registerTriggerUrlElicitationRequiredErrorTool(mockServer);
const handler = handlers.get('trigger-url-elicitation-required-error')!;
expect.assertions(2);
try {
await handler({
url: 'https://example.com/connect',
message: 'Authorization is required to continue.',
elicitationId: 'elicitation-xyz',
});
} catch (error: any) {
expect(error.code).toBe(-32042);
expect(error.data.elicitations[0]).toEqual({
mode: 'url',
url: 'https://example.com/connect',
message: 'Authorization is required to continue.',
elicitationId: 'elicitation-xyz',
});
}
});
});
describe('get-roots-list', () => {
it('should not register when client does not support roots', () => {
const { mockServer } = createMockServer();

View File

@@ -22,6 +22,9 @@
- `trigger-long-running-operation` (tools/trigger-trigger-long-running-operation.ts): Simulates a multi-step operation over a given `duration` and number of `steps`; reports progress via `notifications/progress` when a `progressToken` is provided by the client.
- `toggle-simulated-logging` (tools/toggle-simulated-logging.ts): Starts or stops simulated, randomleveled logging for the invoking session. Respects the clients selected minimum logging level.
- `toggle-subscriber-updates` (tools/toggle-subscriber-updates.ts): Starts or stops simulated resource update notifications for URIs the invoking session has subscribed to.
- `trigger-elicitation-request` (tools/trigger-elicitation-request.ts): Issues an `elicitation/create` request using form-mode fields (strings, numbers, booleans, enums, and format validation) and returns the resulting action/content.
- `trigger-url-elicitation-request` (tools/trigger-url-elicitation-request.ts): Issues an `elicitation/create` request in URL mode (`mode: "url"`) with an `elicitationId`, and can optionally emit `notifications/elicitation/complete` after acceptance. Requires client capability `elicitation.url`.
- `trigger-url-elicitation-required-error` (tools/trigger-url-elicitation-required-error.ts): Throws MCP error `-32042` (`UrlElicitationRequiredError`) with one or more required URL-mode elicitations in `error.data.elicitations`, demonstrating the retry-after-elicitation flow.
- `trigger-sampling-request` (tools/trigger-sampling-request.ts): Issues a `sampling/createMessage` request to the client/LLM using provided `prompt` and optional generation controls; returns the LLM's response payload.
- `simulate-research-query` (tools/simulate-research-query.ts): Demonstrates MCP Tasks (SEP-1686) with a simulated multi-stage research operation. Accepts `topic` and `ambiguous` parameters. Returns a task that progresses through stages with status updates. If `ambiguous` is true and client supports elicitation, sends an elicitation request directly to gather clarification before completing.
- `trigger-sampling-request-async` (tools/trigger-sampling-request-async.ts): Demonstrates bidirectional tasks where the server sends a sampling request that the client executes as a background task. Server polls for status and retrieves the LLM result when complete. Requires client to support `tasks.requests.sampling.createMessage`.

View File

@@ -52,6 +52,8 @@ src/everything
│ ├── toggle-subscriber-updates.ts
│ ├── trigger-elicitation-request.ts
│ ├── trigger-long-running-operation.ts
│ ├── trigger-url-elicitation-required-error.ts
│ ├── trigger-url-elicitation-request.ts
│ └── trigger-sampling-request.ts
└── transports
├── sse.ts
@@ -149,6 +151,10 @@ src/everything
- `GZIP_ALLOWED_DOMAINS` (comma-separated allowlist; empty means all domains allowed)
- `trigger-elicitation-request.ts`
- Registers a `trigger-elicitation-request` tool that sends an `elicitation/create` request to the client/LLM and returns the elicitation result.
- `trigger-url-elicitation-request.ts`
- Registers a `trigger-url-elicitation-request` tool that sends an out-of-band URL-mode `elicitation/create` request (`mode: "url"`) including an `elicitationId`, and can optionally emit `notifications/elicitation/complete` after acceptance.
- `trigger-url-elicitation-required-error.ts`
- Registers a `trigger-url-elicitation-required-error` tool that throws MCP error `-32042` (`UrlElicitationRequiredError`) with required URL-mode elicitation params in `error.data.elicitations`.
- `trigger-sampling-request.ts`
- Registers a `trigger-sampling-request` tool that sends a `sampling/createMessage` request to the client/LLM and returns the sampling result.
- `get-structured-content.ts`

View File

@@ -17,6 +17,8 @@ import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.j
import { registerTriggerSamplingRequestAsyncTool } from "./trigger-sampling-request-async.js";
import { registerTriggerElicitationRequestAsyncTool } from "./trigger-elicitation-request-async.js";
import { registerSimulateResearchQueryTool } from "./simulate-research-query.js";
import { registerTriggerUrlElicitationRequestTool } from "./trigger-url-elicitation-request.js";
import { registerTriggerUrlElicitationRequiredErrorTool } from "./trigger-url-elicitation-required-error.js";
/**
* Register the tools with the MCP server.
@@ -44,6 +46,8 @@ export const registerTools = (server: McpServer) => {
export const registerConditionalTools = (server: McpServer) => {
registerGetRootsListTool(server);
registerTriggerElicitationRequestTool(server);
registerTriggerUrlElicitationRequestTool(server);
registerTriggerUrlElicitationRequiredErrorTool(server);
registerTriggerSamplingRequestTool(server);
// Task-based research tool (uses experimental tasks API)
registerSimulateResearchQueryTool(server);

View File

@@ -0,0 +1,121 @@
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
CallToolResult,
ElicitRequestURLParams,
ElicitResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// Tool input schema
const TriggerUrlElicitationRequestSchema = z.object({
url: z.string().url().describe("The URL the user should open"),
message: z
.string()
.default("Please open the link to complete this action.")
.describe("Message shown to the user before opening the URL"),
elicitationId: z
.string()
.optional()
.describe("Optional explicit elicitation ID. Defaults to a random UUID."),
sendCompletionNotification: z
.boolean()
.default(false)
.describe(
"If true, sends notifications/elicitation/complete after an accepted URL elicitation."
),
});
// Tool configuration
const name = "trigger-url-elicitation-request";
const config = {
title: "Trigger URL Elicitation Request Tool",
description:
"Trigger an out-of-band URL elicitation request so the client can direct the user to a browser flow.",
inputSchema: TriggerUrlElicitationRequestSchema,
};
/**
* Registers the 'trigger-url-elicitation-request' tool.
*
* This tool only registers when the client advertises URL-mode elicitation
* capability (clientCapabilities.elicitation.url).
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerTriggerUrlElicitationRequestTool = (server: McpServer) => {
const clientCapabilities = server.server.getClientCapabilities() || {};
const clientElicitationCapabilities = clientCapabilities.elicitation as
| {
url?: object;
}
| undefined;
const clientSupportsUrlElicitation =
clientElicitationCapabilities?.url !== undefined;
if (clientSupportsUrlElicitation) {
server.registerTool(
name,
config,
async (args, extra): Promise<CallToolResult> => {
const validatedArgs = TriggerUrlElicitationRequestSchema.parse(args);
const {
url,
message,
elicitationId: requestedElicitationId,
sendCompletionNotification,
} = validatedArgs;
const elicitationId = requestedElicitationId ?? randomUUID();
const params: ElicitRequestURLParams = {
mode: "url",
message,
url,
elicitationId,
};
const elicitationResult = await extra.sendRequest(
{
method: "elicitation/create",
params,
},
ElicitResultSchema,
{ timeout: 10 * 60 * 1000 /* 10 minutes */ }
);
const content: CallToolResult["content"] = [
{
type: "text",
text:
`URL elicitation action: ${elicitationResult.action}\n` +
`Elicitation ID: ${elicitationId}\n` +
`URL: ${url}`,
},
];
if (
sendCompletionNotification &&
elicitationResult.action === "accept"
) {
const notifyElicitationComplete =
server.server.createElicitationCompletionNotifier(elicitationId);
await notifyElicitationComplete();
content.push({
type: "text",
text: `Sent notifications/elicitation/complete for ${elicitationId}.`,
});
}
content.push({
type: "text",
text: `Raw result: ${JSON.stringify(elicitationResult, null, 2)}`,
});
return { content };
}
);
}
};

View File

@@ -0,0 +1,79 @@
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
CallToolResult,
ElicitRequestURLParams,
UrlElicitationRequiredError,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// Tool input schema
const TriggerUrlElicitationRequiredErrorSchema = z.object({
url: z.string().url().describe("The URL the user should open"),
message: z
.string()
.default("This request requires more information.")
.describe("Message shown to the user for the URL elicitation"),
elicitationId: z
.string()
.optional()
.describe("Optional explicit elicitation ID. Defaults to a random UUID."),
});
// Tool configuration
const name = "trigger-url-elicitation-required-error";
const config = {
title: "Trigger URL Elicitation Required Error Tool",
description:
"Returns MCP error -32042 (URL elicitation required) so clients can handle URL-mode elicitations via the error path.",
inputSchema: TriggerUrlElicitationRequiredErrorSchema,
};
/**
* Registers the 'trigger-url-elicitation-required-error' tool.
*
* This tool demonstrates the MCP error path for URL elicitation by throwing
* UrlElicitationRequiredError (code -32042) from a tool handler.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerTriggerUrlElicitationRequiredErrorTool = (
server: McpServer
) => {
const clientCapabilities = server.server.getClientCapabilities() || {};
const clientElicitationCapabilities = clientCapabilities.elicitation as
| {
url?: object;
}
| undefined;
const clientSupportsUrlElicitation =
clientElicitationCapabilities?.url !== undefined;
if (clientSupportsUrlElicitation) {
server.registerTool(
name,
config,
async (args): Promise<CallToolResult> => {
const validatedArgs = TriggerUrlElicitationRequiredErrorSchema.parse(args);
const { url, message, elicitationId: requestedElicitationId } =
validatedArgs;
const elicitationId = requestedElicitationId ?? randomUUID();
const requiredElicitation: ElicitRequestURLParams = {
mode: "url",
url,
message,
elicitationId,
};
throw new UrlElicitationRequiredError(
[requiredElicitation],
"This request requires more information."
);
}
);
}
};