Skip to content

feat(core): implement MCP elicitation (form + url) capability#28089

Closed
KittiphonKamnuan wants to merge 6 commits into
google-gemini:mainfrom
KittiphonKamnuan:feature/mcp-elicitation-capability
Closed

feat(core): implement MCP elicitation (form + url) capability#28089
KittiphonKamnuan wants to merge 6 commits into
google-gemini:mainfrom
KittiphonKamnuan:feature/mcp-elicitation-capability

Conversation

@KittiphonKamnuan

Copy link
Copy Markdown

Summary

Implements MCP elicitation (form + url modes) in the core MCP client, addressing #28074 (which consolidates the previously-deferred #15613 and #22249).

Per the 2025-11-25 spec, the client now:

  • Advertises elicitation: { form: {}, url: {} } at initialize.
  • Handles elicitation/create for both form-mode and url-mode requests by routing them through the existing MessageBus (new MCP_ELICITATION_REQUEST / MCP_ELICITATION_RESPONSE message types).
  • Implements the -32042 retry path in McpCallableTool.callTool: catches UrlElicitationRequiredError, drives the URL elicitation flow over the bus, and resumes the tool call. The retry loop is bounded by MAX_URL_ELICITATION_ROUNDS to prevent unbounded re-elicitation.
  • Threads the MessageBus from McpClientManagerMcpClientconnectToMcpServer so the handler can publish requests.

Why

Quoting #28074: elicitation — especially url-mode — is the spec-native primitive for require-confirmation / human-in-the-loop flows and third-party OAuth/3LO. Without it, servers integrating Slack/Notion/Salesforce on the user's behalf are blocked, and form-mode servers receive Method not found. VS Code Copilot's MCP client ships url-mode end-to-end as a production reference.

What's NOT in this PR

The CLI-side UI (form rendering, URL opener) lives in packages/cli and will land in a follow-up PR. This PR is the protocol surface + retry path in core, which is what the issue scopes as effort/medium against mcp-client.ts. Until the CLI subscriber lands, the handler degrades gracefully (returns { action: 'decline' } when no MessageBus is wired).

Files changed

  • packages/core/src/confirmation-bus/types.ts — new message types
  • packages/core/src/tools/mcp-client.ts — capability, handler, retry path
  • packages/core/src/tools/mcp-client-manager.ts — threads MessageBus
  • packages/core/src/tools/mcp-client.test.ts — unit tests

Test plan

  • vitest run packages/core/src/tools/mcp-client.test.ts — 77/77 passing (incl. 2 new)
  • vitest run packages/core/src/tools/mcp-client-manager.test.ts packages/core/src/confirmation-bus/message-bus.test.ts — 55/55 passing
  • eslint — clean on changed files
  • tsc --noEmit — no new errors

References

@KittiphonKamnuan KittiphonKamnuan requested review from a team as code owners June 22, 2026 08:01
@google-cla

google-cla Bot commented Jun 22, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request implements the Model Context Protocol (MCP) elicitation specification (2025-11-25) within the core client. By enabling form and URL-based elicitation, the client can now support complex human-in-the-loop interactions and third-party authentication flows. The changes include protocol capability advertisement, a new request handler for elicitation, and a retry path for tool calls that require external user input, ensuring that servers can gracefully request information without blocking execution.

Highlights

  • MCP Elicitation Implementation: Implemented support for MCP elicitation (form and url modes) in the core client, allowing servers to request user input or trigger out-of-band flows like OAuth.
  • Capability Advertisement: Updated the MCP client to advertise support for both form and url elicitation capabilities during the initialization phase.
  • Retry Logic: Added a robust retry mechanism in McpCallableTool to handle UrlElicitationRequiredError, enabling the client to pause, perform the required elicitation, and resume the tool call.
  • Message Bus Integration: Threaded the MessageBus through the client manager and client to facilitate communication between MCP servers and the UI layer.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions github-actions Bot added the size/l A large sized PR label Jun 22, 2026
@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

📊 PR Size: size/L

  • Lines changed: 562
  • Additions: +546
  • Deletions: -16
  • Files changed: 4

@KittiphonKamnuan

Copy link
Copy Markdown
Author

/gemini review

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

🛑 Action Required: Evaluation Approval

Steering changes have been detected in this PR. To prevent regressions, a maintainer must approve the evaluation run before this PR can be merged.

Maintainers:

  1. Go to the Workflow Run Summary.
  2. Click the yellow 'Review deployments' button.
  3. Select the 'eval-gate' environment and click 'Approve'.

Once approved, the evaluation results will be posted here automatically.

