Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/everything/__tests__/registrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('Registration Index Files', () => {
server: {
getClientCapabilities: vi.fn(() => ({
roots: {},
elicitation: {},
elicitation: { url: {} },
sampling: {},
})),
},
Expand All @@ -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
Expand Down
171 changes: 171 additions & 0 deletions src/everything/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/everything/docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
6 changes: 6 additions & 0 deletions src/everything/docs/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions src/everything/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
121 changes: 121 additions & 0 deletions src/everything/tools/trigger-url-elicitation-request.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
);
}
};

Loading