Skip to content
Closed
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
19 changes: 18 additions & 1 deletion api-extractor/reports/mongodb-mcp-server.public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,14 @@ export class ConfigOverrideError extends Error {
constructor(message: string);
}

// @public
export type ConfirmationResult = {
ok: true;
} | {
ok: false;
reason: "declined" | "no-elicitation-support";
};

// @public (undocumented)
export type ConnectionErrorHandled = {
errorHandled: true;
Expand Down Expand Up @@ -515,11 +523,20 @@ export class Elicitation {
};
required: string[];
};
requestConfirmation(message: string): Promise<boolean>;
requestConfirmation(message: string): Promise<ConfirmationResult>;
requestInput(message: string, schema: ElicitRequestFormParams["requestedSchema"]): Promise<ElicitedInputResult>;
supportsElicitation(): boolean;
}

// @public (undocumented)
export type ElicitedInputResult = {
accepted: true;
fields: Record<string, string>;
} | {
accepted: false;
fields?: undefined;
};

// @public (undocumented)
export enum ErrorCodes {
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion api-extractor/reports/tools.public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1565,7 +1565,7 @@ export abstract class ToolBase<TUserConfig extends UserConfig = UserConfig, TCon
protected get toolMeta(): Record<string, unknown>;
// (undocumented)
protected verifyAllowed(): boolean;
verifyConfirmed(args: ToolArgs<typeof ToolBase.argsShape>): Promise<boolean>;
verifyConfirmed(args: ToolArgs<typeof ToolBase.argsShape>): Promise<ConfirmationResult>;
}

// @public
Expand Down
12 changes: 10 additions & 2 deletions api-extractor/reports/web.public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,14 @@ export class CompositeLogger extends LoggerBase {
protected readonly type?: LoggerType;
}

// @public
export type ConfirmationResult = {
ok: true;
} | {
ok: false;
reason: "declined" | "no-elicitation-support";
};

// @public (undocumented)
export type ConnectionErrorHandled = {
errorHandled: true;
Expand Down Expand Up @@ -506,7 +514,7 @@ export class Elicitation {
};
required: string[];
};
requestConfirmation(message: string): Promise<boolean>;
requestConfirmation(message: string): Promise<ConfirmationResult>;
requestInput(message: string, schema: ElicitRequestFormParams["requestedSchema"]): Promise<ElicitedInputResult>;
supportsElicitation(): boolean;
}
Expand Down Expand Up @@ -970,7 +978,7 @@ export abstract class ToolBase<TUserConfig extends UserConfig = UserConfig, TCon
protected get toolMeta(): Record<string, unknown>;
// (undocumented)
protected verifyAllowed(): boolean;
verifyConfirmed(args: ToolArgs<typeof ToolBase.argsShape>): Promise<boolean>;
verifyConfirmed(args: ToolArgs<typeof ToolBase.argsShape>): Promise<ConfirmationResult>;
}

