Skip to content
90 changes: 80 additions & 10 deletions src/everything/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerEchoTool, EchoSchema } from '../tools/echo.js';
import { registerGetSumTool } from '../tools/get-sum.js';
Expand All @@ -13,7 +13,10 @@ 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 { registerTriggerUrlElicitationTool } from '../tools/trigger-url-elicitation.js';
import {
registerTriggerUrlElicitationTool,
__resetIssuedErrorPathElicitations,
} from '../tools/trigger-url-elicitation.js';
import { registerGetRootsListTool } from '../tools/get-roots-list.js';
import { registerGZipFileAsResourceTool } from '../tools/gzip-file-as-resource.js';

Expand Down Expand Up @@ -708,6 +711,12 @@ describe('Tools', () => {
});

describe('trigger-url-elicitation', () => {
// The error-path marker is module-level state shared across cases; reset it
// so tests are independent of order and of each other's leftover keys.
beforeEach(() => {
__resetIssuedErrorPathElicitations();
});

it('should not register when client does not support URL elicitation', () => {
const handlers: Map<string, Function> = new Map();
const mockServer = {
Expand Down Expand Up @@ -913,7 +922,7 @@ describe('Tools', () => {
);
});

it('should throw MCP error -32042 with required URL elicitation data when errorPath is true', async () => {
it('should throw MCP error -32042 with a prerequisite elicitation pointing at a different URL when errorPath is true', async () => {
const handlers: Map<string, Function> = new Map();
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
Expand All @@ -928,7 +937,7 @@ describe('Tools', () => {

const handler = handlers.get('trigger-url-elicitation')!;

expect.assertions(2);
expect.assertions(5);

try {
await handler(
Expand All @@ -942,13 +951,74 @@ describe('Tools', () => {
);
} 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',
});
const prerequisite = error.data.elicitations[0];
expect(prerequisite.mode).toBe('url');
// The prerequisite must NOT reuse the failing URL, otherwise the client
// would complete it, retry, hit the same error, and loop forever.
expect(prerequisite.url).toBe('https://modelcontextprotocol.io');
expect(prerequisite.url).not.toBe('https://example.com/connect');
// It carries its own elicitation id for the prerequisite itself.
expect(typeof prerequisite.elicitationId).toBe('string');
}
});

it('should ignore errorPath and take the request path when the same call is retried after the prerequisite', async () => {
const handlers: Map<string, Function> = new Map();
const mockSendRequest = vi.fn().mockResolvedValue({ action: 'accept' });

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;

registerTriggerUrlElicitationTool(mockServer);

const handler = handlers.get('trigger-url-elicitation')!;
// A real client retries with the *same* arguments and does not echo the
// prerequisite's elicitationId. Note these args omit elicitationId, so the
// correlation must rely on stable inputs (session + url), not a per-call
// random id.
const args = {
url: 'https://example.com/connect',
message: 'Authorization is required to continue.',
errorPath: true,
};
const extra = { sessionId: 'session-1', sendRequest: mockSendRequest };

// First call: error path issues the prerequisite and throws -32042.
let prerequisiteUrl: string | undefined;
try {
await handler(args, extra);
throw new Error('expected first call to throw');
} catch (error: any) {
expect(error.code).toBe(-32042);
prerequisiteUrl = error.data.elicitations[0].url;
expect(prerequisiteUrl).toBe('https://modelcontextprotocol.io');
expect(mockSendRequest).not.toHaveBeenCalled();
}

// Plain retry with identical arguments: errorPath is ignored and the call
// proceeds via the request path instead of throwing the prerequisite again.
const result = await handler({ ...args }, extra);

expect(mockSendRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'elicitation/create',
params: expect.objectContaining({
mode: 'url',
url: 'https://example.com/connect',
}),
}),
expect.anything(),
expect.anything()
);
expect(result.content[0].text).toContain(
'✅ User completed the URL elicitation flow.'
);
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/everything/docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
- `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` (tools/trigger-url-elicitation.ts): Issues an `elicitation/create` request in URL mode (`mode: "url"`) with an `elicitationId`, or throws MCP error `-32042` (`UrlElicitationRequiredError`) when `errorPath=true`. Requires client capability `elicitation.url`.
- `trigger-url-elicitation` (tools/trigger-url-elicitation.ts): Issues an `elicitation/create` request in URL mode (`mode: "url"`) with an `elicitationId`, or throws MCP error `-32042` (`UrlElicitationRequiredError`) when `errorPath=true`. On the error path the prerequisite elicitation it returns points at a different URL than the failing request (`https://modelcontextprotocol.io`); when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path, so the client does not loop on the same error. The retry marker is one-shot per `(session, url, elicitationId)`: it is cleared on the recognized retry, so re-running the error path with identical arguments without an intervening prerequisite is treated as a retry and proceeds. Requires client capability `elicitation.url`.
- `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
2 changes: 1 addition & 1 deletion src/everything/docs/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ src/everything
- `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.ts`
- Registers a `trigger-url-elicitation` tool that either sends an out-of-band URL-mode `elicitation/create` request (`mode: "url"`) including an `elicitationId` (request path) or throws `UrlElicitationRequiredError` (`-32042`) for client-handled URL elicitation (error path).
- Registers a `trigger-url-elicitation` tool that either sends an out-of-band URL-mode `elicitation/create` request (`mode: "url"`) including an `elicitationId` (request path) or throws `UrlElicitationRequiredError` (`-32042`) for client-handled URL elicitation (error path). On the error path the carried prerequisite elicitation points at a different URL than the failing one (`https://modelcontextprotocol.io`), and when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path — so the client does not loop on the same error.
- `trigger-elicitation-request-async.ts`
- Registers a `trigger-elicitation-request-async` tool that demonstrates bidirectional MCP tasks for elicitation. Sends an elicitation request with task metadata, then polls the client's `tasks/get` endpoint for completion status before fetching the final result.
- `trigger-sampling-request.ts`
Expand Down
84 changes: 76 additions & 8 deletions src/everything/tools/trigger-url-elicitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ const TriggerUrlElicitationSchema = z.object({
"Controls which elicitation mechanism is used. " +
"When false (default), sends an elicitation/create request (request path). " +
"When true, throws a UrlElicitationRequiredError (MCP error code -32042) so the client handles " +
"the URL elicitation via the error path rather than waiting for a response."
"the URL elicitation via the error path rather than waiting for a response. " +
"To clear the error, satisfy the prerequisite and retry this call with the same arguments; the " +
"retry ignores errorPath and proceeds, so the client does not loop on the same error."
),
});

Expand All @@ -48,6 +50,32 @@ const config = {
},
};

/**
* Tracks requests for which an error-path prerequisite has already been issued,
* keyed by the stable inputs a client resends when it retries the original tool
* call (session + URL + caller-supplied elicitationId).
*
* When the client satisfies the prerequisite and retries the same call, the
* matching entry lets us recognize the retry, ignore `errorPath`, and proceed
* via the request path instead of re-throwing `UrlElicitationRequiredError` —
* which would otherwise loop forever (throw -> client satisfies prerequisite ->
* retry -> throw -> ...).
*
* Demo simplification: entries are only removed on a recognized retry, so a
* client that triggers the error path and never retries leaves its key behind.
* That is acceptable for this reference server; a production implementation
* serving many long-lived sessions should evict entries (e.g. a
* `Map<string, timestamp>` with TTL-based cleanup).
*/
const issuedErrorPathElicitations = new Set<string>();

/**
* Test-only helper to reset the module-level error-path state between cases.
* Not part of the tool's public behavior.
*/
export const __resetIssuedErrorPathElicitations = () =>
issuedErrorPathElicitations.clear();

/**
* Registers the 'trigger-url-elicitation' tool.
*
Expand All @@ -56,8 +84,11 @@ const config = {
*
* Depending on the `errorPath` argument it either:
* - Sends an `elicitation/create` request and awaits the result (request path), or
* - Throws a `UrlElicitationRequiredError` (MCP error -32042) for the client to
* handle via the error path.
* - Throws a `UrlElicitationRequiredError` (MCP error -32042) carrying a
* prerequisite elicitation for the client to handle (error path). When the
* client satisfies the prerequisite and retries the same call, the retry
* ignores `errorPath` and proceeds via the request path, so the client does
* not loop on the same error.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
Expand Down Expand Up @@ -85,6 +116,16 @@ export const registerTriggerUrlElicitationTool = (server: McpServer) => {
} = args;

const elicitationId = requestedElicitationId ?? randomUUID();
const sessionId = extra.sessionId ?? "default";

// Key the one-shot error-path marker on inputs the client resends
// verbatim when it retries the original tool call. A real client retries
// with the *same* arguments and does NOT echo the prerequisite's
// (server-generated) elicitationId, so we must key on stable inputs:
// the session, the requested URL, and the caller-supplied elicitationId
// (if any). Keying on the resolved/random elicitationId would change on
// every call and never match, re-throwing the prerequisite forever.
const errorPathKey = `${sessionId}\u0000${url}\u0000${requestedElicitationId ?? ""}`;

const elicitationParams: ElicitRequestURLParams = {
mode: "url",
Expand All @@ -93,12 +134,39 @@ export const registerTriggerUrlElicitationTool = (server: McpServer) => {
elicitationId,
};

// Error path: throw UrlElicitationRequiredError (-32042) for the client to handle
// Error path: signal the client via UrlElicitationRequiredError (-32042)
// so it handles a prerequisite URL elicitation before this request can
// proceed. Two things keep the client from looping forever:
//
// 1. The prerequisite points at a *different* URL than the one that
// failed. Reusing the original `url` would make the client complete
// the prerequisite, retry, and hit the same -32042 error endlessly.
// 2. We remember that we issued a prerequisite for this request. When
// the client satisfies it and retries the same call, we recognize
// the retry, *ignore* errorPath, and fall through to the request
// path. Without this, the retry would re-enter the error path and
// re-request the prerequisite URL — another loop.
if (errorPath) {
throw new UrlElicitationRequiredError(
[elicitationParams],
"This request requires browser-based authorization."
);
if (issuedErrorPathElicitations.has(errorPathKey)) {
// Retry of a satisfied prerequisite: clear the one-shot marker and
// ignore errorPath, falling through to the request path below.
issuedErrorPathElicitations.delete(errorPathKey);
} else {
// Originating call: record that we issued a prerequisite for this
// request, then signal the client via -32042.
issuedErrorPathElicitations.add(errorPathKey);
const prerequisiteElicitation: ElicitRequestURLParams = {
mode: "url",
url: "https://modelcontextprotocol.io",
message:
"Open this link to satisfy the prerequisite, then retry the request.",
elicitationId: randomUUID(),
};
throw new UrlElicitationRequiredError(
[prerequisiteElicitation],
"This request requires browser-based authorization."
);
}
}

// Request path: send elicitation/create and await the user's response
Expand Down
Loading