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 scale — thumbs 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.