Skip to main content

Returning rich responses to the Slackbot MCP Client

MCP servers can return rich, interactive UI in two ways:

Block Kit

MCP servers can return native Block Kit responses using the io.slack/block-kit extension. This enables rich Slack UI components, such as buttons, select menus, and structured layouts, that look and feel native inside Slack.

If your app already builds Block Kit responses, you can return them directly in MCP tool results. This means existing apps can bring their current Block Kit UI into Slackbot conversations without rebuilding anything.

Checking capability

Slack advertises Block Kit support during MCP initialization. Your server can check for this capability to decide whether to return Block Kit responses:

{
"method": "initialize",
"params": {
"capabilities": {
"extensions": {
"io.slack/block-kit": {
"mimeTypes": ["application/vnd.slack.blocks+json"]
}
}
}
}
}

Declaring tool support

Tools that support Block Kit responses should indicate this in their metadata:

{
"name": "list_opportunities",
"description": "Show Salesforce opportunities",
"inputSchema": {
"type": "object",
"properties": {
"stage": { "type": "string" }
}
},
"_meta": {
"slack": {
"supportsBlockKit": true
}
}
}

Returning Block Kit in tool responses

Return Block Kit JSON in _meta.slack.blocks of your tool response. Slack renders these as native UI alongside any standard content:

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "Here are your opportunities"
}
],
"_meta": {
"slack": {
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "Your Salesforce Opportunities"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Acme Corp - Website Redesign*\n$50,000 | Proposal\nJohn Smith | 2026-02-15"
},
"accessory": {
"type": "button",
"text": {
"type": "plain_text",
"text": "Mark Closed Won"
},
"style": "primary",
"action_id": "tool:update_opportunity_stage",
"value": "{\"opportunityId\":\"006xx\",\"newStage\":\"Closed Won\"}"
}
}
]
}
}
}
}

Using interactive components

When users interact with Block Kit elements like buttons, Slack routes the interaction back to your server as an MCP tool call. Your tool can return updated blocks to refresh the UI in place.

To make an element interactive, use the action_id prefix tool: followed by the tool name, and pass arguments as a JSON string in the value field. The tool: prefix tells Slack to route the interaction to your server; any other action_id is stripped and the element becomes inert. The one exception is input:<argname> on form inputs (see Forms and dynamic inputs). To open a URL instead of calling a tool, use a link-out url on the button.

value must be a JSON string, not a raw object

Block Kit's value field must always be a string. Serialize your arguments and escape the quotes. If value is a raw JSON object, an empty string, or a non-object like "true", the tool is called with no arguments. An element with no value at all will fail.

Buttons

{
"type": "button",
"text": { "type": "plain_text", "text": "Mark Closed Won" },
"action_id": "tool:update_opportunity",
"value": "{\"id\":\"006xx\",\"stage\":\"Closed Won\"}"
}

When a user clicks this button, Slack translates it into an MCP tools/call request:

{
"method": "tools/call",
"params": {
"name": "update_opportunity",
"arguments": {
"id": "006xx",
"stage": "Closed Won"
}
}
}

The keys and values inside value are passed as the tool's arguments, so they must match your tool's inputSchema. The value field is limited to 2000 characters and should contain only tool arguments. Slack adds any routing metadata itself when rendering the block, so any routing fields you include are ignored.

Tools with no arguments still need a value. Send an empty JSON object as a string: "value": "{}".

Select menus

For a select menu (e.g., static_select), put the per-choice arguments in each option's value field as a JSON-encoded string, not on the element. The tool: action_id goes on the element:

{
"type": "actions",
"elements": [
{
"type": "static_select",
"action_id": "tool:set_priority",
"placeholder": { "type": "plain_text", "text": "Set priority" },
"options": [
{ "text": { "type": "plain_text", "text": "High" }, "value": "{\"priority\":\"high\"}" },
{ "text": { "type": "plain_text", "text": "Low" }, "value": "{\"priority\":\"low\"}" }
]
}
]
}

Selecting an option calls set_priority with the arguments from that option's value. Multi-selects merge the arguments from every chosen option.

Providing a unique action_id value per block

The action_id value must be unique within a single block. This is tricky when calling the same tool with different arguments (e.g., Confirm/Cancel, or 👍/👎 both calling rate_helpfulness). You can differentiate calls by value, not by action_id. You have two options:

  • Use one element per actions block: the uniqueness scope is per-block, so the same tool: action_id is fine in separate blocks:
{
"blocks": [
{ "type": "actions", "elements": [
{ "type": "button", "text": { "type": "plain_text", "text": "Helpful" },
"action_id": "tool:rate_helpfulness", "value": "{\"rating\":\"helpful\"}" }
]},
{ "type": "actions", "elements": [
{ "type": "button", "text": { "type": "plain_text", "text": "Unhelpful" },
"action_id": "tool:rate_helpfulness", "value": "{\"rating\":\"unhelpful\"}" }
]}
]
}
  • Use a single select instead of multiple buttons: one radio_buttons or static_select element carries one action_id, and each option carries its own value. See Select menus above.

