|
5 | 5 |
|
6 | 6 | from importlib import resources |
7 | 7 | from pathlib import Path |
8 | | -from types import CellType |
9 | | -from types import CodeType |
10 | 8 |
|
11 | 9 | from fastmcp import FastMCP |
| 10 | +from fastmcp.resources import ResourceContent |
| 11 | +from fastmcp.resources import ResourceResult |
12 | 12 | from fastmcp.server.dependencies import get_access_token |
13 | 13 | from fastmcp.server.middleware import Middleware |
14 | 14 | from fastmcp.server.middleware import MiddlewareContext |
15 | 15 | from fastmcp.server.middleware.middleware import CallNext |
16 | | -from mcp import ServerSession |
17 | | -from mcp.types import BlobResourceContents |
18 | 16 | from mcp.types import InitializeRequest |
19 | 17 | from mcp.types import InitializeRequestParams |
20 | 18 | 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 |
25 | 19 |
|
26 | 20 | import linux_mcp_server |
27 | 21 |
|
|
31 | 25 | from linux_mcp_server.config import CONFIG |
32 | 26 | from linux_mcp_server.config import Toolset |
33 | 27 | from linux_mcp_server.config import Transport |
34 | | -from linux_mcp_server.mcp_app import ALLOWED_UI_RESOURCE_URIS |
35 | 28 | from linux_mcp_server.mcp_app import MCP_APP_MIME_TYPE |
36 | 29 | from linux_mcp_server.mcp_app import MCP_UI_EXTENSION |
| 30 | +from linux_mcp_server.mcp_app import RUN_SCRIPT_APP_URI |
37 | 31 | from linux_mcp_server.toolset import get_toolset |
38 | 32 |
|
39 | 33 |
|
| 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 | + |
40 | 48 | logger = logging.getLogger("linux-mcp-server") |
41 | 49 |
|
42 | 50 | INSTRUCTIONS_FIXED = """You have access to predefined commands that inspect the system. They run standard Linux utilities and return formatted results. |
|
158 | 166 | toolset = get_toolset(CONFIG.toolset.value) |
159 | 167 | assert toolset is not None, f"Toolset not found in registry: {CONFIG.toolset}" |
160 | 168 |
|
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 | | - |
167 | 169 | if CONFIG.toolset != Toolset.FIXED and CONFIG.gatekeeper_model is None: |
168 | 170 | logger.error("LINUX_MCP_GATEKEEPER_MODEL not set, this is needed for run_script tools") |
169 | 171 | sys.exit(1) |
170 | 172 |
|
171 | 173 | # Create auth provider if configured |
172 | 174 | auth_provider = create_auth_provider() |
173 | 175 |
|
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) |
178 | 177 |
|
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() |
226 | 182 |
|
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) |
231 | 185 |
|
232 | | - return ServerResult(ReadResourceResult(contents=fallback_contents)) |
233 | 186 |
|
| 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 |
234 | 216 |
|
235 | | -_low_level_server.request_handlers[ReadResourceRequest] = _read_resource_with_meta |
| 217 | + return ResourceResult(contents=[ResourceContent(html, mime_type=MCP_APP_MIME_TYPE)]) |
236 | 218 |
|
237 | 219 |
|
238 | 220 | from linux_mcp_server.tools import * # noqa: E402, F403 |
@@ -334,31 +316,15 @@ async def on_initialize( |
334 | 316 | # away in the ServerSession object, so we need to modify that based |
335 | 317 | # on whether we'll use mcp-apps with the client making the InitializeRequest. |
336 | 318 |
|
| 319 | + assert context.fastmcp_context |
| 320 | + session = context.fastmcp_context.session |
| 321 | + |
337 | 322 | 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 | + ) |
362 | 328 |
|
363 | 329 | return await call_next(context) |
364 | 330 |
|
|
0 commit comments