Advertise `elicitation: { form: {}, url: {} }` at initialize per the MCP
2025-11-25 spec and implement both modes in the MCP client:

- Register an `elicitation/create` request handler that routes form-mode and
  url-mode requests to the UI via the existing MessageBus. New message types
  `MCP_ELICITATION_REQUEST` / `MCP_ELICITATION_RESPONSE` carry the payload.
- In `McpCallableTool.callTool`, catch `UrlElicitationRequiredError`
  (code -32042), drive the URL elicitation flow over the MessageBus, then
  resume/retry the tool call. The retry loop is bounded by
  `MAX_URL_ELICITATION_ROUNDS` to avoid unbounded re-elicitation.
- Thread the MessageBus from `McpClientManager` through `McpClient` into
  `connectToMcpServer` so the elicitation handler can publish requests.

This addresses google-gemini#28074 (consolidating the previously-deferred google-gemini#15613 and
google-gemini#22249). Form rendering and URL opening on the CLI side will follow in a
companion PR; this PR lands the core protocol surface and the retry path.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements support for MCP elicitation requests (both form and URL modes) and adds a retry mechanism for tool calls requiring URL elicitation. The feedback focuses on security and type safety: first, validating and sanitizing URLs received from the MCP server before forwarding them to the message bus to prevent potential security vulnerabilities; second, refactoring the newly introduced McpElicitationRequest and McpElicitationResponse interfaces into discriminated unions to enforce stricter type safety.