Refreshing the UI in place

When Slack calls your tool from an interaction, your tool response is handled like any other; if it returns new _meta.slack.blocks, Slack replaces the original message's blocks with the new ones in place. Return the updated blocks to reflect the new state (e.g., a disabled button or an updated status), or return blocks without the interactive element to "consume" the action.

Forms and dynamic inputs

Instead of encoding all arguments in a button's value, you can use a form: input blocks plus a submit button. When the user clicks submit, Slack harvests the form values and passes them as tool arguments.

Form values are scoped to the submit button's own form. This means a single Slackbot DM can hold several independent forms without their inputs leaking into each other's tool calls.

Argument names must be unique within a form. If two inputs bind to the same <arg_name>, the first wins, so give each input a distinct argument name. Form values that your tool's inputSchema don't declare are silently ignored, so arg names must match your schema.

Bind each input to a tool argument using input:<arg_name> as the action_id. On submit, that input's value becomes the <arg_name> argument:

{
"blocks": [
{
"type": "input",
"label": { "type": "plain_text", "text": "Priority" },
"element": {
"type": "static_select",
"action_id": "input:priority",
"options": [
{ "text": { "type": "plain_text", "text": "High" }, "value": "high" },
{ "text": { "type": "plain_text", "text": "Low" }, "value": "low" }
]
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "Create ticket" },
"action_id": "tool:create_ticket",
"value": "{}"
}
]
}
]
}

Clicking Create ticket calls create_ticket with { "priority": "<selected value>" } merged in. Form values take precedence over static arguments in the button's value.

For external_select (type-ahead with dynamic options), keep the element's action_id as tool:<tool_name>. Its submitted value is bound by the input's block_id rather than an input: prefix.

Troubleshooting interactions

SymptomLikely cause
Clicking the element does nothingaction_id is not prefixed with tool:, or the element has no value.
The tool is called, but with no / empty argumentsvalue was a raw JSON object instead of a JSON-encoded string, was empty, or wasn't a JSON object. Serialize and escape the quotes.
The whole message renders as plain text instead of blocksTwo elements in the same block share an action_id (see above), a block failed validation, or a block type outside the allowlist was included. Also confirm your app is enabled for Block Kit rendering.
The confirmation dialog shows different text than I wroteExpected: Slack replaces confirm dialog text with its own copy (see restrictions).
The tool runs on the wrong server, or routing fields in value seem ignoredExpected: Slack derives routing at render time and ignores any routing fields in value. Put only tool arguments in value.

Allowed block types

Because Slack renders these blocks on your behalf as Slackbot UI, it accepts only a restricted subset of the full Block Kit surface. Any response containing a block type not in this list, at the top level or nested inside a container block, is rejected in full (no blocks from that response are rendered). The permitted top-level block types are:

Text & layout blocks

Block typeDescription
contextSmall contextual text/images.
dividerHorizontal rule.
headerPlain-text heading.
markdownStandard-markdown convenience input. Converted to rich_text before rendering (see restrictions).
rich_textStructured rich text.
sectionText (mrkdwn/plain_text) with an optional accessory and fields.

Media & resources blocks

Block typeDescription
fileEmbedded remote file reference (source: "remote").
imageImage. Uses an external image_url, or a slack_file the current user can already see (see restrictions).
videoEmbedded video player (thumbnail + provider metadata). video_url must be an embeddable, app-claimed URL.

Interactive blocks

Block typeDescription
actionsRow of interactive elements (buttons, selects). Interactivity is limited to the tool: action_id convention.
carouselSwipeable cards (each an image + title/subtitle/body + link-out button). Nests card children.
inputLabeled input element in a form. See Forms and dynamic inputs.

Tables & charts blocks

Block typeDescription
data_tableRicher tabular content. Interactive (action_cell) cells allowed.
data_visualizationBar/line/area/pie chart. See Charts.
tableRead-only tabular content. Display-only cells; no interactive cells.

Cards & containers blocks

Block typeDescription
cardCard surface: hero image/icon, title/subtitle, body, up to 3 action buttons.
containerTitled, optionally collapsible grouping box. Wraps up to 10 child_blocks.

Any other block type is rejected. When in doubt, section, header, context, and actions cover most layouts.

Charts (data_visualization)

A data_visualization block renders a bar, line, area, or pie chart.

Top-level fields

FieldRequiredTypeNotes
typeRequired"data_visualization"
titleRequiredstringA short label above the chart (1–50 characters)
chartRequiredobjectThe chart payload: one of line, bar, area, or pie.

Don't send preview_images. Slack renders the chart server-side.

Line, bar, and area charts

