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 objectBlock 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
actionsblock: the uniqueness scope is per-block, so the sametool:action_idis 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_buttonsorstatic_selectelement carries oneaction_id, and each option carries its ownvalue. 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
| Symptom | Likely cause |
|---|---|
| Clicking the element does nothing | action_id is not prefixed with tool:, or the element has no value. |
The tool is called, but with no / empty arguments | value 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 blocks | Two 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 wrote | Expected: Slack replaces confirm dialog text with its own copy (see restrictions). |
The tool runs on the wrong server, or routing fields in value seem ignored | Expected: 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 type | Description |
|---|---|
context | Small contextual text/images. |
divider | Horizontal rule. |
header | Plain-text heading. |
markdown | Standard-markdown convenience input. Converted to rich_text before rendering (see restrictions). |
rich_text | Structured rich text. |
section | Text (mrkdwn/plain_text) with an optional accessory and fields. |
Media & resources blocks
| Block type | Description |
|---|---|
file | Embedded remote file reference (source: "remote"). |
image | Image. Uses an external image_url, or a slack_file the current user can already see (see restrictions). |
video | Embedded video player (thumbnail + provider metadata). video_url must be an embeddable, app-claimed URL. |
Interactive blocks
| Block type | Description |
|---|---|
actions | Row of interactive elements (buttons, selects). Interactivity is limited to the tool: action_id convention. |
carousel | Swipeable cards (each an image + title/subtitle/body + link-out button). Nests card children. |
input | Labeled input element in a form. See Forms and dynamic inputs. |
Tables & charts blocks
| Block type | Description |
|---|---|
data_table | Richer tabular content. Interactive (action_cell) cells allowed. |
data_visualization | Bar/line/area/pie chart. See Charts. |
table | Read-only tabular content. Display-only cells; no interactive cells. |
Cards & containers blocks
| Block type | Description |
|---|---|
card | Card surface: hero image/icon, title/subtitle, body, up to 3 action buttons. |
container | Titled, 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
| Field | Required | Type | Notes |
|---|---|---|---|
type | Required | "data_visualization" | |
title | Required | string | A short label above the chart (1–50 characters) |
chart | Required | object | The 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 uniquename(1–20 characters) and adataarray. 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). Optionalx_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
confirmdialog to an element, Slack replaces its text with fixed, Slack-authored copy. You cannot control the wording. - URLs must be public HTTPS. Every
urlandimage_urlmust be anhttps://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). markdownis converted.markdownblocks are expanded intorich_textbefore rendering.slack_fileimages are visibility-gated. Animageblock may reference a Slack-hosted file viaslack_file(byid), but only if the user viewing the message can already see that file. Inlinedata:/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
tablemay contain at most 10,000 characters; a singledata_tableat 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:
- A tool is called by Slackbot based on the user's prompt.
- The tool returns data plus a
_meta.ui.resourceUripointing to a UI resource. - Slack fetches the UI resource (HTML/JS) from that URI.
- 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 type | Tool returns | User experience |
|---|---|---|
| Client-only (e.g., tool search, data lookup) | Plain data/text | Conversational text response |
| Apps-only (e.g., dashboards, analytics) | Data + resourceUri | Rich interactive UI |
| Combined | Either, depending on context | Both 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.
- JavaScript
- Python
loading...
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.
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.
- JavaScript
- Python
loading...
loading...