Skip to content

Usage

Use it with an MCP client

Claude Desktop / Cursor — add to your MCP config (claude_desktop_config.json or .cursor/mcp.json):

{
  "mcpServers": {
    "streamlit-mcp": {
      "command": "uvx",
      "args": ["streamlit-mcp", "serve", "/absolute/path/to/your/app.py"]
    }
  }
}

Claude Code:

claude mcp add streamlit-mcp -- uvx streamlit-mcp serve /absolute/path/to/your/app.py

uvx runs the published package with no prior install. stdio (the default) is the right transport for local clients.

Tools exposed to agents

Tool What it does
list_widgets / get_layout introspect widgets (kind, label, value, constraints)
set_widget(identifier, value) set a widget and rerun
click(identifier) click a button and rerun
read_output() the rendered element tree, agent-readable
get_state() the app's session_state

Widgets and outputs are returned in document/render order (the order the app renders them, recursing through the sidebar and columns), so the layout an agent reads back matches the app.

Supported widgets: text_input, number_input, text_area, slider, select_slider, selectbox, multiselect, checkbox, toggle, radio, button, date_input, time_input, color_picker, pills, segmented_control, feedback. Input widgets streamlit-mcp can't drive (file_uploader, camera_input, audio_input, chat_input, data_editor, …) are reported explicitly on every surface (text --layout, --json, and MCP get_layout), never silently dropped — wherever they're placed, including st.sidebar.file_uploader(...) and inside columns, tabs and containers.

Range widgets

A slider, select_slider or date_input built with a tuple value= is a two-handle range widget: its value is a 2-element list, and it advertises a matching array schema ({"type": "array", "items": …, "minItems": 2, "maxItems": 2}), so send both handles — --set "Price=[20, 80]", set_widget("Dates", ["2026-01-01", "2026-01-31"]). Sending a single value to one (or a list to a single-handle widget) is rejected rather than silently dropped.

Forms

An st.form is driven the way a human drives it: set the fields, then click the form's submit button (by its label, e.g. --click "Submit"). The submit button is reported as a regular clickable button; clicking it commits the form and runs its body.

Atomic writes

Setting a selectbox/radio/select_slider/multiselect to an option that isn't offered (including every handle of a two-handle select_slider range), a number_input/slider/date_input outside its min/max (including either end of a two-date range), a value of the wrong arity (a single value for a two-handle range widget, or a list for a single-handle one), a fractional value for an integer number_input, or a color_picker to anything but a #RGB/#RRGGBB hex string, is rejected before any state changes — with a clear error listing the valid choices/range/format. An unparseable number, date, or time value is likewise rejected with a clear message. A failed set_widget leaves the session usable and never silently mutates state.

Option types

A widget built from non-string options (st.selectbox("Pick", [1, 2, 3])) reports its value in the real type (1) while constraints.options lists the string form Streamlit displays (["1", "2", "3"]) — that's what Streamlit's own testing API exposes. Both forms are accepted: set_widget("Pick", 2) and set_widget("Pick", "2") both select the real option 2, and the advertised schema says so — its enum carries both ([1, 2, 3, "1", "2", "3"]), so the reported value is always a member of its own schema and any option can be set in either form.

The value's type is the only evidence of the real option type available at runtime (Streamlit hands over options already stringified), so a widget with nothing selected — an untouched st.multiselect("Nums", [1, 2, 3]) — advertises the string form alone. That form is always settable, so the round-trip still works.

Selection widgets (pills, segmented_control) and feedback

A pills/segmented_control is single- or multi-select depending on its selection_mode, and Streamlit exposes no flag for which — the value's shape is the signal, as it is for a range widget. A single-select holds a bare option (or null before anything is chosen) and advertises the option itself; a multi-select holds a list ([] when empty) and advertises an array. Sending a bare option to a multi-select means "select just this one", as with multiselect; a multi-select is cleared with [].

Once a selection is made it cannot be cleared with null — Streamlit's testing API silently ignores that, so set_widget(id, null) is rejected rather than reported as a success that never happened. Select a different option, or send [] on a multi-select.

An st.feedback rating is an index into its scalethumbs is 0-1, faces and stars are 0-4 — and it advertises those bounds. Out-of-range ratings are rejected up front because the testing API neither raises nor reverts them; it stores them.

Placeholder (null) widgets

A widget built with value=None / index=None — a text_input/text_area/number_input/ date_input/time_input empty-field placeholder, or a "please select…" selectbox/radio — starts with no value: it reports value: null and advertises a nullable schema (e.g. {"type": ["string","null"]}). Setting it to null (set_widget(id, null)) returns it to that no-value state, so the value round-trips and a "reset this field/filter" flow works. A regular widget of the same kind (with a real default) has no no-value state and rejects null — atomically, leaving its value intact. (Over the CLI, JSON-typed values go through --set's JSON parse, so null reaches an option/number widget; a text field takes --set verbatim per #43, so send its null over MCP or clear it another way.)

App exceptions

If the served app raises an uncaught exception, it's captured in the exception field and surfaced on every read surface — --json, MCP read_output/get_layout, and the text CLI (call/inspect print an exception: line). By default this is a reported field, not a failure (exit 0, mirroring MCP's isError=False); pass --strict to call/inspect to exit non-zero when the app raised, for CI and scripts. (A guardrail or load error is a real failure and exits non-zero regardless.)

Custom semantic tools

Beyond the per-widget tools, expose a higher-level named action by decorating a function with @mcp_tool in your app file:

import streamlit as st
from streamlit_mcp import mcp_tool

@mcp_tool
def reset_all():
    """Reset everything to defaults."""
    return {"ok": True}

st.text_input("Name")

streamlit-mcp serve app.py loads the app and exposes reset_all over MCP alongside the widget tools. The decorated function is called directly (it isn't a widget), so keep it self-contained.

The CLI reaches it too (human ↔ agent parity): inspect lists registered tools, and call --tool invokes one:

streamlit-mcp inspect app.py                 # ...lists reset_all under "tools:"
streamlit-mcp call app.py --tool reset_all   # invoke it; --arg key=value passes arguments

Drive it from the terminal

The CLI calls the same engine an agent uses over MCP, so behavior matches exactly:

streamlit-mcp inspect app.py                 # print the widgets
streamlit-mcp inspect app.py --layout --json # full layout (widgets, outputs, state, unsupported)
streamlit-mcp call app.py --set "Name=agent" --click "Save" --read
streamlit-mcp call app.py --read-only --set "Name=x"   # guardrail: blocked, exit 1
streamlit-mcp call app.py --allow "Name" --set "Age=5"  # allow-list: 'Age' blocked, exit 1
streamlit-mcp --version

A --set value is JSON-parsed for typed widgets (so Age=5 is a number, Tags=["a","b"] a list, Agree=true a boolean), but for a text_input/text_area it's stored literally — so Comment=true is the string "true", not the boolean — matching what the same value stores over MCP.