diff --git a/src/everything/__tests__/registrations.test.ts b/src/everything/__tests__/registrations.test.ts index ef56f7c9aa..e3f38f84d8 100644 --- a/src/everything/__tests__/registrations.test.ts +++ b/src/everything/__tests__/registrations.test.ts @@ -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 diff --git a/src/everything/__tests__/tools.test.ts b/src/everything/__tests__/tools.test.ts index dbe463b2a5..feb0a3b473 100644 --- a/src/everything/__tests__/tools.test.ts +++ b/src/everything/__tests__/tools.test.ts @@ -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 = 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 = 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 = 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 = 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 = 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 = 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(); diff --git a/src/everything/docs/features.md b/src/everything/docs/features.md index 145595b820..70ba2030d7 100644 --- a/src/everything/docs/features.md +++ b/src/everything/docs/features.md @@ -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, random‑leveled logging for the invoking session. Respects the client’s 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`. diff --git a/src/everything/docs/structure.md b/src/everything/docs/structure.md index 6bcedcd425..41b0a3279c 100644 --- a/src/everything/docs/structure.md +++ b/src/everything/docs/structure.md @@ -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` diff --git a/src/everything/tools/index.ts b/src/everything/tools/index.ts index 1526f09dde..6d19171b7a 100644 --- a/src/everything/tools/index.ts +++ b/src/everything/tools/index.ts @@ -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); diff --git a/src/everything/tools/trigger-url-elicitation-request.ts b/src/everything/tools/trigger-url-elicitation-request.ts new file mode 100644 index 0000000000..314eed0234 --- /dev/null +++ b/src/everything/tools/trigger-url-elicitation-request.ts @@ -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 => { + 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 }; + } + ); + } +}; + diff --git a/src/everything/tools/trigger-url-elicitation-required-error.ts b/src/everything/tools/trigger-url-elicitation-required-error.ts new file mode 100644 index 0000000000..123ff488ab --- /dev/null +++ b/src/everything/tools/trigger-url-elicitation-required-error.ts @@ -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 => { + 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." + ); + } + ); + } +}; +