// @public
Expand Down
1 change: 1 addition & 0 deletions src/common/logging/loggingDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const LogId = {
toolExecuteFailure: mongoLogId(1_003_002),
toolDisabled: mongoLogId(1_003_003),
toolMetadataChange: mongoLogId(1_003_004),
toolBlockedNoElicitation: mongoLogId(1_003_005),

mongodbConnectFailure: mongoLogId(1_004_001),
mongodbDisconnectFailure: mongoLogId(1_004_002),
Expand Down
39 changes: 35 additions & 4 deletions src/elicitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ export type ElicitedInputResult =
| { accepted: true; fields: Record<string, string> }
| { accepted: false; fields?: undefined };

/**
* Outcome of a confirmation elicitation.
*
* `ok: true` means the user explicitly confirmed and the caller may
* proceed. Both `ok: false` reasons mean the caller MUST refuse the
* operation; they are kept distinct so the caller can produce a
* different, actionable error message for each — a user who declined
* sees a different message than a client that can't show a prompt at
* all.
*
* This replaces a previous `Promise<boolean>` shape that conflated the
* two failure modes and returned `true` (proceed) when the client did
* not advertise elicitation support. See OWASP MCP Top 10 (2025) item
* MCP06 — Intent Flow Subversion — for the rationale.
*/
export type ConfirmationResult =
| { ok: true }
| { ok: false; reason: "declined" | "no-elicitation-support" };

const ELICITATION_TIMEOUT_MS = 300_000; // 5 minutes for user interaction

export class Elicitation {
Expand All @@ -24,12 +43,21 @@ export class Elicitation {

/**
* Requests a boolean confirmation from the user.
*
* Fails closed: if the connected client does not advertise the
* `elicitation` capability there is no way to obtain explicit
* consent, so the caller MUST refuse to proceed
* (`{ ok: false, reason: "no-elicitation-support" }`). The previous
* behaviour returned `true` in that case, which let
* confirmation-gated tools execute without any user prompt against
* clients that don't support elicitation — a silent bypass of
* `confirmationRequiredTools`. See OWASP MCP06.
*
* @param message - The message to display to the user.
* @returns True if the user confirms the action or the client does not support elicitation, false otherwise.
*/
public async requestConfirmation(message: string): Promise<boolean> {
public async requestConfirmation(message: string): Promise<ConfirmationResult> {
if (!this.supportsElicitation()) {
return true;
return { ok: false, reason: "no-elicitation-support" };
}

const result = await this.server.elicitInput(
Expand All @@ -40,7 +68,10 @@ export class Elicitation {
},
{ timeout: ELICITATION_TIMEOUT_MS }
);
return result.action === "accept" && result.content?.confirmation === "Yes";
if (result.action === "accept" && result.content?.confirmation === "Yes") {
return { ok: true };
}
return { ok: false, reason: "declined" };
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export type { TelemetryEvents, TelemetryConfig } from "./telemetry/telemetry.js"
export { EventCache } from "./telemetry/eventCache.js";
export { Keychain, registerGlobalSecretToRedact } from "./common/keychain.js";
export type { Secret } from "./common/keychain.js";
export { Elicitation } from "./elicitation.js";
export { Elicitation, type ConfirmationResult, type ElicitedInputResult } from "./elicitation.js";
export { applyConfigOverrides, ConfigOverrideError } from "./common/config/configOverrides.js";
export {
SessionStore,
Expand Down
42 changes: 33 additions & 9 deletions src/tools/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Telemetry } from "../telemetry/telemetry.js";
import type { ConnectionMetadata, TelemetryToolMetadata, ToolEvent } from "../telemetry/types.js";
import type { UserConfig } from "../common/config/userConfig.js";
import type { Server } from "../server.js";
import type { Elicitation } from "../elicitation.js";
import type { ConfirmationResult, Elicitation } from "../elicitation.js";
import type { PreviewFeature } from "../common/schemas.js";
import type { UIRegistry } from "../ui/registry/index.js";
import { createUIResource, type UIResource } from "@mcp-ui/server";
Expand Down Expand Up @@ -489,7 +489,31 @@ export abstract class ToolBase<

try {
if (this.requiresConfirmation()) {
if (!(await this.verifyConfirmed(args))) {
const confirmation = await this.verifyConfirmed(args);
if (!confirmation.ok) {
if (confirmation.reason === "no-elicitation-support") {
// OWASP MCP06: refuse the confirmation-gated tool when
// the client cannot show a prompt. The user never had
// the chance to consent, so silently proceeding (the
// previous behaviour) would bypass
// `confirmationRequiredTools`.
this.session.logger.warning({
id: LogId.toolBlockedNoElicitation,
context: "tool",
message: `Refused to execute the \`${this.name}\` tool: the connected MCP client does not advertise the elicitation capability, so user confirmation cannot be obtained.`,
noRedaction: true,
});
return {
content: [
{
type: "text",
text: `Refused to execute \`${this.name}\`: this tool requires explicit user confirmation, and the connected MCP client does not support the elicitation capability. Use a client that supports elicitation, or remove \`${this.name}\` from the \`confirmationRequiredTools\` configuration.`,
},
],
isError: true,
};
}

this.session.logger.debug({
id: LogId.toolExecute,
context: "tool",
Expand Down Expand Up @@ -598,17 +622,17 @@ export abstract class ToolBase<
* Check if the user has confirmed the tool execution (if required by
* configuration).
*
* This method automatically checks if the tool name is in the
* `confirmationRequiredTools` configuration list and requests user
* confirmation via the elicitation service if needed.
* Returns a typed result so the caller can distinguish a user-level
* decline ("user clicked no") from an environment-level inability
* to obtain consent ("client doesn't support elicitation"). Both
* block execution, but the two cases want different error messages
* and different audit-log severities. See OWASP MCP06.
*
* @param args - The tool arguments
* @returns A promise resolving to `true` if confirmed or confirmation not
* required, `false` otherwise
*/
public async verifyConfirmed(args: ToolArgs<typeof this.argsShape>): Promise<boolean> {
public async verifyConfirmed(args: ToolArgs<typeof this.argsShape>): Promise<ConfirmationResult> {
if (!this.requiresConfirmation()) {
return true;
return { ok: true };
}

return this.elicitation.requestConfirmation(this.getConfirmationMessage(args));
Expand Down
2 changes: 1 addition & 1 deletion src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export type {
ConnectionErrorUnhandled,
ConnectionErrorHandlerContext,
} from "./common/connectionErrorHandler.js";
export { Elicitation, type ElicitedInputResult } from "./elicitation.js";
export { Elicitation, type ConfirmationResult, type ElicitedInputResult } from "./elicitation.js";
export type {
ConnectionStateConnecting,
ConnectionSettings,
Expand Down
26 changes: 13 additions & 13 deletions tests/unit/elicitation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,23 @@ describe("Elicitation", () => {
describe("requestConfirmation", () => {
const testMessage = "Are you sure you want to proceed?";

it("should return true when client does not support elicitation", async () => {
it("fails closed with reason 'no-elicitation-support' when client cannot elicit (OWASP MCP06)", async () => {
mockGetClientCapabilities.mockReturnValue({});

const result = await elicitation.requestConfirmation(testMessage);

expect(result).toBe(true);
expect(result).toEqual({ ok: false, reason: "no-elicitation-support" });
expect(mockGetClientCapabilities).toHaveBeenCalledTimes(1);
expect(mockElicitInput.mock).not.toHaveBeenCalled();
});

it("should return true when user confirms with 'Yes' and action is 'accept'", async () => {
it("returns ok=true when user confirms with 'Yes' and action is 'accept'", async () => {
mockGetClientCapabilities.mockReturnValue({ elicitation: {} });
mockElicitInput.confirmYes();

const result = await elicitation.requestConfirmation(testMessage);

expect(result).toBe(true);
expect(result).toEqual({ ok: true });
expect(mockGetClientCapabilities).toHaveBeenCalledTimes(1);
expect(mockElicitInput.mock).toHaveBeenCalledTimes(1);
expect(mockElicitInput.mock).toHaveBeenCalledWith(
Expand All @@ -89,47 +89,47 @@ describe("Elicitation", () => {
);
});

it("should return false when user selects 'No' with action 'accept'", async () => {
it("returns ok=false with reason 'declined' when user selects 'No'", async () => {
mockGetClientCapabilities.mockReturnValue({ elicitation: {} });
mockElicitInput.confirmNo();

const result = await elicitation.requestConfirmation(testMessage);

expect(result).toBe(false);
expect(result).toEqual({ ok: false, reason: "declined" });
expect(mockElicitInput.mock).toHaveBeenCalledTimes(1);
});

it("should return false when content is undefined", async () => {
it("returns ok=false with reason 'declined' when content is undefined", async () => {
mockGetClientCapabilities.mockReturnValue({ elicitation: {} });
mockElicitInput.acceptWith(undefined);

const result = await elicitation.requestConfirmation(testMessage);

expect(result).toBe(false);
expect(result).toEqual({ ok: false, reason: "declined" });
expect(mockElicitInput.mock).toHaveBeenCalledTimes(1);
});

it("should return false when confirmation field is missing", async () => {
it("returns ok=false with reason 'declined' when confirmation field is missing", async () => {
mockGetClientCapabilities.mockReturnValue({ elicitation: {} });
mockElicitInput.acceptWith({});

const result = await elicitation.requestConfirmation(testMessage);

expect(result).toBe(false);
expect(result).toEqual({ ok: false, reason: "declined" });
expect(mockElicitInput.mock).toHaveBeenCalledTimes(1);
});

it("should return false when user cancels", async () => {
it("returns ok=false with reason 'declined' when user cancels", async () => {
mockGetClientCapabilities.mockReturnValue({ elicitation: {} });
mockElicitInput.cancel();

const result = await elicitation.requestConfirmation(testMessage);

expect(result).toBe(false);
expect(result).toEqual({ ok: false, reason: "declined" });
expect(mockElicitInput.mock).toHaveBeenCalledTimes(1);
});

it("should handle elicitInput erroring", async () => {
it("propagates elicitInput errors", async () => {
mockGetClientCapabilities.mockReturnValue({ elicitation: {} });
const error = new Error("Elicitation failed");
mockElicitInput.rejectWith(error);
Expand Down
Loading
Loading