Line, bar, and area charts have a type, a series array, and an axis_config:

  • series: 1–12 series. Each series has a unique name (1–20 characters) and a data array. Every series must contain exactly one data point per category. Each data point is { "label": string, "value": number } where the label matches a category.
  • axis_config.categories: an array of unique category labels (1–20 items, each 1–20 characters). Optional x_label / y_label (≤50 characters).

Pie charts

Pie charts have type: "pie" and a segments array (1–12 segments). Each segment is { "label": string, "value": number } with a unique label (1–20 characters) and a value greater than 0.

At most two data_visualization blocks may appear in a single response.

Example (a two-series line chart):

{
"type": "data_visualization",
"title": "Quarterly revenue by region ($K)",
"chart": {
"type": "line",
"series": [
{
"name": "North America",
"data": [
{ "label": "Q1", "value": 14200 },
{ "label": "Q2", "value": 15100 },
{ "label": "Q3", "value": 15900 },
{ "label": "Q4", "value": 16700 }
]
},
{
"name": "EMEA",
"data": [
{ "label": "Q1", "value": 10300 },
{ "label": "Q2", "value": 11100 },
{ "label": "Q3", "value": 11800 },
{ "label": "Q4", "value": 12400 }
]
}
],
"axis_config": {
"categories": ["Q1", "Q2", "Q3", "Q4"]
}
}
}

Validation and restrictions

Every response is validated before rendering. If any rule below is violated, the entire _meta.slack.blocks payload is dropped and nothing is rendered (your content text still shows).

  • Block count and nesting depth. At most 50 top-level blocks per response, and container nesting at most 20 levels deep.
  • Allowed types only. See the table above. The type check is recursive: a container block whose children contain a non-allowed type causes the whole payload to be rejected.
  • Confirmation dialogs are replaced. If you attach a confirm dialog to an element, Slack replaces its text with fixed, Slack-authored copy. You cannot control the wording.
  • URLs must be public HTTPS. Every url and image_url must be an https:// URL to a public host. Embedded credentials, non-HTTPS schemes, or private/internal hosts cause rejection. Externally-hosted images must be served as a direct 200 (redirects are not followed).
  • markdown is converted. markdown blocks are expanded into rich_text before rendering.
  • slack_file images are visibility-gated. An image block may reference a Slack-hosted file via slack_file (by id), but only if the user viewing the message can already see that file. Inline data:/base64 image sources are always rejected.
  • Aggregate size. The combined rendered text is subject to the standard maximum message length in Slack.
  • Table character limits. A single table may contain at most 10,000 characters; a single data_table at most 20,000; and all tables in one message at most 20,000 combined. All rows must have the same number of columns.

MCP Apps

MCP Apps are rich, interactive UI experiences returned by MCP servers. Instead of returning plain text that Slackbot renders as a conversational response, your tools can return full interactive interfaces (dashboards, forms, approval flows) that render natively in Slack.

When an MCP tool returns a response containing a _meta.ui.resourceUri field, Slack detects this and renders the UI resource as an interactive block:

  1. A tool is called by Slackbot based on the user's prompt.
  2. The tool returns data plus a _meta.ui.resourceUri pointing to a UI resource.
  3. Slack fetches the UI resource (HTML/JS) from that URI.
  4. The content is rendered as an interactive block in the conversation.

Example response

{
"content": [
{
"type": "text",
"text": "You have 4 pending tasks"
}
],
"_meta": {
"ui": {
"resourceUri": "ui://acme/task-dashboard"
}
}
}

The content field provides a text fallback, while _meta.ui.resourceUri tells Slack where to fetch the interactive UI.

Integration types

Integration typeTool returnsUser experience
Client-only (e.g., tool search, data lookup)Plain data/textConversational text response
Apps-only (e.g., dashboards, analytics)Data + resourceUriRich interactive UI
CombinedEither, depending on contextBoth conversational and rich UI

Your app can support any combination; some tools return plain text while others return interactive UI.

MCP server example

This is an example of an MCP server returning a rich response. The MCP server registers a dice roller tool and serves an interactive HTML resource that renders the result inside Slack. The /mcp route verifies the Slack request signature before forwarding the request to the MCP transport.

The tool uses the readOnlyHint annotation to indicate it doesn't modify any state, and returns structuredContent so the UI resource can render the roll visually.

The examples below use Bolt for JavaScript and Bolt for Python.

ai/slackbot-mcp-client/rich-responses/mcp-apps/src/app.js
loading...

UI resource

When Slackbot invokes the roll_dice tool, it renders this HTML inside an iframe. The page connects to the MCP Apps runtime and displays the structured result visually.

ai/slackbot-mcp-client/rich-responses/mcp-apps/src/dice.html
loading...

Entry point

Start the server on the configured port. Bolt handles the Slack events route automatically, while the custom /mcp route serves your MCP server.

ai/slackbot-mcp-client/no-auth/app.js
loading...