streamlit-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ # Normalize line endings: store LF in the repo, check out native on each platform.
2
+ * text=auto eol=lf
3
+ *.png binary
4
+ *.jpg binary
5
+ *.gif binary
@@ -0,0 +1,27 @@
1
+ name: release
2
+
3
+ # Tag a release (e.g. `git tag v0.1.0 && git push --tags`) to build and publish to PyPI.
4
+ # Publishing uses PyPI Trusted Publishing (OIDC) — no API token stored in the repo.
5
+ # One-time setup on pypi.org: add a Trusted Publisher for project `streamlit-mcp` with
6
+ # owner `dkedar7`, repository `streamlit-mcp`, workflow `release.yml`, environment `pypi`.
7
+
8
+ on:
9
+ push:
10
+ tags: ["v*"]
11
+
12
+ jobs:
13
+ release:
14
+ runs-on: ubuntu-latest
15
+ environment: pypi
16
+ permissions:
17
+ id-token: write # required for Trusted Publishing (OIDC)
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - name: Install uv
21
+ uses: astral-sh/setup-uv@v5
22
+ - name: Run tests
23
+ run: uv run --with pytest pytest -q
24
+ - name: Build
25
+ run: uv build
26
+ - name: Publish to PyPI (Trusted Publishing)
27
+ run: uv publish
@@ -0,0 +1,22 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - name: Install uv
18
+ uses: astral-sh/setup-uv@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - name: Run tests
22
+ run: uv run --with pytest pytest -q
@@ -0,0 +1,16 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+ .venv/
9
+ venv/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+
14
+ # Secrets / local
15
+ .env
16
+ *.local
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ First release. Serve an existing Streamlit app as an MCP server, driven headlessly via
6
+ `streamlit.testing.v1.AppTest` — no browser automation.
7
+
8
+ - Auto-introspect all ten v1 widget kinds (text_input, number_input, text_area, slider,
9
+ selectbox, multiselect, checkbox, radio, button, date_input) into MCP tools.
10
+ - Core MCP tools: `list_widgets`, `get_layout`, `set_widget`, `click`, `read_output`,
11
+ `get_state`. Unsupported elements are reported explicitly.
12
+ - Transports: stdio and HTTP/SSE (see Known issues for HTTP auth status).
13
+ - Human-first CLI (`serve`/`inspect`/`call`) with parity to the MCP tools.
14
+ - `@mcp_tool` decorator for opt-in semantic tools.
15
+ - Guardrails: read-only mode and widget allow-list (enforced on both CLI and MCP).
16
+
17
+ ### Known issues / immediate follow-ups
18
+ - **HTTP bearer auth is not yet enforced on the transport.** The token primitive
19
+ (`Guardrails.require_bearer`) is implemented and tested, but is not yet bound to the
20
+ FastMCP HTTP/SSE request path. As a safeguard, `serve` refuses to start an HTTP/SSE
21
+ server on a non-loopback host. Wiring `FastMCP(auth=...)` with a token verifier is the
22
+ top follow-up before networked HTTP is supported.
23
+ - **Sessions are not yet disposed.** Per-client isolation works, but there is no
24
+ session-close hook, so long-running HTTP servers accumulate runtimes. Single-client and
25
+ stdio use are unaffected.
26
+ - **No concurrency locking.** Concurrent requests sharing one session are not serialized;
27
+ AppTest is not known to be re-entrant. Use one in-flight request per session for now.
28
+ - Output capture covers headings/markdown/caption/text; `st.write`/`st.error`/etc. are a
29
+ planned coverage expansion.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kedar Dabhadkar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: streamlit-mcp
3
+ Version: 0.1.0
4
+ Summary: Serve an existing Streamlit app as an MCP server — agents drive it natively, no browser.
5
+ Project-URL: Homepage, https://github.com/dkedar7/streamlit-mcp
6
+ Project-URL: Source, https://github.com/dkedar7/streamlit-mcp
7
+ Project-URL: Bug Tracker, https://github.com/dkedar7/streamlit-mcp/issues
8
+ Project-URL: Changelog, https://github.com/dkedar7/streamlit-mcp/blob/main/CHANGELOG.md
9
+ Author: Kedar Dabhadkar
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 Kedar Dabhadkar
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: agent,automation,llm,mcp,model-context-protocol,streamlit
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Topic :: Software Development :: Libraries
40
+ Requires-Python: >=3.10
41
+ Requires-Dist: fastmcp<4,>=3
42
+ Requires-Dist: streamlit>=1.58
43
+ Provides-Extra: dev
44
+ Requires-Dist: pytest>=7; extra == 'dev'
45
+ Description-Content-Type: text/markdown
46
+
47
+ # streamlit-mcp
48
+
49
+ **Serve any Streamlit app as an MCP server.** Agents introspect your app's widgets,
50
+ set values, click buttons, and read the rendered output and `session_state` — natively,
51
+ over MCP, with **no browser automation**.
52
+
53
+ ```bash
54
+ uv tool install . # or: pip install .
55
+
56
+ # serve an app over MCP (stdio for local clients, or HTTP/SSE for networked agents)
57
+ streamlit-mcp serve app.py
58
+ streamlit-mcp serve app.py --transport http --host 127.0.0.1 --port 8000
59
+
60
+ # drive it yourself from the terminal (same engine the agent uses)
61
+ streamlit-mcp inspect app.py
62
+ streamlit-mcp call app.py --set "Name=agent" --click "Save" --read
63
+ ```
64
+
65
+ Streamlit has no callback graph — it reruns the whole script per interaction — so
66
+ streamlit-mcp drives the app headlessly through Streamlit's own test runtime
67
+ (`streamlit.testing.v1.AppTest`) and returns the **semantic element tree**, not pixels.
68
+ Gradio and Dash already shipped native app-as-MCP; this fills the Streamlit gap.
69
+
70
+ ## Tools exposed to agents
71
+
72
+ | Tool | What it does |
73
+ |---|---|
74
+ | `list_widgets` / `get_layout` | introspect widgets (kind, label, value, constraints) |
75
+ | `set_widget(identifier, value)` | set a widget and rerun |
76
+ | `click(identifier)` | click a button and rerun |
77
+ | `read_output()` | the rendered element tree, agent-readable |
78
+ | `get_state()` | the app's `session_state` |
79
+
80
+ Supported widgets (v1): text_input, number_input, text_area, slider, selectbox,
81
+ multiselect, checkbox, radio, button, date_input. Unsupported elements (file_uploader,
82
+ custom components, `st.chat`, fragments) are reported explicitly, never silently dropped.
83
+
84
+ ## Human ↔ agent parity
85
+
86
+ Everything an agent can do over MCP, a human can do via the CLI — both call the same
87
+ engine. The read-only mode and widget allow-list guardrails apply identically to both
88
+ surfaces.
89
+
90
+ ## Security / trust model
91
+
92
+ - **`app_path` is executed as trusted code** in the server process (that's how AppTest
93
+ runs it). Only serve apps you trust.
94
+ - **`get_state` / `read_output` expose the app's `session_state`** to the caller — do not
95
+ put secrets there.
96
+ - **HTTP/SSE is loopback-only in v1.** A bearer-token primitive exists, but it is **not yet
97
+ enforced** on the transport, so `serve` refuses to bind HTTP/SSE to a non-loopback host.
98
+ Use stdio for local clients, or HTTP/SSE on `127.0.0.1`. Enforced networked auth is the
99
+ top follow-up (see `CHANGELOG.md`).
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,57 @@
1
+ # streamlit-mcp
2
+
3
+ **Serve any Streamlit app as an MCP server.** Agents introspect your app's widgets,
4
+ set values, click buttons, and read the rendered output and `session_state` — natively,
5
+ over MCP, with **no browser automation**.
6
+
7
+ ```bash
8
+ uv tool install . # or: pip install .
9
+
10
+ # serve an app over MCP (stdio for local clients, or HTTP/SSE for networked agents)
11
+ streamlit-mcp serve app.py
12
+ streamlit-mcp serve app.py --transport http --host 127.0.0.1 --port 8000
13
+
14
+ # drive it yourself from the terminal (same engine the agent uses)
15
+ streamlit-mcp inspect app.py
16
+ streamlit-mcp call app.py --set "Name=agent" --click "Save" --read
17
+ ```
18
+
19
+ Streamlit has no callback graph — it reruns the whole script per interaction — so
20
+ streamlit-mcp drives the app headlessly through Streamlit's own test runtime
21
+ (`streamlit.testing.v1.AppTest`) and returns the **semantic element tree**, not pixels.
22
+ Gradio and Dash already shipped native app-as-MCP; this fills the Streamlit gap.
23
+
24
+ ## Tools exposed to agents
25
+
26
+ | Tool | What it does |
27
+ |---|---|
28
+ | `list_widgets` / `get_layout` | introspect widgets (kind, label, value, constraints) |
29
+ | `set_widget(identifier, value)` | set a widget and rerun |
30
+ | `click(identifier)` | click a button and rerun |
31
+ | `read_output()` | the rendered element tree, agent-readable |
32
+ | `get_state()` | the app's `session_state` |
33
+
34
+ Supported widgets (v1): text_input, number_input, text_area, slider, selectbox,
35
+ multiselect, checkbox, radio, button, date_input. Unsupported elements (file_uploader,
36
+ custom components, `st.chat`, fragments) are reported explicitly, never silently dropped.
37
+
38
+ ## Human ↔ agent parity
39
+
40
+ Everything an agent can do over MCP, a human can do via the CLI — both call the same
41
+ engine. The read-only mode and widget allow-list guardrails apply identically to both
42
+ surfaces.
43
+
44
+ ## Security / trust model
45
+
46
+ - **`app_path` is executed as trusted code** in the server process (that's how AppTest
47
+ runs it). Only serve apps you trust.
48
+ - **`get_state` / `read_output` expose the app's `session_state`** to the caller — do not
49
+ put secrets there.
50
+ - **HTTP/SSE is loopback-only in v1.** A bearer-token primitive exists, but it is **not yet
51
+ enforced** on the transport, so `serve` refuses to bind HTTP/SSE to a non-loopback host.
52
+ Use stdio for local clients, or HTTP/SSE on `127.0.0.1`. Enforced networked auth is the
53
+ top follow-up (see `CHANGELOG.md`).
54
+
55
+ ## License
56
+
57
+ MIT
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "streamlit-mcp"
3
+ version = "0.1.0"
4
+ description = "Serve an existing Streamlit app as an MCP server — agents drive it natively, no browser."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { file = "LICENSE" }
8
+ authors = [{ name = "Kedar Dabhadkar" }]
9
+ keywords = ["streamlit", "mcp", "model-context-protocol", "agent", "llm", "automation"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Software Development :: Libraries",
18
+ ]
19
+ dependencies = [
20
+ "streamlit>=1.58",
21
+ "fastmcp>=3,<4",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/dkedar7/streamlit-mcp"
26
+ Source = "https://github.com/dkedar7/streamlit-mcp"
27
+ "Bug Tracker" = "https://github.com/dkedar7/streamlit-mcp/issues"
28
+ Changelog = "https://github.com/dkedar7/streamlit-mcp/blob/main/CHANGELOG.md"
29
+
30
+ [project.scripts]
31
+ streamlit-mcp = "streamlit_mcp.cli:_cli"
32
+
33
+ [project.optional-dependencies]
34
+ dev = ["pytest>=7"]
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/streamlit_mcp"]
42
+
43
+ [tool.pytest.ini_options]
44
+ pythonpath = ["src"]
45
+ testpaths = ["tests"]
@@ -0,0 +1,29 @@
1
+ """streamlit-mcp — serve a Streamlit app as an MCP server, no browser.
2
+
3
+ The app is driven headlessly through Streamlit's AppTest runtime (behind a Runtime
4
+ interface); widgets auto-map to MCP tools, and a human-first CLI exercises the same
5
+ engine an agent uses (parity).
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ # Public API
11
+ from .decorator import mcp_tool # noqa: E402
12
+ from .engine import Engine, PermissionDenied # noqa: E402
13
+ from .guardrails import Guardrails # noqa: E402
14
+ from .runtime import AppTestRuntime, Runtime, RuntimeError_, WidgetNotFound # noqa: E402
15
+ from .server import build_server, serve # noqa: E402
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "mcp_tool",
20
+ "Engine",
21
+ "PermissionDenied",
22
+ "Guardrails",
23
+ "AppTestRuntime",
24
+ "Runtime",
25
+ "RuntimeError_",
26
+ "WidgetNotFound",
27
+ "build_server",
28
+ "serve",
29
+ ]
@@ -0,0 +1,180 @@
1
+ """streamlit-mcp CLI — the human-first surface.
2
+
3
+ `serve` launches the MCP server; `inspect`/`call` let a human drive the app from the
4
+ terminal. All three go through the same engine an agent uses over MCP, so parity (R2)
5
+ holds by construction. Guardrail flags apply identically to the CLI and the server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from typing import Any, Optional
14
+
15
+ from .engine import Engine
16
+ from .guardrails import Guardrails
17
+ from .runtime import AppTestRuntime
18
+
19
+
20
+ def _force_utf8_output() -> None:
21
+ for stream in (sys.stdout, sys.stderr):
22
+ try:
23
+ stream.reconfigure(encoding="utf-8", errors="replace")
24
+ except Exception:
25
+ pass
26
+
27
+
28
+ def build_guardrails(args: argparse.Namespace) -> Optional[Guardrails]:
29
+ read_only = getattr(args, "read_only", False)
30
+ allow = getattr(args, "allow", None)
31
+ bearer = getattr(args, "bearer_token", None)
32
+ if not read_only and not allow and not bearer:
33
+ return None
34
+ return Guardrails(
35
+ read_only=read_only,
36
+ allow_list=set(allow) if allow else None,
37
+ bearer_token=bearer,
38
+ )
39
+
40
+
41
+ def _engine(args: argparse.Namespace) -> Engine:
42
+ rt = AppTestRuntime(args.app)
43
+ rt.run()
44
+ return Engine(rt, guard=build_guardrails(args), app_path=args.app)
45
+
46
+
47
+ def _parse_value(raw: str) -> Any:
48
+ """Parse a --set value: JSON when possible (true/41/["a"]), else a plain string."""
49
+ try:
50
+ return json.loads(raw)
51
+ except (json.JSONDecodeError, ValueError):
52
+ return raw
53
+
54
+
55
+ def _split_assignment(item: str) -> tuple[str, Any]:
56
+ if "=" not in item:
57
+ raise ValueError(f"--set expects 'identifier=value', got {item!r}")
58
+ identifier, raw = item.split("=", 1)
59
+ return identifier.strip(), _parse_value(raw)
60
+
61
+
62
+ # --------------------------------------------------------------------- commands
63
+ def cmd_serve(args: argparse.Namespace) -> int:
64
+ from .server import serve
65
+ try:
66
+ serve(
67
+ args.app,
68
+ transport=args.transport,
69
+ host=args.host,
70
+ port=args.port,
71
+ guard=build_guardrails(args),
72
+ )
73
+ except ValueError as e: # fail-closed / bad transport -> clean message, not a traceback
74
+ print(str(e), file=sys.stderr)
75
+ return 1
76
+ return 0
77
+
78
+
79
+ def cmd_inspect(args: argparse.Namespace) -> int:
80
+ eng = _engine(args)
81
+ out = eng.get_layout() if args.layout else eng.list_widgets()
82
+ if args.json:
83
+ print(json.dumps(out, indent=2, default=str))
84
+ return 0
85
+ for w in out["widgets"]:
86
+ flag = " [action]" if w.get("action") else ""
87
+ print(f" {w['kind']:<13} {w['identifier']:<14} = {w['value']!r}{flag}")
88
+ if args.layout:
89
+ for o in out.get("outputs", []):
90
+ print(f" [{o['kind']}] {o['text']}")
91
+ state = out.get("session_state") or {}
92
+ if state:
93
+ print(" session_state:")
94
+ for k, v in state.items():
95
+ print(f" {k} = {v!r}")
96
+ return 0
97
+
98
+
99
+ def cmd_call(args: argparse.Namespace) -> int:
100
+ try:
101
+ eng = _engine(args)
102
+ for item in args.set or []:
103
+ ident, value = _split_assignment(item)
104
+ eng.set_widget(ident, value)
105
+ for ident in args.click or []:
106
+ eng.click(ident)
107
+ result = eng.get_state() if args.state else eng.read_output()
108
+ except Exception as e: # CLI: surface any failure as a clean message + exit 1
109
+ print(str(e), file=sys.stderr)
110
+ return 1
111
+ if args.json:
112
+ print(json.dumps(result, indent=2, default=str))
113
+ return 0
114
+ if args.state:
115
+ for k, v in result.items():
116
+ print(f" {k} = {v!r}")
117
+ else:
118
+ for o in result.get("outputs", []):
119
+ print(f" [{o['kind']}] {o['text']}")
120
+ return 0
121
+
122
+
123
+ # --------------------------------------------------------------------- parser
124
+ def _add_guard_flags(p: argparse.ArgumentParser, *, bearer: bool) -> None:
125
+ p.add_argument("--read-only", dest="read_only", action="store_true",
126
+ help="block state-changing actions")
127
+ p.add_argument("--allow", action="append",
128
+ help="allow-list a widget identifier (repeatable)")
129
+ if bearer:
130
+ p.add_argument("--bearer-token", dest="bearer_token",
131
+ help="require this bearer token on HTTP/SSE")
132
+
133
+
134
+ def build_parser() -> argparse.ArgumentParser:
135
+ parser = argparse.ArgumentParser(prog="streamlit-mcp",
136
+ description="Serve a Streamlit app as an MCP server.")
137
+ sub = parser.add_subparsers(dest="command", required=True)
138
+
139
+ p_serve = sub.add_parser("serve", help="serve an app over MCP")
140
+ p_serve.add_argument("app")
141
+ p_serve.add_argument("--transport", choices=["stdio", "http", "sse"], default="stdio")
142
+ p_serve.add_argument("--host", default="127.0.0.1")
143
+ p_serve.add_argument("--port", type=int, default=8000)
144
+ _add_guard_flags(p_serve, bearer=True)
145
+ p_serve.set_defaults(func=cmd_serve)
146
+
147
+ p_inspect = sub.add_parser("inspect", help="print the app's widgets")
148
+ p_inspect.add_argument("app")
149
+ p_inspect.add_argument("--layout", action="store_true", help="full layout (outputs, state, unsupported)")
150
+ p_inspect.add_argument("--json", action="store_true")
151
+ _add_guard_flags(p_inspect, bearer=False)
152
+ p_inspect.set_defaults(func=cmd_inspect)
153
+
154
+ p_call = sub.add_parser("call", help="drive the app (same engine agents use)")
155
+ p_call.add_argument("app")
156
+ p_call.add_argument("--set", action="append", help="identifier=value (repeatable)")
157
+ p_call.add_argument("--click", action="append", help="button identifier (repeatable)")
158
+ p_call.add_argument("--read", action="store_true", help="print rendered output (default)")
159
+ p_call.add_argument("--state", action="store_true", help="print session_state instead")
160
+ p_call.add_argument("--json", action="store_true")
161
+ _add_guard_flags(p_call, bearer=False)
162
+ p_call.set_defaults(func=cmd_call)
163
+
164
+ return parser
165
+
166
+
167
+ def main(argv: Optional[list[str]] = None) -> int:
168
+ _force_utf8_output()
169
+ args = build_parser().parse_args(argv)
170
+ return args.func(args)
171
+
172
+
173
+ def _cli() -> None:
174
+ """Console-script entry point. Propagates the exit code — entry-point shims call the
175
+ target and ignore its return value, so returning from main() would always exit 0."""
176
+ raise SystemExit(main())
177
+
178
+
179
+ if __name__ == "__main__":
180
+ _cli()
@@ -0,0 +1,70 @@
1
+ """Opt-in semantic tools: expose a developer-chosen function as a clean MCP tool.
2
+
3
+ The auto path (widget tools) needs no app changes. When a developer wants higher-level,
4
+ named actions, they decorate a function with ``@mcp_tool`` and it is registered alongside
5
+ the auto-generated widget tools (origin R8). Names must not collide with the core tools or
6
+ with each other (AE5 reporting).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Callable, Optional
13
+
14
+ # Mirror of server.TOOL_NAMES, kept here to avoid a server<->decorator import cycle.
15
+ RESERVED_NAMES = (
16
+ "list_widgets",
17
+ "get_layout",
18
+ "set_widget",
19
+ "click",
20
+ "read_output",
21
+ "get_state",
22
+ )
23
+
24
+
25
+ @dataclass
26
+ class SemanticToolSpec:
27
+ name: str
28
+ description: str
29
+ func: Callable
30
+
31
+
32
+ _REGISTRY: dict[str, SemanticToolSpec] = {}
33
+
34
+
35
+ def mcp_tool(func: Optional[Callable] = None, *, name: Optional[str] = None,
36
+ description: Optional[str] = None):
37
+ """Register ``func`` as a semantic MCP tool. Usable bare or with arguments::
38
+
39
+ @mcp_tool
40
+ def reset(): ...
41
+
42
+ @mcp_tool(name="reset_all", description="Reset everything")
43
+ def reset(): ...
44
+ """
45
+
46
+ def register(fn: Callable) -> Callable:
47
+ tool_name = name or fn.__name__
48
+ if tool_name in RESERVED_NAMES:
49
+ raise ValueError(f"semantic tool name {tool_name!r} collides with a core tool")
50
+ if tool_name in _REGISTRY:
51
+ raise ValueError(f"semantic tool name {tool_name!r} is already registered")
52
+ _REGISTRY[tool_name] = SemanticToolSpec(
53
+ name=tool_name,
54
+ description=description or (fn.__doc__ or "").strip(),
55
+ func=fn,
56
+ )
57
+ return fn
58
+
59
+ if func is not None: # used as @mcp_tool
60
+ return register(func)
61
+ return register # used as @mcp_tool(...)
62
+
63
+
64
+ def registered_semantic_tools() -> list[SemanticToolSpec]:
65
+ return list(_REGISTRY.values())
66
+
67
+
68
+ def clear_registry() -> None:
69
+ """Reset the registry (test isolation)."""
70
+ _REGISTRY.clear()