Comment on lines +1967 to +2010
mcpClient.setRequestHandler(ElicitRequestSchema, async (request) => {
if (!messageBus) {
// Without a message bus we cannot route the request to the UI, so
// decline rather than silently hanging the server.
return { action: 'decline' } as ElicitResult;
}

const params = request.params;
const isUrlMode = params.mode === 'url';

const elicitationRequest: Omit<McpElicitationRequest, 'correlationId'> =
isUrlMode
? {
type: MessageBusType.MCP_ELICITATION_REQUEST,
serverName: mcpServerName,
mode: 'url',
message: params.message,
elicitationId: (params as ElicitRequestURLParams).elicitationId,
url: (params as ElicitRequestURLParams).url,
}
: {
type: MessageBusType.MCP_ELICITATION_REQUEST,
serverName: mcpServerName,
mode: 'form',
message: params.message,
requestedSchema: (params as ElicitRequestFormParams)
.requestedSchema,
};

const response = await messageBus.request<
McpElicitationRequest,
McpElicitationResponse
>(
elicitationRequest,
MessageBusType.MCP_ELICITATION_RESPONSE,
mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC,
);

const result: ElicitResult = { action: response.action };
if (response.action === 'accept' && response.content) {
result.content = response.content;
}
return result;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The MCP elicitation request handler receives a URL from the MCP server and forwards it directly to the message bus without any validation or sanitization. A malicious or compromised MCP server could send a dangerous URL (e.g., using javascript:, file:, or other dangerous protocols), which could lead to arbitrary code execution, local file disclosure, or phishing when opened by the UI/CLI. Please validate that the URL uses a safe protocol (e.g., http: or https:) before forwarding it to the message bus.

  mcpClient.setRequestHandler(ElicitRequestSchema, async (request) => {
    if (!messageBus) {
      // Without a message bus we cannot route the request to the UI, so
      // decline rather than silently hanging the server.
      return { action: 'decline' } as ElicitResult;
    }

    const params = request.params;
    const isUrlMode = params.mode === 'url';

    if (isUrlMode) {
      const urlStr = (params as ElicitRequestURLParams).url;
      try {
        const parsedUrl = new URL(urlStr);
        if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
          return { action: 'decline' } as ElicitResult;
        }
      } catch {
        return { action: 'decline' } as ElicitResult;
      }
    }

    const elicitationRequest: Omit<McpElicitationRequest, 'correlationId'> =
      isUrlMode
        ? {
            type: MessageBusType.MCP_ELICITATION_REQUEST,
            serverName: mcpServerName,
            mode: 'url',
            message: params.message,
            elicitationId: (params as ElicitRequestURLParams).elicitationId,
            url: (params as ElicitRequestURLParams).url,
          }
        : {
            type: MessageBusType.MCP_ELICITATION_REQUEST,
            serverName: mcpServerName,
            mode: 'form',
            message: params.message,
            requestedSchema: (params as ElicitRequestFormParams)
              .requestedSchema,
          };

    const response = await messageBus.request<
      McpElicitationRequest,
      McpElicitationResponse
    >(
      elicitationRequest,
      MessageBusType.MCP_ELICITATION_RESPONSE,
      mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC,
    );

    const result: ElicitResult = { action: response.action };
    if (response.action === 'accept' && response.content) {
      result.content = response.content;
    }
    return result;
  });

Comment on lines +1556 to 1578
private async runUrlElicitations(
elicitations: ElicitRequestURLParams[],
): Promise<void> {
if (!this.messageBus) return;
for (const elicitation of elicitations) {
await this.messageBus.request<
McpElicitationRequest,
McpElicitationResponse
>(
{
type: MessageBusType.MCP_ELICITATION_REQUEST,
serverName: this.serverName,
mode: 'url',
message: elicitation.message,
elicitationId: elicitation.elicitationId,
url: elicitation.url,
},
MessageBusType.MCP_ELICITATION_RESPONSE,
this.timeout,
);
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The runUrlElicitations method receives a list of elicitations containing URLs from the MCP server and forwards them directly to the message bus without any validation or sanitization. A malicious or compromised MCP server could send a dangerous URL (e.g., using javascript:, file:, or other dangerous protocols), which could lead to arbitrary code execution, local file disclosure, or phishing when opened by the UI/CLI. Please validate that the URL uses a safe protocol (e.g., http: or https:) before forwarding it to the message bus.

  private async runUrlElicitations(
    elicitations: ElicitRequestURLParams[],
  ): Promise<void> {
    if (!this.messageBus) return;
    for (const elicitation of elicitations) {
      try {
        const parsedUrl = new URL(elicitation.url);
        if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
          continue;
        }
      } catch {
        continue;
      }
      await this.messageBus.request<
        McpElicitationRequest,
        McpElicitationResponse
      >(
        {
          type: MessageBusType.MCP_ELICITATION_REQUEST,
          serverName: this.serverName,
          mode: 'url',
          message: elicitation.message,
          elicitationId: elicitation.elicitationId,
          url: elicitation.url,
        },
        MessageBusType.MCP_ELICITATION_RESPONSE,
        this.timeout,
      );
    }
  }

Comment on lines +231 to +243
export interface McpElicitationRequest {
type: MessageBusType.MCP_ELICITATION_REQUEST;
correlationId: string;
serverName: string;
mode: 'form' | 'url';
message: string;
/** Present when `mode === 'form'`. JSON Schema describing requested fields. */
requestedSchema?: unknown;
/** Present when `mode === 'url'`. Opaque server-issued id used to correlate completion. */
elicitationId?: string;
/** Present when `mode === 'url'`. URL the user should open. */
url?: string;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To improve type safety and align with the discriminated union pattern used elsewhere, McpElicitationRequest should be defined as a type using a discriminated union on the mode property. This will ensure that mode-specific properties (requestedSchema for form, and elicitationId/url for url) are correctly required and typed, preventing potential runtime errors.

export type McpElicitationRequest = {
  type: MessageBusType.MCP_ELICITATION_REQUEST;
  correlationId: string;
  serverName: string;
  message: string;
} & (
  | {
      mode: 'form';
      /** JSON Schema describing requested fields. */
      requestedSchema: unknown;
    }
  | {
      mode: 'url';
      /** Opaque server-issued id used to correlate completion. */
      elicitationId: string;
      /** URL the user should open. */
      url: string;
    }
);

Comment on lines +245 to +251
export interface McpElicitationResponse {
type: MessageBusType.MCP_ELICITATION_RESPONSE;
correlationId: string;
action: 'accept' | 'decline' | 'cancel';
/** Form values when `action === 'accept'` for form-mode requests. */
content?: Record<string, string | number | boolean | string[]>;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

For better type safety, McpElicitationResponse should be a discriminated union based on the action property. The content property is only relevant when action is 'accept'. This change will make the type definition stricter and prevent accessing content on responses where it's not applicable.

export type McpElicitationResponse = {
  type: MessageBusType.MCP_ELICITATION_RESPONSE;
  correlationId: string;
} & (
  | {
      action: 'accept';
      /** Form values when `action === 'accept'` for form-mode requests. */
      content?: Record<string, string | number | boolean | string[]>;
    }
  | {
      action: 'decline' | 'cancel';
    }
);

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements support for Model Context Protocol (MCP) elicitation requests in both form and URL modes, allowing MCP servers to elicit information from the user via the message bus. The changes include defining new message types, registering elicitation request handlers, and implementing retry logic for tool calls requiring URL elicitation. Feedback from the review highlights critical security vulnerabilities where server-provided URLs are forwarded to the UI without validation, potentially leading to Remote Code Execution (RCE) or phishing. To address this, the reviewer suggests strictly validating URL protocols (restricting to HTTP/HTTPS), properly handling user cancellation or decline responses during retries, and using an AbortSignal instead of a timeout for asynchronous operations.

Comment thread packages/core/src/tools/mcp-client.ts Outdated
Comment on lines +1560 to +1576
for (const elicitation of elicitations) {
await this.messageBus.request<
McpElicitationRequest,
McpElicitationResponse
>(
{
type: MessageBusType.MCP_ELICITATION_REQUEST,
serverName: this.serverName,
mode: 'url',
message: elicitation.message,
elicitationId: elicitation.elicitationId,
url: elicitation.url,
},
MessageBusType.MCP_ELICITATION_RESPONSE,
this.timeout,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The elicitation.url is forwarded directly to the UI via the message bus without any validation or sanitization. This poses a significant security risk, as a malicious or compromised MCP server could supply a dangerous URL (e.g., using file:, javascript:, data:, or custom OS protocol handlers). Such URLs, when opened by the UI, could lead to Remote Code Execution (RCE), Local File Disclosure, or phishing. The provided code suggestion addresses this by validating that the URL strictly uses the 'http:' or 'https:' protocol before forwarding it.

Additionally, the current logic in runUrlElicitations does not properly handle user 'decline' or 'cancel' responses to URL elicitation requests. This oversight causes invokeWithUrlElicitationRetry to proceed with retrying the tool call, leading to repeated prompts for the user even after they have explicitly chosen to decline or cancel. It is recommended that runUrlElicitations be updated to return a boolean indicating whether all elicitations were successfully accepted, and invokeWithUrlElicitationRetry should abort and throw an error immediately if any elicitation is declined or cancelled.

Furthermore, when waiting for user input via the MessageBus, rely on the provided AbortSignal for cancellation instead of using a separate timeout (this.timeout) to maintain consistency with existing patterns.

    for (const elicitation of elicitations) {
      try {
        const parsed = new URL(elicitation.url);
        if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
          continue;
        }
      } catch {
        continue;
      }
      await this.messageBus.request<
        McpElicitationRequest,
        McpElicitationResponse
      >(
        {
          type: MessageBusType.MCP_ELICITATION_REQUEST,
          serverName: this.serverName,
          mode: 'url',
          message: elicitation.message,
          elicitationId: elicitation.elicitationId,
          url: elicitation.url,
        },
        MessageBusType.MCP_ELICITATION_RESPONSE,
        signal,
      );
    }
References
  1. Asynchronous operations waiting for user input via the MessageBus should rely on the provided AbortSignal for cancellation, rather than implementing a separate timeout, to maintain consistency with existing patterns.

Comment thread packages/core/src/tools/mcp-client.ts Outdated
Comment on lines +1974 to +1976
const params = request.params;
const isUrlMode = params.mode === 'url';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The MCP client receives elicitation requests from the MCP server. If the elicitation mode is 'url', the server-provided URL (params.url) is forwarded directly to the UI via the message bus without any validation or sanitization. A malicious or compromised MCP server could supply a dangerous URL (e.g., using file:, javascript:, data:, or custom OS protocol handlers) which, when opened by the UI, could lead to Remote Code Execution (RCE), Local File Disclosure, or phishing. Please validate that the URL strictly uses the 'http:' or 'https:' protocol before forwarding it.

    const params = request.params;
    const isUrlMode = params.mode === 'url';

    if (isUrlMode) {
      const rawUrl = (params as ElicitRequestURLParams).url;
      try {
        const parsed = new URL(rawUrl);
        if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
          return { action: 'decline' } as ElicitResult;
        }
      } catch {
        return { action: 'decline' } as ElicitResult;
      }
    }

Apply review feedback from gemini-code-assist on google-gemini#28089:

- **Security**: An untrusted MCP server could return a `javascript:`,
  `file:`, or `data:` URL via `elicitation/create` (url-mode) or via
  `UrlElicitationRequiredError` (-32042). The handler now rejects any
  URL whose protocol is not `http(s):` before forwarding it to the
  MessageBus — `elicitation/create` responds with `{ action: 'decline' }`,
  the retry path silently skips the unsafe entry.
- **Type safety**: `McpElicitationRequest` and `McpElicitationResponse`
  are now discriminated unions on `mode` / `action`, so mode-specific
  fields (`requestedSchema`, `elicitationId`, `url`) and
  `accept`-only `content` are correctly required and narrowed.
- Add table-driven tests covering `javascript:`, `file:`, `data:`,
  and malformed-URL inputs.
@KittiphonKamnuan

Copy link
Copy Markdown
Author

Thanks for the review @gemini-code-assist! Addressed all four findings in 99010bc:

Security (2/2)

  • Added isSafeElicitationUrl() helper that only accepts http: and https: URLs.
  • elicitation/create handler now returns { action: 'decline' } for unsafe URLs.
  • runUrlElicitations() retry path silently skips unsafe entries.
  • Added table-driven tests for javascript:, file:, data:, and malformed inputs.

Type safety (2/2)

  • McpElicitationRequest is now a discriminated union on mode — form variant requires requestedSchema, url variant requires elicitationId + url.
  • McpElicitationResponse is now a discriminated union on actioncontent is only available on the accept variant.
  • Added McpElicitationRequestWithoutCorrelationId distributive-Omit helper so callers can still construct the no-correlation-id shape without collapsing the union.

All 136 unit tests still pass (mcp-client 81/81, mcp-client-manager 38/38, message-bus 17/17). Lint and tsc clean.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

The updates to isSafeElicitationUrl and the implementation of discriminated unions for McpElicitationRequest and McpElicitationResponse effectively address the security and type safety concerns. The inclusion of table-driven tests for URL validation provides good coverage for edge cases. The PR appears to be in a solid state; please ensure the evaluation gate (mentioned in the PR conversation) is approved to proceed with merging.

Follow-up to gemini-code-assist review on google-gemini#28089. Previously
`runUrlElicitations` discarded the user's response: if the user
declined or cancelled, the retry loop would re-fire the tool call,
which would raise the same -32042 error and prompt the user again
until `MAX_URL_ELICITATION_ROUNDS` was exhausted.

`runUrlElicitations` now returns a boolean — `true` only if every
elicitation was accepted. `invokeWithUrlElicitationRetry` aborts
immediately with a clear error message when the user declined,
rather than re-prompting them on every round.

Note: the reviewer also suggested replacing the timeout argument
to `messageBus.request` with an `AbortSignal`. The current
`MessageBus.request` signature only accepts `timeoutMs`, so that
refactor is left to a follow-up that touches `message-bus.ts`.
@KittiphonKamnuan

Copy link
Copy Markdown
Author

Second pass on the review feedback (commit 3c06ce4):

Decline/cancel handlingrunUrlElicitations() now returns boolean (true only if every elicitation was accepted). invokeWithUrlElicitationRetry aborts immediately with a clear error when the user declines or cancels, instead of re-prompting on every round of the retry loop.

AbortSignal instead of timeout — Deferred. The current MessageBus.request(req, responseType, timeoutMs) signature only accepts a number; switching to AbortSignal requires changing message-bus.ts and updating every existing caller, which is out of scope for this PR. Happy to do it in a follow-up if maintainers want it.

URL validation (suggested again on the elicitation handler at L1996) was already added in the previous commit (99010bc) — see isSafeElicitationUrl and the if (isUrlMode) { ... return { action: 'decline' } } block in connectToMcpServer.

@KittiphonKamnuan

Copy link
Copy Markdown
Author

/gemini review

@gemini-cli gemini-cli Bot added priority/p2 Important but can be addressed in a future release. area/extensions Issues related to Gemini CLI extensions capability labels Jun 22, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for Model Context Protocol (MCP) elicitation requests (form and URL modes), allowing MCP servers to elicit information from the user via the message bus. It includes safety checks to reject unsafe protocols, retry logic for URL elicitation, and comprehensive unit tests. The review feedback focuses on improving robustness by adding defensive checks for malformed payloads, wrapping message bus requests in try-catch blocks to handle errors gracefully, and using an AbortSignal for cancellation instead of a timeout to maintain consistency with existing patterns.

Comment on lines +1584 to +1616
private async runUrlElicitations(
elicitations: ElicitRequestURLParams[],
): Promise<boolean> {
if (!this.messageBus) return false;
for (const elicitation of elicitations) {
if (!isSafeElicitationUrl(elicitation.url)) {
// Untrusted MCP server sent an unsafe URL; treat as declined so
// we don't surface `javascript:`/`file:`/etc. to the UI and don't
// retry in a loop.
return false;
}
const elicitationRequest: McpElicitationRequestWithoutCorrelationId = {
type: MessageBusType.MCP_ELICITATION_REQUEST,
serverName: this.serverName,
mode: 'url',
message: elicitation.message,
elicitationId: elicitation.elicitationId,
url: elicitation.url,
};
const response = await this.messageBus.request<
McpElicitationRequest,
McpElicitationResponse
>(
elicitationRequest as Omit<McpElicitationRequest, 'correlationId'>,
MessageBusType.MCP_ELICITATION_RESPONSE,
this.timeout,
);
if (response.action !== 'accept') {
return false;
}
}
return true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Add defensive checks to ensure elicitations is defined before iterating over it. Additionally, wrap the elicitation loop in a try-catch block to handle any message bus errors gracefully, logging the detailed error for debugging and returning false to signal a clean cancellation. To maintain consistency with existing patterns, rely on the provided AbortSignal for cancellation rather than implementing a separate timeout.

  private async runUrlElicitations(
    elicitations: ElicitRequestURLParams[],
    signal?: AbortSignal,
  ): Promise<boolean> {
    if (!this.messageBus || !elicitations) return false;
    try {
      for (const elicitation of elicitations) {
        if (!isSafeElicitationUrl(elicitation.url)) {
          // Untrusted MCP server sent an unsafe URL; treat as declined so
          // we don't surface `javascript:`/`file:`/etc. to the UI and don't
          // retry in a loop.
          return false;
        }
        const elicitationRequest: McpElicitationRequestWithoutCorrelationId = {
          type: MessageBusType.MCP_ELICITATION_REQUEST,
          serverName: this.serverName,
          mode: 'url',
          message: elicitation.message,
          elicitationId: elicitation.elicitationId,
          url: elicitation.url,
        };
        const response = await this.messageBus.request<
          McpElicitationRequest,
          McpElicitationResponse
        >(
          elicitationRequest as Omit<McpElicitationRequest, 'correlationId'>,
          MessageBusType.MCP_ELICITATION_RESPONSE,
          undefined,
          signal,
        );
        if (response.action !== 'accept') {
          return false;
        }
      }
      return true;
    } catch (error) {
      console.error('Failed to run URL elicitations:', error);
      return false;
    }
  }
References
  1. Asynchronous operations waiting for user input via the MessageBus should rely on the provided AbortSignal for cancellation, rather than implementing a separate timeout, to maintain consistency with existing patterns.
  2. When catching exceptions, log the detailed error for debugging instead of providing only a generic error message.

Comment on lines +2006 to +2024
mcpClient.setRequestHandler(ElicitRequestSchema, async (request) => {
if (!messageBus) {
// Without a message bus we cannot route the request to the UI, so
// decline rather than silently hanging the server.
return { action: 'decline' } as ElicitResult;
}

const params = request.params;
const isUrlMode = params.mode === 'url';

if (isUrlMode) {
const urlStr = (params as ElicitRequestURLParams).url;
if (!isSafeElicitationUrl(urlStr)) {
// Untrusted MCP server tried to coerce the UI into opening a
// dangerous URL (e.g. `javascript:`/`file:`/`data:`). Decline
// rather than forward it.
return { action: 'decline' } as ElicitResult;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Defensively guard against missing or malformed request or request.params payloads from the MCP server. Additionally, validate that the elicitation mode is either 'url' or 'form' to prevent runtime TypeError exceptions or unexpected behavior when handling unsupported modes.

  mcpClient.setRequestHandler(ElicitRequestSchema, async (request) => {
    if (!messageBus) {
      // Without a message bus we cannot route the request to the UI, so
      // decline rather than silently hanging the server.
      return { action: 'decline' } as ElicitResult;
    }

    const params = request?.params;
    if (!params) {
      return { action: 'decline' } as ElicitResult;
    }

    const isUrlMode = params.mode === 'url';
    const isFormMode = params.mode === 'form';

    if (!isUrlMode && !isFormMode) {
      return { action: 'decline' } as ElicitResult;
    }

    if (isUrlMode) {
      const urlStr = (params as ElicitRequestURLParams).url;
      if (!isSafeElicitationUrl(urlStr)) {
        // Untrusted MCP server tried to coerce the UI into opening a
        // dangerous URL (e.g. `javascript:`/`file:`/`data:`). Decline
        // rather than forward it.
        return { action: 'decline' } as ElicitResult;
      }
    }

Comment thread packages/core/src/tools/mcp-client.ts Outdated
Comment on lines +2045 to +2058
const response = await messageBus.request<
McpElicitationRequest,
McpElicitationResponse
>(
elicitationRequest as Omit<McpElicitationRequest, 'correlationId'>,
MessageBusType.MCP_ELICITATION_RESPONSE,
mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC,
);

const result: ElicitResult = { action: response.action };
if (response.action === 'accept' && response.content) {
result.content = response.content;
}
return result;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Wrap the messageBus.request call in a try-catch block. If the elicitation request fails on the message bus, log the detailed error for debugging and return an ElicitResult with action: "decline" or action: "cancel" to allow the server to handle the failure gracefully. Additionally, rely on the provided AbortSignal for cancellation instead of a separate timeout to maintain consistency with existing patterns.

    try {
      const response = await messageBus.request<
        McpElicitationRequest,
        McpElicitationResponse
      >(
        elicitationRequest as Omit<McpElicitationRequest, 'correlationId'>,
        MessageBusType.MCP_ELICITATION_RESPONSE,
        undefined,
        signal,
      );

      const result: ElicitResult = { action: response.action };
      if (response.action === 'accept' && response.content) {
        result.content = response.content;
      }
      return result;
    } catch (error) {
      console.error('Elicitation request failed:', error);
      return { action: 'decline' } as ElicitResult;
    }
References
  1. Asynchronous operations waiting for user input via the MessageBus should rely on the provided AbortSignal for cancellation, rather than implementing a separate timeout, to maintain consistency with existing patterns.
  2. When catching exceptions, log the detailed error for debugging instead of providing only a generic error message.

Follow-up to gemini-code-assist review on google-gemini#28089.

In the `elicitation/create` handler:
- Guard against missing or non-object `request.params`.
- Reject unknown `mode` values up front (treat absence as form, per spec).
- Wrap `messageBus.request` in try/catch — log via `debugLogger.error`
  (the project's logger, not `console.error`) and decline on failure
  so the server gets a clean response instead of hanging.

In `runUrlElicitations`:
- Guard against undefined / empty `elicitations`.
- Wrap the loop in try/catch with the same logging pattern; treat any
  bus failure as a declined elicitation so the retry loop aborts.

Add tests for malformed payloads, unknown modes, and bus failures.
@KittiphonKamnuan

Copy link
Copy Markdown
Author

Third pass on the review (commit 51dd395):

Defensive guardselicitation/create handler now rejects missing/non-object params and unknown mode values up front. runUrlElicitations now also guards against undefined / empty input.

Error handling — Both the handler and runUrlElicitations wrap messageBus.request in try/catch. On failure they log via debugLogger.error (the project's logger — console.error isn't the pattern in mcp-client.ts) and decline the elicitation cleanly so the server doesn't hang and the retry loop doesn't spin.

AbortSignal — Still deferred. MessageBus.request(req, responseType, timeoutMs) only accepts a number today; adopting AbortSignal requires changing message-bus.ts and every existing caller of .request() / .subscribe(). Happy to do that in a separate PR if maintainers want it.

84/84 tests passing (3 new), lint and tsc clean.

@KittiphonKamnuan

Copy link
Copy Markdown
Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements support for MCP elicitation requests (elicitation/create) in both form and url modes, introducing new message types to the confirmation bus, registering request handlers, and adding retry logic with user prompts in McpCallableTool. The review feedback recommends defensively validating that url and elicitationId are valid strings in both the request handler and retry logic, as well as trimming the optional message string with a fallback to avoid whitespace-only or undefined values.

Comment on lines +2039 to +2066
if (isUrlMode) {
const urlStr = (params as ElicitRequestURLParams).url;
if (!isSafeElicitationUrl(urlStr)) {
// Untrusted MCP server tried to coerce the UI into opening a
// dangerous URL (e.g. `javascript:`/`file:`/`data:`). Decline
// rather than forward it.
return { action: 'decline' } as ElicitResult;
}
}

const elicitationRequest: McpElicitationRequestWithoutCorrelationId =
isUrlMode
? {
type: MessageBusType.MCP_ELICITATION_REQUEST,
serverName: mcpServerName,
mode: 'url',
message: params.message,
elicitationId: (params as ElicitRequestURLParams).elicitationId,
url: (params as ElicitRequestURLParams).url,
}
: {
type: MessageBusType.MCP_ELICITATION_REQUEST,
serverName: mcpServerName,
mode: 'form',
message: params.message,
requestedSchema: (params as ElicitRequestFormParams)
.requestedSchema,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Defensively validate that url and elicitationId are valid strings when in url mode, and provide a fallback for message since it is optional in the MCP specification. When using an optional string with a fallback value, trim the optional string and use the fallback if the result is empty to avoid uninformative messages from whitespace-only strings.

    if (isUrlMode) {
      const urlStr = (params as ElicitRequestURLParams).url;
      const elicitationId = (params as ElicitRequestURLParams).elicitationId;
      if (
        typeof urlStr !== 'string' ||
        typeof elicitationId !== 'string' ||
        !isSafeElicitationUrl(urlStr)
      ) {
        // Untrusted MCP server tried to coerce the UI into opening a
        // dangerous URL or sent malformed parameters. Decline rather than forward it.
        return { action: 'decline' } as ElicitResult;
      }
    }

    const elicitationRequest: McpElicitationRequestWithoutCorrelationId = 
      isUrlMode
        ? {
            type: MessageBusType.MCP_ELICITATION_REQUEST,
            serverName: mcpServerName,
            mode: 'url',
            message: (params as ElicitRequestURLParams).message?.trim() || '',
            elicitationId: (params as ElicitRequestURLParams).elicitationId,
            url: (params as ElicitRequestURLParams).url,
          }
        : {
            type: MessageBusType.MCP_ELICITATION_REQUEST,
            serverName: mcpServerName,
            mode: 'form',
            message: (params as ElicitRequestFormParams).message?.trim() || '',
            requestedSchema: (params as ElicitRequestFormParams)
              .requestedSchema,
          };
References
  1. When using an optional string with a fallback value, trim the optional string and use the fallback if the result is empty to avoid uninformative messages from whitespace-only strings.

Comment on lines +1592 to +1606
for (const elicitation of elicitations) {
if (!isSafeElicitationUrl(elicitation.url)) {
// Untrusted MCP server sent an unsafe URL; treat as declined so
// we don't surface `javascript:`/`file:`/etc. to the UI and don't
// retry in a loop.
return false;
}
const elicitationRequest: McpElicitationRequestWithoutCorrelationId = {
type: MessageBusType.MCP_ELICITATION_REQUEST,
serverName: this.serverName,
mode: 'url',
message: elicitation.message,
elicitationId: elicitation.elicitationId,
url: elicitation.url,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Ensure that url and elicitationId are validated as strings before processing them, and provide a fallback for message to prevent passing undefined to the McpElicitationRequest which requires a string. When using an optional string with a fallback value, trim the optional string and use the fallback if the result is empty to avoid uninformative messages from whitespace-only strings.

      for (const elicitation of elicitations) {
        if (
          typeof elicitation.url !== 'string' ||
          typeof elicitation.elicitationId !== 'string' ||
          !isSafeElicitationUrl(elicitation.url)
        ) {
          // Untrusted MCP server sent an unsafe URL or malformed parameters; treat as declined.
          return false;
        }
        const elicitationRequest: McpElicitationRequestWithoutCorrelationId = {
          type: MessageBusType.MCP_ELICITATION_REQUEST,
          serverName: this.serverName,
          mode: 'url',
          message: elicitation.message?.trim() || '',
          elicitationId: elicitation.elicitationId,
          url: elicitation.url,
        };
References
  1. When using an optional string with a fallback value, trim the optional string and use the fallback if the result is empty to avoid uninformative messages from whitespace-only strings.

Follow-up to gemini-code-assist review on google-gemini#28089. Add string type guards
on the url-mode payload — `url` and `elicitationId` — in both the
`elicitation/create` handler and `runUrlElicitations`, so a malformed
server response (non-string field) is declined before reaching the UI.

Skipped the suggestion to `?.trim() || ''` the `message` field: the
MCP Zod schemas require `message: string`, so an undefined or
whitespace-only value would already be rejected at the SDK boundary.

Add table-driven tests covering non-string `url`, missing
`elicitationId`, and non-string `elicitationId`.
@KittiphonKamnuan

Copy link
Copy Markdown
Author

Fourth pass on the review (commit c83372e):

String type guards on url-mode payload — Added typeof url === 'string' and typeof elicitationId === 'string' checks in both the elicitation/create handler and runUrlElicitations. A malformed server response is now declined before reaching the UI.

Skipped: ?.trim() || '' on message — The MCP Zod schemas (ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema) require message: z.ZodString, so an undefined or whitespace-only value would already be rejected at the SDK boundary. Adding the fallback would be dead code given the schema contract.

Added table-driven tests for non-string url, missing elicitationId, and non-string elicitationId.

87/87 tests passing (3 new). Lint and tsc clean.

@gemini-cli

gemini-cli Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Hi there! Thank you for your interest in contributing to Gemini CLI.

To ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.

This PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.

@gemini-cli

gemini-cli Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/extensions Issues related to Gemini CLI extensions capability priority/p2 Important but can be addressed in a future release. size/l A large sized PR status/pr-nudge-sent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP client advertises no elicitation capability (only roots) — implement elicitation (form + url)

1 participant