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
2 changes: 1 addition & 1 deletion src/linux_mcp_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class Config(BaseSettings):
command_timeout: int = 30 # Timeout in seconds; prevents hung SSH operations

# Indicate mcp-app compatibility
use_mcp_apps: bool = False
use_mcp_apps: bool | None = None

@property
def effective_known_hosts_path(self) -> Path:
Expand Down
1 change: 1 addition & 0 deletions src/linux_mcp_server/mcp_app.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
RUN_SCRIPT_APP_URI = "ui://run_script_readonly_with_mcp_app/run-script-app.html"
ALLOWED_UI_RESOURCE_URIS = set([RUN_SCRIPT_APP_URI])
MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"
MCP_UI_EXTENSION = "io.modelcontextprotocol/ui"
48 changes: 30 additions & 18 deletions src/linux_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@
from linux_mcp_server.config import Toolset
from linux_mcp_server.mcp_app import ALLOWED_UI_RESOURCE_URIS
from linux_mcp_server.mcp_app import MCP_APP_MIME_TYPE

from linux_mcp_server.config import CONFIG
from linux_mcp_server.mcp_app import MCP_UI_EXTENSION


logger = logging.getLogger("linux-mcp-server")
Expand Down Expand Up @@ -197,22 +196,6 @@ async def _read_resource_with_meta(req: ReadResourceRequest):
from linux_mcp_server.tools import * # noqa: E402, F403


# TODO: Dynamically inject the 'modify' tool based on user compatibility.
#
# This is a temporary implementation. The injection logic should be moved to the
# `on_initialize` handler in `DynamicDiscoveryMiddleware` once Goose starts
# providing `mcp-app` compatibility during the initialize request.
if CONFIG.use_mcp_apps:
mcp.add_tool(run_script_modify_interactive)
mcp.add_tool(get_execution_state)
mcp.add_tool(execute_script)
mcp.add_tool(reject_script)

else:
mcp.add_tool(run_script_modify)


# This middleware can be used to dynamically inject tools based on client side compatibility
class DynamicDiscoveryMiddleware(Middleware):
async def on_list_tools(self, context: MiddlewareContext, call_next):
tools = await call_next(context)
Expand All @@ -222,6 +205,35 @@ async def on_list_tools(self, context: MiddlewareContext, call_next):
# if the app calls tools we don't list at all, so we just filter out the "app" tools
filtered_tools = [t for t in tools if "hidden_from_model" not in (t.tags)]

fastmcp_context = context.fastmcp_context
assert fastmcp_context is not None, (
"FastMCP framework error: context.fastmcp_context should not be None inside on_list_tools"
)

request_ctx = fastmcp_context.request_context
assert request_ctx is not None, (
"FastMCP framework error: request context should not be None inside on_list_tools"
)

client_params = request_ctx.session.client_params
assert client_params is not None, (
"FastMCP framework error: client_params should not be None inside on_list_tools"
)

# 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.
if CONFIG.use_mcp_apps or (CONFIG.use_mcp_apps is None and MCP_APP_MIME_TYPE in mime_types):
filtered_tools = [t for t in filtered_tools if "mcp_apps_exclude" not in t.tags]
else:
filtered_tools = [t for t in filtered_tools if "mcp_apps_only" not in t.tags]

return filtered_tools


Expand Down
79 changes: 32 additions & 47 deletions src/linux_mcp_server/tools/run_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

from fastmcp import Context
from fastmcp.exceptions import ToolError
from fastmcp.tools.tool import Tool
from fastmcp.tools.tool import ToolResult
from mcp.types import ContentBlock
from mcp.types import TextContent
Expand Down Expand Up @@ -286,7 +285,14 @@ class ExecuteScriptResult:
output: str


async def _execute_script(
@mcp.tool(
tags={"run_script", "hidden_from_model"},
description="Execute a script; this is only available to the our mcp-app",
meta={"ui": {"visibility": ["app"]}},
)
@log_tool_call
@disallow_local_execution_in_containers
async def execute_script(
id: t.Annotated[str, Field(description="The associated ID of the script to be executed")],
) -> ToolResult:
script_details = script_store.get_script_details(id)
Expand Down Expand Up @@ -316,33 +322,30 @@ async def _execute_script(
return ToolResult(content=content, structured_content=asdict(result))


execute_script = Tool.from_function(
_execute_script,
name="execute_script",
@mcp.tool(
tags={"run_script", "hidden_from_model"},
description="Execute a script; this is only available to the our mcp-app",
description="Reject a script; this is only available to the our mcp-app",
meta={"ui": {"visibility": ["app"]}},
)


async def _reject_script(
@log_tool_call
@disallow_local_execution_in_containers
async def reject_script(
id: t.Annotated[str, Field(description="The associated ID of the script to be rejected")],
):
script_store.set_script_state(id, "rejected-user")


reject_script = Tool.from_function(
_reject_script,
name="reject_script",
tags={"run_script", "hidden_from_model"},
description="Reject a script; this is only available to the our mcp-app",
meta={"ui": {"visibility": ["app"]}},
@mcp.tool(
tags={"run_script", "mcp_apps_only"},
title="Propose to run a script that modifies system",
description=RUN_SCRIPT_MODIFY_INTERACTIVE,
annotations=ToolAnnotations(destructiveHint=True),
output_schema=RunScriptInteractiveResult.model_json_schema(),
meta={"ui": {"resourceUri": RUN_SCRIPT_APP_URI}},
)


@log_tool_call
@disallow_local_execution_in_containers
async def _run_script_modify_interactive(
async def run_script_modify_interactive(
ctx: Context,
description: t.Annotated[
str,
Expand Down Expand Up @@ -387,21 +390,15 @@ async def _run_script_modify_interactive(
return result


run_script_modify_interactive = Tool.from_function(
_run_script_modify_interactive,
name="run_script_modify_interactive",
tags={"run_script"},
title="Propose to run a script that modifies system",
description=RUN_SCRIPT_MODIFY_INTERACTIVE,
@mcp.tool(
tags={"run_script", "mcp_apps_exclude"},
title="Run script to modify system",
description=RUN_SCRIPT_MODIFY_DESCRIPTION,
annotations=ToolAnnotations(destructiveHint=True),
output_schema=RunScriptInteractiveResult.model_json_schema(),
meta={"ui": {"resourceUri": RUN_SCRIPT_APP_URI}},
)


@log_tool_call
@disallow_local_execution_in_containers
async def _run_script_modify(
async def run_script_modify(
ctx: Context,
description: t.Annotated[
str,
Expand Down Expand Up @@ -454,26 +451,14 @@ async def _run_script_modify(
return f"Error executing script: return code {returncode}, stderr: {stderr}"


run_script_modify = Tool.from_function(
_run_script_modify,
name="run_script_modify",
tags={"run_script"},
title="Run script to modify system",
description=RUN_SCRIPT_MODIFY_DESCRIPTION,
annotations=ToolAnnotations(destructiveHint=True),
)


def _get_execution_state(id: str):
script_detail = script_store.get_script_details(id)
return {"state": script_detail.state}


get_execution_state = Tool.from_function(
_get_execution_state,
name="get_execution_state",
@mcp.tool(
tags={"run_script", "hidden_from_model"},
title="Get the execution state with request ID",
description="Get the execution state with request ID",
meta={"ui": {"visibility": ["app"]}},
)
@log_tool_call
@disallow_local_execution_in_containers
async def get_execution_state(id: str):
script_detail = script_store.get_script_details(id)
return {"state": script_detail.state}