How TUUI Implements the MCP Sampling Protocol for LLM Tool Calls: IPC Architecture Explained

TUUI implements the MCP sampling protocol by registering a request handler in the main process that forwards LLM tool calls to the renderer via UUID-based IPC channels, enabling user interaction through UI components before returning responses to the model.

TUUI leverages the Model Context Protocol (MCP) sampling protocol to bridge language model tool calls with interactive user interfaces in an Electron application. This architecture allows LLMs running in the backend to request user-driven sampling operations—such as confirming tool executions—while keeping the UI layer isolated in the renderer process. The implementation relies on dynamic IPC channels to maintain strict process separation while enabling synchronous-feeling round-trip communication between the MCP client and the user interface.

MCP Sampling Protocol Architecture Overview

The implementation spans three distinct layers that handle the flow from LLM request to user interaction and back:

  • MCP Client (Main Process): Registers the sampling method handler and initiates IPC transfer
  • IPC Bridge (Main ↔ Renderer): Manages dynamic response channels using UUID-based naming
  • Renderer UI (Renderer Process): Receives requests, presents interfaces like SamplingCard.vue, and returns results

This separation ensures that heavy UI work and user interactions remain in the renderer while the MCP client maintains its state in the main process.

Registering the Sampling Handler in the Main Process

The entry point for MCP sampling requests begins in the main process where the MCP client registers a specific handler for sampling operations.

Setting Up the MCP Client Request Handler

In src/main/mcp/client.ts, the application registers a request handler using the SamplingRequestSchema to intercept sampling calls from the LLM:

// src/main/mcp/client.ts
client.setRequestHandler(SamplingRequestSchema, async (request) => {
  console.log('Sampling request received:\n', request)
  const response = await samplingTransferInvoke(request)   // Forward to renderer
  console.log(response)
  return response
})

When an MCP-enabled LLM issues a tool call requiring user sampling, the client receives a request matching SamplingRequestSchema. The handler immediately forwards the request to samplingTransferInvoke, which manages the IPC bridge to the renderer process.

IPC Bridge: Dynamic Channel Communication

The main process implements a sophisticated IPC mechanism using dynamically generated channels to handle the request-response cycle without blocking the main thread.

Creating UUID-Based Response Channels

The samplingTransferInvoke function in src/main/index.ts creates a unique response channel for each sampling request to prevent cross-talk between concurrent operations:

// src/main/index.ts
export function samplingTransferInvoke(request: SamplingRequest): Promise<SamplingResponse> {
  return new Promise<SamplingResponse>((resolve) => {
    if (!mainWindow || mainWindow.isDestroyed()) {
      resolve(null)
      return
    }

    // Generate unique channel for this round-trip
    const responseChannel = `${msgSamplingTransferResultChannel}-${uuidv4()}`

    // Wait for single reply from renderer
    listenOnceForRendererResponse(responseChannel, resolve)

    // Send request with channel name to renderer
    mainWindow.webContents.send('msgSamplingTransferInvoke', {
      request,
      responseChannel
    } as IpcSamplingRequest)
  })
}

This implementation creates a UUID-suffixed channel name (msgSamplingTransferResult-<uuid>) for every sampling request, ensuring that concurrent tool calls receive responses on their dedicated channels without interference.

One-Shot Listener Implementation

The listenOnceForRendererResponse helper in src/main/IPCs.ts registers a single-use listener that automatically resolves the promise when the renderer responds:

// src/main/IPCs.ts
export function listenOnceForRendererResponse(
  responseChannel: string,
  resolve: (_value: McpClientResponse) => void
) {
  ipcMain.once(responseChannel, (_event, response: McpClientResponse) => {
    resolve(response)  // Resolves the promise in samplingTransferInvoke
  })
}

Using ipcMain.once ensures that the listener is automatically cleaned up after receiving the response, preventing memory leaks from accumulated listeners.

Renderer Process: Handling Sampling Requests

The renderer process receives sampling requests through a utility class that bridges the IPC gap and exposes methods for UI components to respond.

Registering the IPC Listener

In src/renderer/utils/index.ts, the Sampling class provides static methods to handle incoming sampling requests and send responses:

// src/renderer/utils/index.ts
class Sampling {
  static async msgSamplingTransferInvoke(callback: IpcSamplingRequestCallback) {
    return window.mainApi.on('msgSamplingTransferInvoke', callback)
  }

  // Renderer → Main: send the sampled result back
  static async msgSamplingTransferResult(channel: string, response: SamplingResponse) {
    console.log(channel, response)
    window.mainApi.send(channel, response)  // Uses the dynamic channel
  }
}

export const SamplingTransfer = {
  request: Sampling.msgSamplingTransferInvoke,
  response: Sampling.msgSamplingTransferResult
}

The request method registers a listener for incoming sampling operations, while the response method sends the user's decision back to the main process using the specific channel identifier provided in the original request.

UI Implementation with SamplingCard.vue

The SamplingCard.vue component in the renderer handles the actual user interaction, displaying dialogs when sampling is required:

