diff --git a/src/linux_mcp_server/config.py b/src/linux_mcp_server/config.py index 54e2bc02..2490355d 100644 --- a/src/linux_mcp_server/config.py +++ b/src/linux_mcp_server/config.py @@ -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: diff --git a/src/linux_mcp_server/mcp_app.py b/src/linux_mcp_server/mcp_app.py index ee969226..ccbe428f 100644 --- a/src/linux_mcp_server/mcp_app.py +++ b/src/linux_mcp_server/mcp_app.py @@ -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" diff --git a/src/linux_mcp_server/server.py b/src/linux_mcp_server/server.py index ce164508..0ed4e161 100644 --- a/src/linux_mcp_server/server.py +++ b/src/linux_mcp_server/server.py @@ -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") @@ -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) @@ -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 diff --git a/src/linux_mcp_server/tools/run_script.py b/src/linux_mcp_server/tools/run_script.py index 61398f56..e239fcb3 100644 --- a/src/linux_mcp_server/tools/run_script.py +++ b/src/linux_mcp_server/tools/run_script.py @@ -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 @@ -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) @@ -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, @@ -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, @@ -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}