Skip to content

Commit 224b6ee

Browse files
committed
url elicitation
1 parent 618cf48 commit 224b6ee

File tree

7 files changed

+389
-3
lines changed

7 files changed

+389
-3
lines changed

src/everything/__tests__/registrations.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe('Registration Index Files', () => {
5454
server: {
5555
getClientCapabilities: vi.fn(() => ({
5656
roots: {},
57-
elicitation: {},
57+
elicitation: { url: {} },
5858
sampling: {},
5959
})),
6060
},
@@ -67,14 +67,16 @@ describe('Registration Index Files', () => {
6767

6868
registerConditionalTools(mockServerWithCapabilities);
6969

70-
// Should register 3 conditional tools + 3 task-based tools when all capabilities present
71-
expect(mockServerWithCapabilities.registerTool).toHaveBeenCalledTimes(3);
70+
// Should register 5 conditional tools + 3 task-based tools when all capabilities present
71+
expect(mockServerWithCapabilities.registerTool).toHaveBeenCalledTimes(5);
7272

7373
const registeredTools = (
7474
mockServerWithCapabilities.registerTool as any
7575
).mock.calls.map((call: any[]) => call[0]);
7676
expect(registeredTools).toContain('get-roots-list');
7777
expect(registeredTools).toContain('trigger-elicitation-request');
78+
expect(registeredTools).toContain('trigger-url-elicitation-request');
79+
expect(registeredTools).toContain('trigger-url-elicitation-required-error');
7880
expect(registeredTools).toContain('trigger-sampling-request');
7981

8082
// Task-based tools are registered via experimental.tasks.registerToolTask

src/everything/__tests__/tools.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { registerToggleSimulatedLoggingTool } from '../tools/toggle-simulated-lo
1313
import { registerToggleSubscriberUpdatesTool } from '../tools/toggle-subscriber-updates.js';
1414
import { registerTriggerSamplingRequestTool } from '../tools/trigger-sampling-request.js';
1515
import { registerTriggerElicitationRequestTool } from '../tools/trigger-elicitation-request.js';
16+
import { registerTriggerUrlElicitationRequestTool } from '../tools/trigger-url-elicitation-request.js';
17+
import { registerTriggerUrlElicitationRequiredErrorTool } from '../tools/trigger-url-elicitation-required-error.js';
1618
import { registerGetRootsListTool } from '../tools/get-roots-list.js';
1719
import { registerGZipFileAsResourceTool } from '../tools/gzip-file-as-resource.js';
1820

@@ -706,6 +708,175 @@ describe('Tools', () => {
706708
});
707709
});
708710

711+
describe('trigger-url-elicitation-request', () => {
712+
it('should not register when client does not support URL elicitation', () => {
713+
const handlers: Map<string, Function> = new Map();
714+
const mockServer = {
715+
registerTool: vi.fn((name: string, config: any, handler: Function) => {
716+
handlers.set(name, handler);
717+
}),
718+
server: {
719+
getClientCapabilities: vi.fn(() => ({ elicitation: { form: {} } })),
720+
},
721+
} as unknown as McpServer;
722+
723+
registerTriggerUrlElicitationRequestTool(mockServer);
724+
725+
expect(mockServer.registerTool).not.toHaveBeenCalled();
726+
});
727+
728+
it('should register when client supports URL elicitation', () => {
729+
const handlers: Map<string, Function> = new Map();
730+
const mockServer = {
731+
registerTool: vi.fn((name: string, config: any, handler: Function) => {
732+
handlers.set(name, handler);
733+
}),
734+
server: {
735+
getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })),
736+
createElicitationCompletionNotifier: vi.fn(() => vi.fn()),
737+
},
738+
} as unknown as McpServer;
739+
740+
registerTriggerUrlElicitationRequestTool(mockServer);
741+
742+
expect(mockServer.registerTool).toHaveBeenCalledWith(
743+
'trigger-url-elicitation-request',
744+
expect.objectContaining({
745+
title: 'Trigger URL Elicitation Request Tool',
746+
description: expect.stringContaining('URL elicitation'),
747+
}),
748+
expect.any(Function)
749+
);
750+
});
751+
752+
it('should send URL-mode elicitation request and notify completion when requested', async () => {
753+
const handlers: Map<string, Function> = new Map();
754+
const mockSendRequest = vi.fn().mockResolvedValue({
755+
action: 'accept',
756+
});
757+
const mockNotifyComplete = vi.fn().mockResolvedValue(undefined);
758+
759+
const mockServer = {
760+
registerTool: vi.fn((name: string, config: any, handler: Function) => {
761+
handlers.set(name, handler);
762+
}),
763+
server: {
764+
getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })),
765+
createElicitationCompletionNotifier: vi
766+
.fn()
767+
.mockReturnValue(mockNotifyComplete),
768+
},
769+
} as unknown as McpServer;
770+
771+
registerTriggerUrlElicitationRequestTool(mockServer);
772+
773+
const handler = handlers.get('trigger-url-elicitation-request')!;
774+
const result = await handler(
775+
{
776+
url: 'https://example.com/verify',
777+
message: 'Open this page to verify your identity',
778+
elicitationId: 'elicitation-123',
779+
sendCompletionNotification: true,
780+
},
781+
{ sendRequest: mockSendRequest }
782+
);
783+
784+
expect(mockSendRequest).toHaveBeenCalledWith(
785+
expect.objectContaining({
786+
method: 'elicitation/create',
787+
params: expect.objectContaining({
788+
mode: 'url',
789+
url: 'https://example.com/verify',
790+
message: 'Open this page to verify your identity',
791+
elicitationId: 'elicitation-123',
792+
}),
793+
}),
794+
expect.anything(),
795+
expect.anything()
796+
);
797+
798+
expect(mockServer.server.createElicitationCompletionNotifier).toHaveBeenCalledWith(
799+
'elicitation-123'
800+
);
801+
expect(mockNotifyComplete).toHaveBeenCalledTimes(1);
802+
expect(result.content[0].text).toContain('URL elicitation action: accept');
803+
});
804+
});
805+
806+
describe('trigger-url-elicitation-required-error', () => {
807+
it('should not register when client does not support URL elicitation', () => {
808+
const handlers: Map<string, Function> = new Map();
809+
const mockServer = {
810+
registerTool: vi.fn((name: string, config: any, handler: Function) => {
811+
handlers.set(name, handler);
812+
}),
813+
server: {
814+
getClientCapabilities: vi.fn(() => ({ elicitation: { form: {} } })),
815+
},
816+
} as unknown as McpServer;
817+
818+
registerTriggerUrlElicitationRequiredErrorTool(mockServer);
819+
820+
expect(mockServer.registerTool).not.toHaveBeenCalled();
821+
});
822+
823+
it('should register when client supports URL elicitation', () => {
824+
const handlers: Map<string, Function> = new Map();
825+
const mockServer = {
826+
registerTool: vi.fn((name: string, config: any, handler: Function) => {
827+
handlers.set(name, handler);
828+
}),
829+
server: {
830+
getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })),
831+
},
832+
} as unknown as McpServer;
833+
834+
registerTriggerUrlElicitationRequiredErrorTool(mockServer);
835+
836+
expect(mockServer.registerTool).toHaveBeenCalledWith(
837+
'trigger-url-elicitation-required-error',
838+
expect.objectContaining({
839+
title: 'Trigger URL Elicitation Required Error Tool',
840+
}),
841+
expect.any(Function)
842+
);
843+
});
844+
845+
it('should throw MCP error -32042 with required URL elicitation data', async () => {
846+
const handlers: Map<string, Function> = new Map();
847+
const mockServer = {
848+
registerTool: vi.fn((name: string, config: any, handler: Function) => {
849+
handlers.set(name, handler);
850+
}),
851+
server: {
852+
getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} } })),
853+
},
854+
} as unknown as McpServer;
855+
856+
registerTriggerUrlElicitationRequiredErrorTool(mockServer);
857+
858+
const handler = handlers.get('trigger-url-elicitation-required-error')!;
859+
860+
expect.assertions(2);
861+
862+
try {
863+
await handler({
864+
url: 'https://example.com/connect',
865+
message: 'Authorization is required to continue.',
866+
elicitationId: 'elicitation-xyz',
867+
});
868+
} catch (error: any) {
869+
expect(error.code).toBe(-32042);
870+
expect(error.data.elicitations[0]).toEqual({
871+
mode: 'url',
872+
url: 'https://example.com/connect',
873+
message: 'Authorization is required to continue.',
874+
elicitationId: 'elicitation-xyz',
875+
});
876+
}
877+
});
878+
});
879+
709880
describe('get-roots-list', () => {
710881
it('should not register when client does not support roots', () => {
711882
const { mockServer } = createMockServer();

src/everything/docs/features.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
- `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.
2323
- `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.
2424
- `toggle-subscriber-updates` (tools/toggle-subscriber-updates.ts): Starts or stops simulated resource update notifications for URIs the invoking session has subscribed to.
25+
- `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.
26+
- `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`.
27+
- `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.
2528
- `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.
2629
- `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.
2730
- `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`.

src/everything/docs/structure.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ src/everything
5252
│ ├── toggle-subscriber-updates.ts
5353
│ ├── trigger-elicitation-request.ts
5454
│ ├── trigger-long-running-operation.ts
55+
│ ├── trigger-url-elicitation-required-error.ts
56+
│ ├── trigger-url-elicitation-request.ts
5557
│ └── trigger-sampling-request.ts
5658
└── transports
5759
├── sse.ts
@@ -149,6 +151,10 @@ src/everything
149151
- `GZIP_ALLOWED_DOMAINS` (comma-separated allowlist; empty means all domains allowed)
150152
- `trigger-elicitation-request.ts`
151153
- Registers a `trigger-elicitation-request` tool that sends an `elicitation/create` request to the client/LLM and returns the elicitation result.
154+
- `trigger-url-elicitation-request.ts`
155+
- 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.
156+
- `trigger-url-elicitation-required-error.ts`
157+
- Registers a `trigger-url-elicitation-required-error` tool that throws MCP error `-32042` (`UrlElicitationRequiredError`) with required URL-mode elicitation params in `error.data.elicitations`.
152158
- `trigger-sampling-request.ts`
153159
- Registers a `trigger-sampling-request` tool that sends a `sampling/createMessage` request to the client/LLM and returns the sampling result.
154160
- `get-structured-content.ts`

src/everything/tools/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.j
1717
import { registerTriggerSamplingRequestAsyncTool } from "./trigger-sampling-request-async.js";
1818
import { registerTriggerElicitationRequestAsyncTool } from "./trigger-elicitation-request-async.js";
1919
import { registerSimulateResearchQueryTool } from "./simulate-research-query.js";
20+
import { registerTriggerUrlElicitationRequestTool } from "./trigger-url-elicitation-request.js";
21+
import { registerTriggerUrlElicitationRequiredErrorTool } from "./trigger-url-elicitation-required-error.js";
2022

2123
/**
2224
* Register the tools with the MCP server.
@@ -44,6 +46,8 @@ export const registerTools = (server: McpServer) => {
4446
export const registerConditionalTools = (server: McpServer) => {
4547
registerGetRootsListTool(server);
4648
registerTriggerElicitationRequestTool(server);
49+
registerTriggerUrlElicitationRequestTool(server);
50+
registerTriggerUrlElicitationRequiredErrorTool(server);
4751
registerTriggerSamplingRequestTool(server);
4852
// Task-based research tool (uses experimental tasks API)
4953
registerSimulateResearchQueryTool(server);
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { randomUUID } from "node:crypto";
2+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3+
import {
4+
CallToolResult,
5+
ElicitRequestURLParams,
6+
ElicitResultSchema,
7+
} from "@modelcontextprotocol/sdk/types.js";
8+
import { z } from "zod";
9+
10+
// Tool input schema
11+
const TriggerUrlElicitationRequestSchema = z.object({
12+
url: z.string().url().describe("The URL the user should open"),
13+
message: z
14+
.string()
15+
.default("Please open the link to complete this action.")
16+
.describe("Message shown to the user before opening the URL"),
17+
elicitationId: z
18+
.string()
19+
.optional()
20+
.describe("Optional explicit elicitation ID. Defaults to a random UUID."),
21+
sendCompletionNotification: z
22+
.boolean()
23+
.default(false)
24+
.describe(
25+
"If true, sends notifications/elicitation/complete after an accepted URL elicitation."
26+
),
27+
});
28+
29+
// Tool configuration
30+
const name = "trigger-url-elicitation-request";
31+
const config = {
32+
title: "Trigger URL Elicitation Request Tool",
33+
description:
34+
"Trigger an out-of-band URL elicitation request so the client can direct the user to a browser flow.",
35+
inputSchema: TriggerUrlElicitationRequestSchema,
36+
};
37+
38+
/**
39+
* Registers the 'trigger-url-elicitation-request' tool.
40+
*
41+
* This tool only registers when the client advertises URL-mode elicitation
42+
* capability (clientCapabilities.elicitation.url).
43+
*
44+
* @param {McpServer} server - The McpServer instance where the tool will be registered.
45+
*/
46+
export const registerTriggerUrlElicitationRequestTool = (server: McpServer) => {
47+
const clientCapabilities = server.server.getClientCapabilities() || {};
48+
const clientElicitationCapabilities = clientCapabilities.elicitation as
49+
| {
50+
url?: object;
51+
}
52+
| undefined;
53+
54+
const clientSupportsUrlElicitation =
55+
clientElicitationCapabilities?.url !== undefined;
56+
57+
if (clientSupportsUrlElicitation) {
58+
server.registerTool(
59+
name,
60+
config,
61+
async (args, extra): Promise<CallToolResult> => {
62+
const validatedArgs = TriggerUrlElicitationRequestSchema.parse(args);
63+
const {
64+
url,
65+
message,
66+
elicitationId: requestedElicitationId,
67+
sendCompletionNotification,
68+
} = validatedArgs;
69+
70+
const elicitationId = requestedElicitationId ?? randomUUID();
71+
72+
const params: ElicitRequestURLParams = {
73+
mode: "url",
74+
message,
75+
url,
76+
elicitationId,
77+
};
78+
79+
const elicitationResult = await extra.sendRequest(
80+
{
81+
method: "elicitation/create",
82+
params,
83+
},
84+
ElicitResultSchema,
85+
{ timeout: 10 * 60 * 1000 /* 10 minutes */ }
86+
);
87+
88+
const content: CallToolResult["content"] = [
89+
{
90+
type: "text",
91+
text:
92+
`URL elicitation action: ${elicitationResult.action}\n` +
93+
`Elicitation ID: ${elicitationId}\n` +
94+
`URL: ${url}`,
95+
},
96+
];
97+
98+
if (
99+
sendCompletionNotification &&
100+
elicitationResult.action === "accept"
101+
) {
102+
const notifyElicitationComplete =
103+
server.server.createElicitationCompletionNotifier(elicitationId);
104+
await notifyElicitationComplete();
105+
content.push({
106+
type: "text",
107+
text: `Sent notifications/elicitation/complete for ${elicitationId}.`,
108+
});
109+
}
110+
111+
content.push({
112+
type: "text",
113+
text: `Raw result: ${JSON.stringify(elicitationResult, null, 2)}`,
114+
});
115+
116+
return { content };
117+
}
118+
);
119+
}
120+
};
121+

0 commit comments

Comments
 (0)