Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/linux_mcp_server/mcp_app.py
Original file line number Diff line number Diff line change
@@ -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
47 changes: 5 additions & 42 deletions src/linux_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions src/linux_mcp_server/tools/run_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down