diff --git a/src/linux_mcp_server/mcp_app.py b/src/linux_mcp_server/mcp_app.py index 5b970fb0..85ceac22 100644 --- a/src/linux_mcp_server/mcp_app.py +++ b/src/linux_mcp_server/mcp_app.py @@ -1,3 +1,57 @@ +from fastmcp.server.dependencies import get_context +from mcp.types import InitializeRequestParams + +from linux_mcp_server.config import CONFIG + + RUN_SCRIPT_APP_URI = "ui://run_script_readonly_with_mcp_app/run-script-app.html" MCP_APP_MIME_TYPE = "text/html;profile=mcp-app" MCP_UI_EXTENSION = "io.modelcontextprotocol/ui" + + +def use_mcp_app_for_client(client_params: InitializeRequestParams | None = None): + if client_params is None: + client_params = get_context().session.client_params + + assert client_params is not None, ( + "FastMCP framework error: client_params should not be None after `initialize` is done" + ) + # The configuration can overwrite the MCP app support detection, so we have the flexibility to + # manually turn the Mcp app feature on/off for developing/testing purposes. + if CONFIG.use_mcp_apps is not None: + return CONFIG.use_mcp_apps + + # For python-sdk -1.x, count on extensibility of protocol types - while this is being + # removed for v2, hopefully extensions will be there properly. + capabilities = client_params.capabilities + extensions = getattr(capabilities, "extensions", {}) + mcp_ui_extension = extensions.get(MCP_UI_EXTENSION) or {} + mime_types = mcp_ui_extension.get("mimeTypes") or [] + + # The configuration can overwrite the MCP app support detection, so we have the flexibility to + # manually turn the Mcp app feature on/off for developing/testing purposes. + return MCP_APP_MIME_TYPE in mime_types + + +def hide_app_tools_for_client(client_params: InitializeRequestParams | None = None): + # Versions of goose before 1.29.0 don't understand _meta.ui.visiblity, so would + # leak our app-only tools to the model. However, they also are happy to let the + # model call tools that aren't listed as well. So if we see such an old version + # of goose, we strip out the app-only tools. + if client_params is None: + client_params = get_context().session.client_params + + assert client_params is not None, ( + "FastMCP framework error: client_params should not be None after `initialize` is done" + ) + + client_info = getattr(client_params, "clientInfo", None) + if client_info and client_info.name and client_info.name.startswith("goose"): + try: + major, minor = client_info.version.split(".")[0:2] + if (int(major), int(minor)) < (1, 29): + return True + except ValueError: + return False + + return False diff --git a/src/linux_mcp_server/server.py b/src/linux_mcp_server/server.py index 3d3d5ffa..a0d35cc5 100644 --- a/src/linux_mcp_server/server.py +++ b/src/linux_mcp_server/server.py @@ -18,7 +18,6 @@ from fastmcp.server.middleware.middleware import CallNext from fastmcp.utilities.components import FastMCPComponent from mcp.types import InitializeRequest -from mcp.types import InitializeRequestParams from mcp.types import InitializeResult import linux_mcp_server @@ -29,9 +28,10 @@ from linux_mcp_server.config import CONFIG from linux_mcp_server.config import Toolset from linux_mcp_server.config import Transport +from linux_mcp_server.mcp_app import hide_app_tools_for_client from linux_mcp_server.mcp_app import MCP_APP_MIME_TYPE -from linux_mcp_server.mcp_app import MCP_UI_EXTENSION from linux_mcp_server.mcp_app import RUN_SCRIPT_APP_URI +from linux_mcp_server.mcp_app import use_mcp_app_for_client from linux_mcp_server.toolset import get_toolset from linux_mcp_server.toolset import Toolset as ToolsetInfo @@ -227,41 +227,6 @@ def run_script_app_html() -> ResourceResult: from linux_mcp_server.tools import * # noqa: E402, F403 -def _use_mcp_app_for_client(client_params: InitializeRequestParams): - # The configuration can overwrite the MCP app support detection, so we have the flexibility to - # manually turn the Mcp app feature on/off for developing/testing purposes. - if CONFIG.use_mcp_apps is not None: - return CONFIG.use_mcp_apps - - # For python-sdk -1.x, count on extensibility of protocol types - while this is being - # removed for v2, hopefully extensions will be there properly. - capabilities = client_params.capabilities - extensions = getattr(capabilities, "extensions", {}) - mcp_ui_extension = extensions.get(MCP_UI_EXTENSION) or {} - mime_types = mcp_ui_extension.get("mimeTypes") or [] - - # The configuration can overwrite the MCP app support detection, so we have the flexibility to - # manually turn the Mcp app feature on/off for developing/testing purposes. - return MCP_APP_MIME_TYPE in mime_types - - -def _hide_app_tools_for_client(client_params: InitializeRequestParams): - # Versions of goose before 1.29.0 don't understand _meta.ui.visiblity, so would - # leak our app-only tools to the model. However, they also are happy to let the - # model call tools that aren't listed as well. So if we see such an old version - # of goose, we strip out the app-only tools. - client_info = getattr(client_params, "clientInfo", None) - if client_info and client_info.name and client_info.name.startswith("goose"): - try: - major, minor = client_info.version.split(".")[0:2] - if (int(major), int(minor)) < (1, 29): - return True - except ValueError: - return False - - return False - - @dataclass class ComponentFilter: """ @@ -291,10 +256,8 @@ def includes(self, component: FastMCPComponent): @staticmethod def get(context: Context, *, is_list_tools=False): - client_params = context.session.client_params - assert client_params is not None - mcp_apps = _use_mcp_app_for_client(client_params) - hide_app_tools = mcp_apps and is_list_tools and _hide_app_tools_for_client(client_params) + mcp_apps = use_mcp_app_for_client() + hide_app_tools = mcp_apps and is_list_tools and hide_app_tools_for_client() return ComponentFilter( mcp_apps=mcp_apps, @@ -401,7 +364,7 @@ async def on_initialize( instructions = _get_instructions() toolset = _current_toolset() - if "run_script" in toolset.tags and _use_mcp_app_for_client(context.message.params): + if "run_script" in toolset.tags and use_mcp_app_for_client(context.message.params): instructions = instructions.replace("run_script_with_confirmation", "run_script_interactive") session._init_options.instructions = instructions diff --git a/src/linux_mcp_server/tools/run_script.py b/src/linux_mcp_server/tools/run_script.py index 4993cb50..dc2e660b 100644 --- a/src/linux_mcp_server/tools/run_script.py +++ b/src/linux_mcp_server/tools/run_script.py @@ -24,6 +24,7 @@ from linux_mcp_server.gatekeeper import check_run_script from linux_mcp_server.gatekeeper import GatekeeperStatus from linux_mcp_server.mcp_app import RUN_SCRIPT_APP_URI +from linux_mcp_server.mcp_app import use_mcp_app_for_client from linux_mcp_server.server import mcp from linux_mcp_server.utils.decorators import disallow_local_execution_in_containers from linux_mcp_server.utils.types import Host @@ -369,6 +370,15 @@ async def get_execution_state(id: str): return {"state": script_detail.state} +def _pick_execution_tool(needs_confirmation: bool): + if not needs_confirmation: + return "run_script" + elif use_mcp_app_for_client(): + return "run_script_interactive" + else: + return "run_script_with_confirmation" + + @mcp.tool( tags={"run_script"}, title="Validate a script", @@ -411,7 +421,12 @@ async def validate_script( raise ToolError(gatekeeper_result.description) result = ToolResult( - content=[TextContent(type="text", text=f"Script passed gatekeeper validation and is stored with ID {id}")], + content=[ + TextContent( + type="text", + 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.", + ) + ], structured_content={ "token": id, "needs_confirmation": script_details.needs_confirmation, @@ -436,7 +451,7 @@ async def run_script( # Verify that this script doesn't require confirmation if script_details.needs_confirmation: - raise ToolError("This script requires confirmation. Use run_script_with_confirmation instead of run_script.") + raise ToolError(f"This script requires confirmation. Use {_pick_execution_tool(True)} instead of run_script.") script_store.set_script_state(token, "executing") try: