Skip to content

Commit 2fbc786

Browse files
committed
refactor: improve execution tool guidance for script execution
This commit refactors MCP app detection and enhances the tool feedback loop to guide clients toward the correct execution tools. Key changes: * Extracted `_use_mcp_app_for_client` from `server.py` into a public `use_mcp_app_for_client` utility in `mcp_app.py` for cross-module reuse. * Added a `_pick_execution_tool` helper in `run_script.py` to dynamically determine the right tool (`run_script`, `run_script_interactive`, or `run_script_with_confirmation`) based on script flags (readonly, needs_confirmation) and client capabilities. * Updated `validate_script` to explicitly instruct the client on which execution tool to use next in its text response. * Improved the error message in `run_script` to dynamically suggest the appropriate confirmation tool if the script requires confirmation but was called incorrectly. * Updated the middleware tests for adapting to the change from this patch.
1 parent 1d5556d commit 2fbc786

4 files changed

Lines changed: 74 additions & 35 deletions

File tree

src/linux_mcp_server/mcp_app.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,27 @@
1+
from mcp.types import InitializeRequestParams
2+
3+
from linux_mcp_server.config import CONFIG
4+
5+
16
RUN_SCRIPT_APP_URI = "ui://run_script_readonly_with_mcp_app/run-script-app.html"
27
ALLOWED_UI_RESOURCE_URIS = set([RUN_SCRIPT_APP_URI])
38
MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"
49
MCP_UI_EXTENSION = "io.modelcontextprotocol/ui"
10+
11+
12+
def use_mcp_app_for_client(client_params: InitializeRequestParams):
13+
# The configuration can overwrite the MCP app support detection, so we have the flexibility to
14+
# manually turn the Mcp app feature on/off for developing/testing purposes.
15+
if CONFIG.use_mcp_apps is not None:
16+
return CONFIG.use_mcp_apps
17+
18+
# For python-sdk -1.x, count on extensibility of protocol types - while this is being
19+
# removed for v2, hopefully extensions will be there properly.
20+
capabilities = client_params.capabilities
21+
extensions = getattr(capabilities, "extensions", {})
22+
mcp_ui_extension = extensions.get(MCP_UI_EXTENSION) or {}
23+
mime_types = mcp_ui_extension.get("mimeTypes") or []
24+
25+
# The configuration can overwrite the MCP app support detection, so we have the flexibility to
26+
# manually turn the Mcp app feature on/off for developing/testing purposes.
27+
return MCP_APP_MIME_TYPE in mime_types

src/linux_mcp_server/server.py

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from mcp import ServerSession
1616
from mcp.types import BlobResourceContents
1717
from mcp.types import InitializeRequest
18-
from mcp.types import InitializeRequestParams
1918
from mcp.types import InitializeResult
2019
from mcp.types import ReadResourceRequest
2120
from mcp.types import ReadResourceResult
@@ -28,7 +27,7 @@
2827
from linux_mcp_server.config import Toolset
2928
from linux_mcp_server.mcp_app import ALLOWED_UI_RESOURCE_URIS
3029
from linux_mcp_server.mcp_app import MCP_APP_MIME_TYPE
31-
from linux_mcp_server.mcp_app import MCP_UI_EXTENSION
30+
from linux_mcp_server.mcp_app import use_mcp_app_for_client
3231

3332

3433
logger = logging.getLogger("linux-mcp-server")
@@ -222,24 +221,6 @@ async def _read_resource_with_meta(req: ReadResourceRequest):
222221
from linux_mcp_server.tools import * # noqa: E402, F403
223222

224223

