Skip to content

Commit c74f0dc

Browse files
authored
deps: Port to FastMCP 3.2 (#452)
Upgrade FastMCP to >= 3.2.4. Changes: * Use built-in support for mcp-apps in FastMCP to simplify app and resource handling. * Remove awful workarounds to get access to to session from on_initialize() middleware. * Use mcp.get_tool() rather than mcp.get_tools() There is a bug in FastMCP 3.2.4 where visibility=["app"] tools are not listed or callable - monkeypatch FastMCP to temporarily work around that. (Fix at PrefectHQ/fastmcp#4112)
1 parent de5f42a commit c74f0dc

7 files changed

Lines changed: 237 additions & 374 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ requires-python = ">=3.10"
1616
# no longer getting security fixes.
1717
dependencies = [
1818
"asyncssh[bcrypt] >= 2.22.0",
19-
"fastmcp >=2.14.4, <2.14.6",
19+
"fastmcp >= 3.2.4",
2020
"litellm>=1.80.16",
2121
"pydantic-settings >= 2.12.0",
2222
"pydantic >= 2.12.5",

src/linux_mcp_server/mcp_app.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
RUN_SCRIPT_APP_URI = "ui://run_script_readonly_with_mcp_app/run-script-app.html"
2-
ALLOWED_UI_RESOURCE_URIS = set([RUN_SCRIPT_APP_URI])
32
MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"
43
MCP_UI_EXTENSION = "io.modelcontextprotocol/ui"

src/linux_mcp_server/server.py

Lines changed: 62 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,17 @@
55

66
from importlib import resources
77
from pathlib import Path
8-
from types import CellType
9-
from types import CodeType
108

119
from fastmcp import FastMCP
10+
from fastmcp.resources import ResourceContent
11+
from fastmcp.resources import ResourceResult
1212
from fastmcp.server.dependencies import get_access_token
1313
from fastmcp.server.middleware import Middleware
1414
from fastmcp.server.middleware import MiddlewareContext
1515
from fastmcp.server.middleware.middleware import CallNext
16-
from mcp import ServerSession
17-
from mcp.types import BlobResourceContents
1816
from mcp.types import InitializeRequest
1917
from mcp.types import InitializeRequestParams
2018
from mcp.types import InitializeResult
21-
from mcp.types import ReadResourceRequest
22-
from mcp.types import ReadResourceResult
23-
from mcp.types import ServerResult
24-
from mcp.types import TextResourceContents
2519

2620
import linux_mcp_server
2721

@@ -31,12 +25,26 @@
3125
from linux_mcp_server.config import CONFIG
3226
from linux_mcp_server.config import Toolset
3327
from linux_mcp_server.config import Transport
34-
from linux_mcp_server.mcp_app import ALLOWED_UI_RESOURCE_URIS
3528
from linux_mcp_server.mcp_app import MCP_APP_MIME_TYPE
3629
from linux_mcp_server.mcp_app import MCP_UI_EXTENSION
30+
from linux_mcp_server.mcp_app import RUN_SCRIPT_APP_URI
3731
from linux_mcp_server.toolset import get_toolset
3832

3933

34+
def monkeypatch_fastmcp_for_app_visibility():
35+
# fastmcp 3.2.4 has a bug where tools defined with
36+
# visibility=["app"] aren't returned by tools/list
37+
# https://github.com/PrefectHQ/fastmcp/issues/4088
38+
# https://github.com/PrefectHQ/fastmcp/pull/4112
39+
import fastmcp.server.server as m
40+
41+
if hasattr(m, "_is_model_visible"):
42+
m._is_model_visible = lambda _tool: True
43+
44+
45+
monkeypatch_fastmcp_for_app_visibility()
46+
47+
4048
logger = logging.getLogger("linux-mcp-server")
4149

4250
INSTRUCTIONS_FIXED = """You have access to predefined commands that inspect the system. They run standard Linux utilities and return formatted results.
@@ -158,81 +166,55 @@
158166
toolset = get_toolset(CONFIG.toolset.value)
159167
assert toolset is not None, f"Toolset not found in registry: {CONFIG.toolset}"
160168

161-
kwargs = {}
162-
if toolset.include_tags:
163-
kwargs["include_tags"] = toolset.include_tags
164-
if toolset.exclude_tags:
165-
kwargs["exclude_tags"] = toolset.exclude_tags
166-
167169
if CONFIG.toolset != Toolset.FIXED and CONFIG.gatekeeper_model is None:
168170
logger.error("LINUX_MCP_GATEKEEPER_MODEL not set, this is needed for run_script tools")
169171
sys.exit(1)
170172

171173
# Create auth provider if configured
172174
auth_provider = create_auth_provider()
173175

174-
mcp = FastMCP(
175-
"linux-mcp-server", instructions=instructions, version=linux_mcp_server.__version__, auth=auth_provider, **kwargs
176-
)
177-
176+
mcp = FastMCP("linux-mcp-server", instructions=instructions, version=linux_mcp_server.__version__, auth=auth_provider)
178177

179-
_low_level_server = mcp._mcp_server
180-
_original_resource_request_handler = _low_level_server.request_handlers[ReadResourceRequest]
181-
182-
183-
async def _read_resource_with_meta(req: ReadResourceRequest):
184-
uri = str(req.params.uri)
185-
fallback_contents: list[TextResourceContents | BlobResourceContents] = [
186-
TextResourceContents(uri=req.params.uri, mimeType="text/plain", text="Resource not found")
187-
]
188-
189-
if uri.startswith("ui://"):
190-
if uri in ALLOWED_UI_RESOURCE_URIS:
191-
filename = uri.split("/")[-1]
192-
193-
# Try ui_resources first (wheel install)
194-
ui_resources_path = resources.files(linux_mcp_server).joinpath("ui_resources")
195-
resource_file = ui_resources_path.joinpath(filename)
196-
logger.debug(f"Checking for UI resource at: {resource_file}")
197-
198-
# Check if we need to fall back to mcp-app/dist (editable install)
199-
if not resource_file.is_file():
200-
package_path = Path(linux_mcp_server.__file__).parent
201-
repo_root = package_path.parent.parent
202-
mcp_app_dist = repo_root / "mcp-app" / "dist" / filename
203-
logger.debug(f"Checking for UI resource at: {mcp_app_dist}")
204-
205-
if mcp_app_dist.exists():
206-
resource_file = mcp_app_dist
207-
else:
208-
logger.error(f"UI resource not found: {filename}")
209-
raise FileNotFoundError(f"Resource {filename} not found")
210-
211-
# Read the file
212-
try:
213-
html = resource_file.read_text()
214-
logger.info(f"Serving UI resource from: {resource_file}")
215-
except Exception as e:
216-
logger.error(f"Failed to read UI resource from {resource_file}: {e}")
217-
raise
218-
219-
content = TextResourceContents.model_validate(
220-
{
221-
"uri": uri,
222-
"mimeType": MCP_APP_MIME_TYPE,
223-
"text": html,
224-
}
225-
)
178+
if toolset.include_tags:
179+
mcp.enable(tags=toolset.include_tags, only=True)
180+
else:
181+
mcp.enable()
226182

227-
return ServerResult(ReadResourceResult(contents=[content]))
228-
else:
229-
if _original_resource_request_handler:
230-
return await _original_resource_request_handler(req)
183+
if toolset.exclude_tags:
184+
mcp.disable(tags=toolset.exclude_tags)
231185

232-
return ServerResult(ReadResourceResult(contents=fallback_contents))
233186

187+
@mcp.resource(
188+
RUN_SCRIPT_APP_URI,
189+
tags={"run_script"},
190+
)
191+
def run_script_app_html() -> ResourceResult:
192+
filename = "run-script-app.html"
193+
194+
# Try ui_resources first (wheel install)
195+
ui_resources_path = resources.files(linux_mcp_server).joinpath("ui_resources")
196+
resource_file = ui_resources_path.joinpath(filename)
197+
logger.debug(f"Checking for UI resource at: {resource_file}")
198+
# Check if we need to fall back to mcp-app/dist (editable install)
199+
if not resource_file.is_file():
200+
package_path = Path(linux_mcp_server.__file__).parent
201+
repo_root = package_path.parent.parent
202+
mcp_app_dist = repo_root / "mcp-app" / "dist" / filename
203+
logger.debug(f"Checking for UI resource at: {mcp_app_dist}")
204+
if mcp_app_dist.exists():
205+
resource_file = mcp_app_dist
206+
else:
207+
logger.error(f"UI resource not found: {filename}")
208+
raise FileNotFoundError(f"Resource {filename} not found")
209+
# Read the file
210+
try:
211+
html = resource_file.read_text()
212+
logger.info(f"Serving UI resource from: {resource_file}")
213+
except Exception as e:
214+
logger.error(f"Failed to read UI resource from {resource_file}: {e}")
215+
raise
234216

235-
_low_level_server.request_handlers[ReadResourceRequest] = _read_resource_with_meta
217+
return ResourceResult(contents=[ResourceContent(html, mime_type=MCP_APP_MIME_TYPE)])
236218

237219

238220
from linux_mcp_server.tools import * # noqa: E402, F403
@@ -334,31 +316,15 @@ async def on_initialize(
334316
# away in the ServerSession object, so we need to modify that based
335317
# on whether we'll use mcp-apps with the client making the InitializeRequest.
336318

319+
assert context.fastmcp_context
320+
session = context.fastmcp_context.session
321+
337322
if _use_mcp_app_for_client(context.message.params):
338-
# Getting the ServerSession object is easy for FastMCP 3.x - it's
339-
# just context.fastcmp_context.session, but the property getter
340-
# will raise RuntimeError for FastMCP 2.x, so we check _session instead.
341-
assert context.fastmcp_context is not None, "fastmcp_context should be set in on_initialize"
342-
session: ServerSession | None = getattr(context.fastmcp_context, "_session", None)
343-
if session is None:
344-
# FastMCP 2.x - let's pull out the hacks! call_next is a closure within a method
345-
# of fastmcp.server.low_level.MiddlewareServerSession. The "self" variable used
346-
# in the closure is what we need. Assuming CPython, we can dig and and get it!
347-
code: CodeType | None = getattr(call_next, "__code__", None)
348-
closure: tuple[CellType, ...] | None = getattr(call_next, "__closure__", None)
349-
if code and closure:
350-
# co_freevars gives us the names of the variables captured in __closure__
351-
closure_dict = dict(zip(code.co_freevars, [c.cell_contents for c in closure]))
352-
session = closure_dict.get("self")
353-
354-
if session and isinstance(session, ServerSession):
355-
instructions = session._init_options.instructions
356-
if instructions:
357-
session._init_options.instructions = instructions.replace(
358-
"run_script_with_confirmation", "run_script_interactive"
359-
)
360-
else:
361-
logger.warning("Unable to get ServerSession to update instructions for mcp-apps")
323+
instructions = session._init_options.instructions
324+
if instructions:
325+
session._init_options.instructions = instructions.replace(
326+
"run_script_with_confirmation", "run_script_interactive"
327+
)
362328

363329
return await call_next(context)
364330

src/linux_mcp_server/tools/run_script.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
from dataclasses import dataclass
88

99
from fastmcp import Context
10+
from fastmcp.apps import AppConfig
1011
from fastmcp.exceptions import ToolError
11-
from fastmcp.tools.tool import ToolResult
12+
from fastmcp.tools import ToolResult
1213
from mcp.types import ContentBlock
1314
from mcp.types import TextContent
1415
from mcp.types import ToolAnnotations
@@ -234,7 +235,7 @@ class ExecuteScriptResult:
234235
@mcp.tool(
235236
tags={"run_script", "hidden_from_model"},
236237
description="Execute a script; this is only available to the our mcp-app",
237-
meta={"ui": {"visibility": ["app"]}},
238+
app=AppConfig(visibility=["app"]),
238239
)
239240
@log_tool_call
240241
@disallow_local_execution_in_containers
@@ -271,7 +272,7 @@ async def execute_script(
271272
@mcp.tool(
272273
tags={"run_script", "hidden_from_model"},
273274
description="Reject a script; this is only available to the our mcp-app",
274-
meta={"ui": {"visibility": ["app"]}},
275+
app=AppConfig(visibility=["app"]),
275276
)
276277
@log_tool_call
277278
@disallow_local_execution_in_containers
@@ -287,7 +288,7 @@ async def reject_script(
287288
description=RUN_SCRIPT_INTERACTIVE_DESCRIPTION,
288289
annotations=ToolAnnotations(destructiveHint=True),
289290
output_schema=RunScriptInteractiveResult.model_json_schema(),
290-
meta={"ui": {"resourceUri": RUN_SCRIPT_APP_URI}},
291+
app=AppConfig(resourceUri=RUN_SCRIPT_APP_URI),
291292
)
292293
@log_tool_call
293294
@disallow_local_execution_in_containers
@@ -359,7 +360,7 @@ async def run_script_interactive(
359360
tags={"run_script", "hidden_from_model"},
360361
title="Get the execution state with request ID",
361362
description="Get the execution state with request ID",
362-
meta={"ui": {"visibility": ["app"]}},
363+
app=AppConfig(visibility=["app"]),
363364
)
364365
@log_tool_call
365366
@disallow_local_execution_in_containers

tests/test_middleware.py

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -59,62 +59,24 @@ async def test_skips_modification_when_disabled(self, middleware, mock_context,
5959
mock_session = mocker.Mock(spec=ServerSession)
6060
mock_session._init_options = mocker.Mock()
6161
mock_session._init_options.instructions = "Use run_script_with_confirmation for changes"
62-
mock_context.fastmcp_context._session = mock_session
62+
mock_context.fastmcp_context.session = mock_session
6363

6464
await middleware.on_initialize(mock_context, mocker.AsyncMock(return_value=mocker.Mock()))
6565

6666
assert mock_session._init_options.instructions == "Use run_script_with_confirmation for changes"
6767

68-
async def test_modifies_instructions_fastmcp_3x(self, middleware, mock_context, mocker):
69-
# Test instruction modification with FastMCP 3.x
68+
async def test_modifies_instructions(self, middleware, mock_context, mocker):
7069
mocker.patch.object(server_module, "_use_mcp_app_for_client", return_value=True)
7170

7271
mock_session = mocker.Mock(spec=ServerSession)
7372
mock_session._init_options = mocker.Mock()
7473
mock_session._init_options.instructions = "Use run_script_with_confirmation for changes"
75-
mock_context.fastmcp_context._session = mock_session
74+
mock_context.fastmcp_context.session = mock_session
7675

7776
await middleware.on_initialize(mock_context, mocker.AsyncMock(return_value=mocker.Mock()))
7877

7978
assert mock_session._init_options.instructions == "Use run_script_interactive for changes"
8079

81-
async def test_modifies_instructions_fastmcp_2x(self, middleware, mock_context, mocker):
82-
# Test instruction modification with FastMCP 2.x via closure extraction
83-
mocker.patch.object(server_module, "_use_mcp_app_for_client", return_value=True)
84-
mock_context.fastmcp_context._session = None
85-
86-
mock_session = mocker.Mock(spec=ServerSession)
87-
mock_session._init_options = mocker.Mock()
88-
mock_session._init_options.instructions = "Use run_script_with_confirmation for changes"
89-
90-
def make_call_next(session_obj):
91-
self = session_obj
92-
93-
async def call_next_func(_ctx):
94-
_ = self
95-
return mocker.Mock()
96-
97-
return call_next_func
98-
99-
await middleware.on_initialize(mock_context, make_call_next(mock_session))
100-
101-
assert mock_session._init_options.instructions == "Use run_script_interactive for changes"
102-
103-
async def test_handles_extraction_failure(self, middleware, mock_context, mocker):
104-
# Test graceful handling when session extraction fails
105-
mocker.patch.object(server_module, "_use_mcp_app_for_client", return_value=True)
106-
mock_logger = mocker.patch.object(server_module, "logger")
107-
108-
mock_context.fastmcp_context._session = None
109-
call_next = mocker.AsyncMock(return_value=mocker.Mock())
110-
call_next.__code__ = mocker.Mock()
111-
call_next.__code__.co_freevars = []
112-
call_next.__closure__ = None
113-
114-
await middleware.on_initialize(mock_context, call_next)
115-
116-
mock_logger.warning.assert_called_once()
117-
11880

11981
class TestDynamicDiscoveryMiddlewareOnListTools:
12082
@pytest.fixture

tests/test_tool_schemas.py

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,6 @@
55
from linux_mcp_server.server import mcp
66

77

8-
@pytest.fixture(scope="module")
9-
async def mcp_tools() -> dict:
10-
"""Fetch all MCP tools using the public API."""
11-
return await mcp.get_tools()
12-
13-
14-
@pytest.fixture
15-
def tool_properties(mcp_tools: dict):
16-
"""Get the properties dict for a tool's parameters schema."""
17-
18-
def _tool_properties(tool_name: str) -> dict:
19-
tool = mcp_tools.get(tool_name)
20-
if tool is None:
21-
raise ValueError(f"Tool '{tool_name}' not found") # pragma: no cover
22-
return tool.parameters.get("properties", {})
23-
24-
return _tool_properties
25-
26-
278
class TestToolSchemaExamples:
289
"""Verify parameters have examples for LLM guidance."""
2910

@@ -42,8 +23,10 @@ class TestToolSchemaExamples:
4223
("read_file", "path"),
4324
],
4425
)
45-
def test_parameter_has_examples(self, tool_name: str, param_name: str, tool_properties) -> None:
46-
props = tool_properties(tool_name)
26+
async def test_parameter_has_examples(self, tool_name: str, param_name: str) -> None:
27+
tool = await mcp.get_tool(tool_name)
28+
assert tool
29+
props = tool.parameters.get("properties", {})
4730

4831
assert param_name in props, f"Parameter '{param_name}' not found in {tool_name}"
4932
assert "examples" in props[param_name], f"Parameter '{param_name}' in {tool_name} missing examples"

0 commit comments

Comments
 (0)