<!-- src/renderer/components/common/SamplingCard.vue -->
<script setup lang="ts">
import { SamplingTransfer } from '@/utils'
import { ref } from 'vue'

const samplingDialog = ref(false)
const samplingParams = ref<SamplingRequestParams | {}>({})
const samplingChannel = ref('')

// Register the request handler once
SamplingTransfer.request(async (event, { request, responseChannel }) => {
  samplingParams.value = request
  samplingChannel.value = responseChannel
  samplingDialog.value = true  // Open UI dialog
})

// When user confirms the sampling:
async function confirmSampling(result) {
  await SamplingTransfer.response(samplingChannel.value, result)
  // UI closes after response
}
</script>

This component captures the responseChannel from the incoming request and preserves it until the user completes the interaction, then sends the result back through that specific channel to resolve the waiting promise in the main process.

Security: Preload Script Configuration

The IPC bridge is securely exposed to the renderer through a preload script that limits available channels and prevents arbitrary IPC access:

// src/preload/index.ts
contextBridge.exposeInMainWorld('mainApi', {
  on: (channel: string, listener: (...args: any[]) => void) => ipcRenderer.on(channel, listener),
  send: (channel: string, ...args: any[]) => ipcRenderer.send(channel, ...args)
})

// Allowed channels include sampling protocols
const allowedChannels = [
  'msgSamplingTransferInvoke',
  'msgSamplingTransferResult',
  // ...
]

The preload script safely exposes only necessary Electron APIs to the renderer, with the dynamic response channels being permitted because the channel strings are generated and supplied by the trusted main process.

End-to-End Flow: Complete Round-Trip Example

The complete MCP sampling protocol execution follows this sequence:

  1. LLM Request: The language model sends a sampling request via MCP to the client
  2. Handler Activation: src/main/mcp/client.ts receives the request and calls samplingTransferInvoke
  3. Channel Creation: The main process generates a UUID-based response channel and registers a one-shot listener via listenOnceForRendererResponse
  4. Renderer Notification: The request and channel name are sent to the renderer via msgSamplingTransferInvoke
  5. UI Presentation: SamplingCard.vue receives the request, stores the channel identifier, and presents the sampling dialog
  6. User Response: After user interaction, the component calls SamplingTransfer.response with the stored channel and result
  7. Promise Resolution: The main process receives the response on the UUID channel, resolves the original promise, and returns the result to the MCP client
  8. LLM Completion: The client returns the sampling result to the LLM, completing the tool-call round-trip

This architecture ensures that the MCP sampling protocol maintains responsiveness while keeping UI concerns properly isolated in the renderer process.

Summary

  • Dynamic Channel Management: TUUI uses UUID-based channel names (msgSamplingTransferResult-${uuid}) to handle concurrent sampling requests without interference
  • Process Isolation: The implementation maintains strict separation between the MCP client (main process) and UI components (renderer process) through Electron's IPC architecture
  • One-Shot Listeners: The listenOnceForRendererResponse pattern prevents memory leaks by automatically removing IPC listeners after receiving responses
  • Type-Safe Bridge: The SamplingTransfer utility in src/renderer/utils/index.ts provides typed methods for handling sampling requests and responses
  • Secure Exposure: The preload script limits IPC channel access while allowing dynamic channel communication required for the sampling protocol

Frequently Asked Questions

What is the MCP sampling protocol in TUUI?

The MCP sampling protocol in TUUI is an implementation of the Model Context Protocol that allows language models to request user input or confirmation during tool execution. According to the TUUI source code, this protocol intercepts sampling requests in src/main/mcp/client.ts using SamplingRequestSchema, then forwards them to the renderer process via IPC to display interactive UI components like SamplingCard.vue before returning the user's response to the model.

How does TUUI maintain process isolation during sampling?

TUUI maintains process isolation by keeping the MCP client in the main process while executing all user interface logic in the renderer process. The samplingTransferInvoke function in src/main/index.ts creates a promise-based bridge that sends requests to the renderer via mainWindow.webContents.send(), then waits for a response on a dynamically generated channel. This ensures that heavy UI operations never block the main process while the MCP client maintains its connection to the language model.

What happens if the main window is destroyed during a sampling request?

If the main window is destroyed during a sampling request, the samplingTransferInvoke function immediately resolves the promise with null and returns early without creating IPC channels. As shown in the source code, the function checks if (!mainWindow || mainWindow.isDestroyed()) before proceeding with channel creation, preventing errors from sending IPC messages to non-existent windows and gracefully handling the edge case.

Can the sampling protocol handle multiple concurrent requests?

Yes, the sampling protocol handles multiple concurrent requests through its UUID-based channel naming strategy. Each call to samplingTransferInvoke generates a unique response channel using uuidv4(), ensuring that concurrent sampling operations from the LLM receive responses on their dedicated channels without cross-talk. The listenOnceForRendererResponse mechanism further ensures that each channel listener is isolated and cleaned up independently after its specific request completes.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →