Skip to content

Commit 867d26b

Browse files
authored
refactor: improve execution tool guidance for script execution (#431)
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 flag (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.
1 parent 15c0723 commit 867d26b

3 files changed

Lines changed: 76 additions & 44 deletions

File tree

src/linux_mcp_server/mcp_app.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,57 @@
1+
from fastmcp.server.dependencies import get_context
2+
from mcp.types import InitializeRequestParams
3+
4+
from linux_mcp_server.config import CONFIG
5+
6+
17
RUN_SCRIPT_APP_URI = "ui://run_script_readonly_with_mcp_app/run-script-app.html"
28
MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"
39
MCP_UI_EXTENSION = "io.modelcontextprotocol/ui"
10+
11+
12+
def use_mcp_app_for_client(client_params: InitializeRequestParams | None = None):
13+
if client_params is None:
14+
client_params = get_context().session.client_params
15+
16+
assert client_params is not None, (
17+
"FastMCP framework error: client_params should not be None after `initialize` is done"
18+
)
19+
# The configuration can overwrite the MCP app support detection, so we have the flexibility to
20+
# manually turn the Mcp app feature on/off for developing/testing purposes.
21+
if CONFIG.use_mcp_apps is not None:
22+
return CONFIG.use_mcp_apps
23+
24+
# For python-sdk -1.x, count on extensibility of protocol types - while this is being
25+
# removed for v2, hopefully extensions will be there properly.
26+
capabilities = client_params.capabilities
27+
extensions = getattr(capabilities, "extensions", {})
28+
mcp_ui_extension = extensions.get(MCP_UI_EXTENSION) or {}
29+
mime_types = mcp_ui_extension.get("mimeTypes") or []
30+
31+
# The configuration can overwrite the MCP app support detection, so we have the flexibility to
32+
# manually turn the Mcp app feature on/off for developing/testing purposes.
33+
return MCP_APP_MIME_TYPE in mime_types
34+
35+
36+
def hide_app_tools_for_client(client_params: InitializeRequestParams | None = None):
37+
# Versions of goose before 1.29.0 don't understand _meta.ui.visiblity, so would
38+
# leak our app-only tools to the model. However, they also are happy to let the
39+
# model call tools that aren't listed as well. So if we see such an old version
40+
# of goose, we strip out the app-only tools.
41+
if client_params is None:
42+
client_params = get_context().session.client_params
43+
44+
assert client_params is not None, (
45+
"FastMCP framework error: client_params should not be None after `initialize` is done"
46+
)
47+
48+
client_info = getattr(client_params, "clientInfo", None)
49+
if client_info and client_info.name and client_info.name.startswith("goose"):
50+
try:
51+
major, minor = client_info.version.split(".")[0:2]
52+
if (int(major), int(minor)) < (1, 29):
53+
return True
54+
except ValueError:
55+
return False
56+
57+
return False

src/linux_mcp_server/server.py

Lines changed: 5 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from fastmcp.server.middleware.middleware import CallNext
1919
from fastmcp.utilities.components import FastMCPComponent
2020
from mcp.types import InitializeRequest
21-
from mcp.types import InitializeRequestParams
2221
from mcp.types import InitializeResult
2322

2423
import linux_mcp_server
@@ -29,9 +28,10 @@
2928
from linux_mcp_server.config import CONFIG
3029
from linux_mcp_server.config import Toolset
3130
from linux_mcp_server.config import Transport
31+
from linux_mcp_server.mcp_app import hide_app_tools_for_client
3232
from linux_mcp_server.mcp_app import MCP_APP_MIME_TYPE
33-
from linux_mcp_server.mcp_app import MCP_UI_EXTENSION
3433
from linux_mcp_server.mcp_app import RUN_SCRIPT_APP_URI
34+
from linux_mcp_server.mcp_app import use_mcp_app_for_client
3535
from linux_mcp_server.toolset import get_toolset
3636
from linux_mcp_server.toolset import Toolset as ToolsetInfo
3737

@@ -227,41 +227,6 @@ def run_script_app_html() -> ResourceResult:
227227
from linux_mcp_server.tools import * # noqa: E402, F403
228228

229229

230-
def _use_mcp_app_for_client(client_params: InitializeRequestParams):
231-
# The configuration can overwrite the MCP app support detection, so we have the flexibility to
232-
# manually turn the Mcp app feature on/off for developing/testing purposes.
233-
if CONFIG.use_mcp_apps is not None:
234-
return CONFIG.use_mcp_apps
235-
236-
# For python-sdk -1.x, count on extensibility of protocol types - while this is being
237-
# removed for v2, hopefully extensions will be there properly.
238-
capabilities = client_params.capabilities
239-
extensions = getattr(capabilities, "extensions", {})
240-
mcp_ui_extension = extensions.get(MCP_UI_EXTENSION) or {}
241-
mime_types = mcp_ui_extension.get("mimeTypes") or []
242-
243-
# The configuration can overwrite the MCP app support detection, so we have the flexibility to
244-
# manually turn the Mcp app feature on/off for developing/testing purposes.
245-
return MCP_APP_MIME_TYPE in mime_types
246-
247-
248-
def _hide_app_tools_for_client(client_params: InitializeRequestParams):
249-
# Versions of goose before 1.29.0 don't understand _meta.ui.visiblity, so would
250-
# leak our app-only tools to the model. However, they also are happy to let the
251-
# model call tools that aren't listed as well. So if we see such an old version
252-
# of goose, we strip out the app-only tools.
253-
client_info = getattr(client_params, "clientInfo", None)
254-
if client_info and client_info.name and client_info.name.startswith("goose"):
255-
try:
256-
major, minor = client_info.version.split(".")[0:2]
257-
if (int(major), int(minor)) < (1, 29):
258-
return True
259-
except ValueError:
260-
return False
261-
262-
return False
263-
264-
265230
@dataclass
266231
class ComponentFilter:
267232
"""
@@ -291,10 +256,8 @@ def includes(self, component: FastMCPComponent):
291256

292257
@staticmethod
293258
def get(context: Context, *, is_list_tools=False):
294-
client_params = context.session.client_params
295-
assert client_params is not None
296-
mcp_apps = _use_mcp_app_for_client(client_params)
297-
hide_app_tools = mcp_apps and is_list_tools and _hide_app_tools_for_client(client_params)
259+
mcp_apps = use_mcp_app_for_client()
260+
hide_app_tools = mcp_apps and is_list_tools and hide_app_tools_for_client()
298261

299262
return ComponentFilter(
300263
mcp_apps=mcp_apps,
@@ -401,7 +364,7 @@ async def on_initialize(
401364
instructions = _get_instructions()
402365

403366
toolset = _current_toolset()
404-
if "run_script" in toolset.tags and _use_mcp_app_for_client(context.message.params):
367+
if "run_script" in toolset.tags and use_mcp_app_for_client(context.message.params):
405368
instructions = instructions.replace("run_script_with_confirmation", "run_script_interactive")
406369

407370
session._init_options.instructions = instructions

src/linux_mcp_server/tools/run_script.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from linux_mcp_server.gatekeeper import check_run_script
2525
from linux_mcp_server.gatekeeper import GatekeeperStatus
2626
from linux_mcp_server.mcp_app import RUN_SCRIPT_APP_URI
27+
from linux_mcp_server.mcp_app import use_mcp_app_for_client
2728
from linux_mcp_server.server import mcp
2829
from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers
2930
from linux_mcp_server.utils.types import Host
@@ -369,6 +370,15 @@ async def get_execution_state(id: str):
369370
return {"state": script_detail.state}
370371

371372

373+
def _pick_execution_tool(needs_confirmation: bool):
374+
if not needs_confirmation:
375+
return "run_script"
376+
elif use_mcp_app_for_client():
377+
return "run_script_interactive"
378+
else:
379+
return "run_script_with_confirmation"
380+
381+
372382
@mcp.tool(
373383
tags={"run_script"},
374384
title="Validate a script",
@@ -411,7 +421,12 @@ async def validate_script(
411421
raise ToolError(gatekeeper_result.description)
412422

413423
result = ToolResult(
414-
content=[TextContent(type="text", text=f"Script passed gatekeeper validation and is stored with ID {id}")],
424+
content=[
425+
TextContent(
426+
type="text",
427+
text=f"Script passed gatekeeper validation and is stored with ID {id}. Please use {_pick_execution_tool(script_details.needs_confirmation)} to execute the validated script.",
428+
)
429+
],
415430
structured_content={
416431
"token": id,
417432
"needs_confirmation": script_details.needs_confirmation,
@@ -436,7 +451,7 @@ async def run_script(
436451

437452
# Verify that this script doesn't require confirmation
438453
if script_details.needs_confirmation:
439-
raise ToolError("This script requires confirmation. Use run_script_with_confirmation instead of run_script.")
454+
raise ToolError(f"This script requires confirmation. Use {_pick_execution_tool(True)} instead of run_script.")
440455

441456
script_store.set_script_state(token, "executing")
442457
try:

0 commit comments

Comments
 (0)