225-
def _use_mcp_app_for_client(client_params: InitializeRequestParams):
226-
# The configuration can overwrite the MCP app support detection, so we have the flexibility to
227-
# manually turn the Mcp app feature on/off for developing/testing purposes.
228-
if CONFIG.use_mcp_apps is not None:
229-
return CONFIG.use_mcp_apps
230-
231-
# For python-sdk -1.x, count on extensibility of protocol types - while this is being
232-
# removed for v2, hopefully extensions will be there properly.
233-
capabilities = client_params.capabilities
234-
extensions = getattr(capabilities, "extensions", {})
235-
mcp_ui_extension = extensions.get(MCP_UI_EXTENSION) or {}
236-
mime_types = mcp_ui_extension.get("mimeTypes") or []
237-
238-
# The configuration can overwrite the MCP app support detection, so we have the flexibility to
239-
# manually turn the Mcp app feature on/off for developing/testing purposes.
240-
return MCP_APP_MIME_TYPE in mime_types
241-
242-
243224
class DynamicDiscoveryMiddleware(Middleware):
244225
async def on_initialize(
245226
self,
@@ -253,7 +234,7 @@ async def on_initialize(
253234
# away in the ServerSession object, so we need to modify that based
254235
# on whether we'll use mcp-apps with the client making the InitializeRequest.
255236

256-
if _use_mcp_app_for_client(context.message.params):
237+
if use_mcp_app_for_client(context.message.params):
257238
# Getting the ServerSession object is easy for FastMCP 3.x - it's
258239
# just context.fastcmp_context.session, but the property getter
259240
# will raise RuntimeError for FastMCP 2.x, so we check _session instead.
@@ -304,7 +285,7 @@ async def on_list_tools(self, context: MiddlewareContext, call_next):
304285
"FastMCP framework error: client_params should not be None inside on_list_tools"
305286
)
306287

307-
if _use_mcp_app_for_client(client_params):
288+
if use_mcp_app_for_client(client_params):
308289
filtered_tools = [t for t in filtered_tools if "mcp_apps_exclude" not in t.tags]
309290
else:
310291
filtered_tools = [t for t in filtered_tools if "mcp_apps_only" not in t.tags]

src/linux_mcp_server/tools/run_script.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from linux_mcp_server.gatekeeper import check_run_script
2424
from linux_mcp_server.gatekeeper import GatekeeperStatus
2525
from linux_mcp_server.mcp_app import RUN_SCRIPT_APP_URI
26+
from linux_mcp_server.mcp_app import use_mcp_app_for_client
2627
from linux_mcp_server.server import mcp
2728
from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers
2829
from linux_mcp_server.utils.types import Host
@@ -368,6 +369,16 @@ async def get_execution_state(id: str):
368369
return {"state": script_detail.state}
369370

370371

372+
def _pick_execution_tool(readonly: bool, needs_confirmation: bool, use_mcp_app: bool):
373+
if not needs_confirmation and readonly:
374+
return "run_script"
375+
376+
if use_mcp_app:
377+
return "run_script_interactive"
378+
else:
379+
return "run_script_with_confirmation"
380+
381+
371382
@mcp.tool(
372383
tags={"run_script"},
373384
title="Validate a script",
@@ -409,8 +420,20 @@ async def validate_script(
409420
script_store.set_script_state(id, "rejected-gatekeeper")
410421
raise ToolError(gatekeeper_result.description)
411422

423+
client_params = ctx.session.client_params
424+
assert client_params is not None, "FastMCP framework error: client_params should not be None inside tool"
425+
426+
execution_tool = _pick_execution_tool(
427+
readonly, script_details.needs_confirmation, use_mcp_app_for_client(client_params)
428+
)
429+
412430
result = ToolResult(
413-
content=[TextContent(type="text", text=f"Script passed gatekeeper validation and is stored with ID {id}")],
431+
content=[
432+
TextContent(
433+
type="text",
434+
text=f"Script passed gatekeeper validation and is stored with ID {id}. Please use {execution_tool} to execute the validated script.",
435+
)
436+
],
414437
structured_content={
415438
"token": id,
416439
"needs_confirmation": script_details.needs_confirmation,
@@ -433,9 +456,17 @@ async def run_script(
433456
) -> str:
434457
script_details = script_store.get_script_details(token)
435458

459+
client_params = ctx.session.client_params
460+
assert client_params is not None, "FastMCP framework error: client_params should not be None inside tool"
461+
462+
if use_mcp_app_for_client(client_params):
463+
confirmation_tool_name = "run_script_interactive"
464+
else:
465+
confirmation_tool_name = "run_script_with_confirmation"
466+
436467
# Verify that this script doesn't require confirmation
437468
if script_details.needs_confirmation:
438-
raise ToolError("This script requires confirmation. Use run_script_with_confirmation instead of run_script.")
469+
raise ToolError(f"This script requires confirmation. Use {confirmation_tool_name} instead of run_script.")
439470

440471
script_store.set_script_state(token, "executing")
441472
try:

tests/test_middleware.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,39 @@
88
from mcp.types import InitializeRequestParams
99
from mcp.types import Tool
1010

11+
from linux_mcp_server.mcp_app import MCP_APP_MIME_TYPE
12+
from linux_mcp_server.mcp_app import MCP_UI_EXTENSION
13+
1114

1215
# Workaround: with python-3.10, mocker.patch("linux_mcp_server.server.X")
1316
# doesn't work because it finds the imported server function rather than the module.
1417
server_module = importlib.import_module("linux_mcp_server.server")
18+
mcp_app_module = importlib.import_module("linux_mcp_server.mcp_app")
1519

1620

1721
class TestUseMcpAppForClient:
1822
@pytest.mark.parametrize("config_value,expected", [(True, True), (False, False)])
1923
def test_config_override(self, mocker, config_value, expected):
2024
# Test CONFIG.use_mcp_apps override
21-
mocker.patch.object(server_module, "CONFIG", use_mcp_apps=config_value)
22-
result = server_module._use_mcp_app_for_client(mocker.Mock(spec=InitializeRequestParams))
25+
mocker.patch.object(mcp_app_module, "CONFIG", use_mcp_apps=config_value)
26+
result = server_module.use_mcp_app_for_client(mocker.Mock(spec=InitializeRequestParams))
2327
assert result is expected
2428

2529
@pytest.mark.parametrize(
2630
"extensions,expected",
2731
[
28-
({server_module.MCP_UI_EXTENSION: {"mimeTypes": [server_module.MCP_APP_MIME_TYPE]}}, True),
32+
({MCP_UI_EXTENSION: {"mimeTypes": [MCP_APP_MIME_TYPE]}}, True),
2933
({"other-extension": {}}, False),
3034
],
3135
)
3236
def test_detection(self, mocker, extensions, expected):
3337
# Test mcp-app detection from client capabilities
34-
mocker.patch.object(server_module, "CONFIG", use_mcp_apps=None)
38+
mocker.patch.object(mcp_app_module, "CONFIG", use_mcp_apps=None)
3539
capabilities = mocker.Mock()
3640
capabilities.extensions = extensions
3741
params = mocker.Mock(spec=InitializeRequestParams)
3842
params.capabilities = capabilities
39-
result = server_module._use_mcp_app_for_client(params)
43+
result = server_module.use_mcp_app_for_client(params)
4044
assert result is expected
4145

4246

@@ -56,7 +60,7 @@ def mock_context(self, mocker):
5660

5761
async def test_skips_modification_when_disabled(self, middleware, mock_context, mocker):
5862
# Test that instructions are not modified when mcp-apps is disabled
59-
mocker.patch.object(server_module, "_use_mcp_app_for_client", return_value=False)
63+
mocker.patch.object(server_module, "use_mcp_app_for_client", return_value=False)
6064

6165
mock_session = mocker.Mock(spec=ServerSession)
6266
mock_session._init_options = mocker.Mock()
@@ -69,7 +73,7 @@ async def test_skips_modification_when_disabled(self, middleware, mock_context,
6973

7074
async def test_modifies_instructions_fastmcp_3x(self, middleware, mock_context, mocker):
7175
# Test instruction modification with FastMCP 3.x
72-
mocker.patch.object(server_module, "_use_mcp_app_for_client", return_value=True)
76+
mocker.patch.object(server_module, "use_mcp_app_for_client", return_value=True)
7377

7478
mock_session = mocker.Mock(spec=ServerSession)
7579
mock_session._init_options = mocker.Mock()
@@ -82,7 +86,7 @@ async def test_modifies_instructions_fastmcp_3x(self, middleware, mock_context,
8286

8387
async def test_modifies_instructions_fastmcp_2x(self, middleware, mock_context, mocker):
8488
# Test instruction modification with FastMCP 2.x via closure extraction
85-
mocker.patch.object(server_module, "_use_mcp_app_for_client", return_value=True)
89+
mocker.patch.object(server_module, "use_mcp_app_for_client", return_value=True)
8690
mock_context.fastmcp_context._session = None
8791

8892
mock_session = mocker.Mock(spec=ServerSession)
@@ -104,7 +108,7 @@ async def call_next_func(_ctx):
104108

105109
async def test_handles_extraction_failure(self, middleware, mock_context, mocker):
106110
# Test graceful handling when session extraction fails
107-
mocker.patch.object(server_module, "_use_mcp_app_for_client", return_value=True)
111+
mocker.patch.object(server_module, "use_mcp_app_for_client", return_value=True)
108112
mock_logger = mocker.patch.object(server_module, "logger")
109113

110114
mock_context.fastmcp_context._session = None
@@ -148,7 +152,7 @@ def _make_tool(self, mocker, name: str, tags: set[str] | None = None) -> Tool:
148152

149153
async def test_filters_when_mcp_apps_enabled(self, middleware, mock_context, mocker):
150154
# Test that hidden_from_model and mcp_apps_exclude are filtered when enabled
151-
mocker.patch.object(server_module, "_use_mcp_app_for_client", return_value=True)
155+
mocker.patch.object(server_module, "use_mcp_app_for_client", return_value=True)
152156

153157
tools = [
154158
self._make_tool(mocker, "regular", set()),
@@ -165,7 +169,7 @@ async def test_filters_when_mcp_apps_enabled(self, middleware, mock_context, moc
165169

166170
async def test_filters_when_mcp_apps_disabled(self, middleware, mock_context, mocker):
167171
# Test that hidden_from_model and mcp_apps_only are filtered when disabled
168-
mocker.patch.object(server_module, "_use_mcp_app_for_client", return_value=False)
172+
mocker.patch.object(server_module, "use_mcp_app_for_client", return_value=False)
169173

170174
tools = [
171175
self._make_tool(mocker, "regular", set()),

0 commit comments

Comments
 (0)