windows-server-mcp 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- windows_server_mcp/__init__.py +1 -0
- windows_server_mcp/registry.py +118 -0
- windows_server_mcp/server.py +116 -0
- windows_server_mcp/shell.py +200 -0
- windows_server_mcp/tools/__init__.py +1 -0
- windows_server_mcp/tools/advanced_process.py +184 -0
- windows_server_mcp/tools/developer_environment.py +143 -0
- windows_server_mcp/tools/disk_health.py +68 -0
- windows_server_mcp/tools/driver_maintenance.py +67 -0
- windows_server_mcp/tools/event_diagnostics.py +68 -0
- windows_server_mcp/tools/execute_powershell.py +135 -0
- windows_server_mcp/tools/hardware_drivers.py +217 -0
- windows_server_mcp/tools/manage_network.py +56 -0
- windows_server_mcp/tools/manage_registry.py +74 -0
- windows_server_mcp/tools/media_management.py +131 -0
- windows_server_mcp/tools/network_diagnostics.py +198 -0
- windows_server_mcp/tools/optimization.py +79 -0
- windows_server_mcp/tools/peripherals.py +84 -0
- windows_server_mcp/tools/power_hardware.py +84 -0
- windows_server_mcp/tools/process_management.py +66 -0
- windows_server_mcp/tools/restore_update_controls.py +150 -0
- windows_server_mcp/tools/safe_file_operations.py +520 -0
- windows_server_mcp/tools/security_updates.py +99 -0
- windows_server_mcp/tools/services.py +47 -0
- windows_server_mcp/tools/shell_helpers.py +91 -0
- windows_server_mcp/tools/software_audit.py +65 -0
- windows_server_mcp/tools/software_maintenance.py +119 -0
- windows_server_mcp/tools/startup_tasks.py +143 -0
- windows_server_mcp/tools/storage_analysis.py +122 -0
- windows_server_mcp/tools/system_info.py +69 -0
- windows_server_mcp/tools/task_manager.py +98 -0
- windows_server_mcp/tools/text_files.py +113 -0
- windows_server_mcp/tools/virtualization.py +71 -0
- windows_server_mcp/tools/window_management.py +109 -0
- windows_server_mcp-1.0.0.dist-info/METADATA +136 -0
- windows_server_mcp-1.0.0.dist-info/RECORD +39 -0
- windows_server_mcp-1.0.0.dist-info/WHEEL +4 -0
- windows_server_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- windows_server_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This file makes windows_server_mcp a Python package
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
log = logging.getLogger("windows_server_mcp")
|
|
7
|
+
|
|
8
|
+
registered_tools = []
|
|
9
|
+
|
|
10
|
+
#: Hard ceiling (seconds) for a single tool call. Commands have their own,
|
|
11
|
+
#: shorter timeouts (see shell.py); this only frees the server when something
|
|
12
|
+
#: blocks inside Python itself (a stuck syscall, a hung handle query...).
|
|
13
|
+
DEFAULT_TOOL_DEADLINE = 180
|
|
14
|
+
LONG_TOOL_DEADLINE = 3700 # update/driver installs
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _deadline_for(name: str, kwargs: dict) -> int:
|
|
18
|
+
if name == "execute_powershell":
|
|
19
|
+
try:
|
|
20
|
+
return min(int(kwargs.get("timeout_seconds", 60)), 3600) + 30
|
|
21
|
+
except (TypeError, ValueError):
|
|
22
|
+
return 90
|
|
23
|
+
if "install" in name:
|
|
24
|
+
return LONG_TOOL_DEADLINE
|
|
25
|
+
return DEFAULT_TOOL_DEADLINE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run_in_thread(func):
|
|
29
|
+
"""Run a blocking tool in a worker thread with a hard deadline.
|
|
30
|
+
|
|
31
|
+
* With ``mcp`` 1.x plain sync tools run on the event loop, so one slow call
|
|
32
|
+
froze the whole server. A thread keeps the server responsive.
|
|
33
|
+
* If the thread blocks forever (e.g. a stuck syscall), the call is
|
|
34
|
+
abandoned after the deadline and an error is returned, instead of the
|
|
35
|
+
client waiting until its own timeout.
|
|
36
|
+
* Every call is logged (start/finish/abandon) so a hang can be traced to a
|
|
37
|
+
specific tool: a ``start`` line with no ``finished`` line is the culprit.
|
|
38
|
+
"""
|
|
39
|
+
if inspect.iscoroutinefunction(func):
|
|
40
|
+
return func
|
|
41
|
+
|
|
42
|
+
@functools.wraps(func)
|
|
43
|
+
async def wrapper(*args, **kwargs):
|
|
44
|
+
import anyio
|
|
45
|
+
|
|
46
|
+
name = func.__name__
|
|
47
|
+
deadline = _deadline_for(name, kwargs)
|
|
48
|
+
started = time.monotonic()
|
|
49
|
+
log.info("tool start: %s (deadline %ss)", name, deadline)
|
|
50
|
+
|
|
51
|
+
def target():
|
|
52
|
+
try:
|
|
53
|
+
return func(*args, **kwargs)
|
|
54
|
+
finally:
|
|
55
|
+
log.info("tool finished: %s in %.1fs", name, time.monotonic() - started)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with anyio.fail_after(deadline):
|
|
59
|
+
return await anyio.to_thread.run_sync(target, abandon_on_cancel=True)
|
|
60
|
+
except TimeoutError:
|
|
61
|
+
log.error("tool ABANDONED: %s still running after %ss", name, deadline)
|
|
62
|
+
return (
|
|
63
|
+
f"Error: tool '{name}' did not finish within {deadline}s and was abandoned. "
|
|
64
|
+
"The server is still responsive; try a narrower request."
|
|
65
|
+
)
|
|
66
|
+
except Exception:
|
|
67
|
+
log.exception("tool failed: %s", name)
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
return wrapper
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def windows_server_mcp_tool():
|
|
74
|
+
"""
|
|
75
|
+
Decorator to register a function as a Windows MCP tool.
|
|
76
|
+
"""
|
|
77
|
+
def decorator(func):
|
|
78
|
+
registered_tools.append(func)
|
|
79
|
+
return func
|
|
80
|
+
return decorator
|
|
81
|
+
|
|
82
|
+
def register_all_tools(mcp):
|
|
83
|
+
"""
|
|
84
|
+
Imports all tool modules and registers them with the server instance
|
|
85
|
+
(``MCPServer`` on mcp 2.x, ``FastMCP`` on mcp 1.x).
|
|
86
|
+
"""
|
|
87
|
+
import windows_server_mcp.tools.execute_powershell
|
|
88
|
+
import windows_server_mcp.tools.manage_registry
|
|
89
|
+
import windows_server_mcp.tools.manage_network
|
|
90
|
+
import windows_server_mcp.tools.task_manager
|
|
91
|
+
import windows_server_mcp.tools.software_audit
|
|
92
|
+
import windows_server_mcp.tools.storage_analysis
|
|
93
|
+
import windows_server_mcp.tools.system_info
|
|
94
|
+
import windows_server_mcp.tools.process_management
|
|
95
|
+
import windows_server_mcp.tools.optimization
|
|
96
|
+
import windows_server_mcp.tools.services
|
|
97
|
+
import windows_server_mcp.tools.hardware_drivers
|
|
98
|
+
import windows_server_mcp.tools.media_management
|
|
99
|
+
import windows_server_mcp.tools.advanced_process
|
|
100
|
+
import windows_server_mcp.tools.window_management
|
|
101
|
+
import windows_server_mcp.tools.startup_tasks
|
|
102
|
+
import windows_server_mcp.tools.event_diagnostics
|
|
103
|
+
import windows_server_mcp.tools.network_diagnostics
|
|
104
|
+
import windows_server_mcp.tools.security_updates
|
|
105
|
+
import windows_server_mcp.tools.power_hardware
|
|
106
|
+
import windows_server_mcp.tools.safe_file_operations
|
|
107
|
+
import windows_server_mcp.tools.developer_environment
|
|
108
|
+
import windows_server_mcp.tools.shell_helpers
|
|
109
|
+
import windows_server_mcp.tools.disk_health
|
|
110
|
+
import windows_server_mcp.tools.driver_maintenance
|
|
111
|
+
import windows_server_mcp.tools.software_maintenance
|
|
112
|
+
import windows_server_mcp.tools.peripherals
|
|
113
|
+
import windows_server_mcp.tools.virtualization
|
|
114
|
+
import windows_server_mcp.tools.restore_update_controls
|
|
115
|
+
import windows_server_mcp.tools.text_files
|
|
116
|
+
|
|
117
|
+
for func in registered_tools:
|
|
118
|
+
mcp.tool()(_run_in_thread(func))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Entry point for the Windows Management MCP server.
|
|
2
|
+
|
|
3
|
+
This module works with both generations of the official MCP Python SDK:
|
|
4
|
+
|
|
5
|
+
* ``mcp >= 2.0`` -> ``MCPServer`` (``mcp.server.mcpserver``).
|
|
6
|
+
In v2 the old ``FastMCP`` class was renamed to ``MCPServer`` and the
|
|
7
|
+
``mcp.server.fastmcp`` module was removed without a deprecation shim.
|
|
8
|
+
* ``mcp 1.x`` -> ``FastMCP`` (``mcp.server.fastmcp``).
|
|
9
|
+
|
|
10
|
+
Migration guide:
|
|
11
|
+
https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import logging.handlers
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from importlib.metadata import PackageNotFoundError, version as _dist_version
|
|
20
|
+
|
|
21
|
+
from windows_server_mcp.registry import register_all_tools
|
|
22
|
+
|
|
23
|
+
try: # mcp 2.x
|
|
24
|
+
from mcp.server.mcpserver import MCPServer as _Server
|
|
25
|
+
|
|
26
|
+
_SDK_GENERATION = 2
|
|
27
|
+
except ImportError: # mcp 1.x
|
|
28
|
+
from mcp.server.fastmcp import FastMCP as _Server
|
|
29
|
+
|
|
30
|
+
_SDK_GENERATION = 1
|
|
31
|
+
|
|
32
|
+
SERVER_NAME = "Windows Management"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _package_version() -> str:
|
|
36
|
+
try:
|
|
37
|
+
return _dist_version("windows-server-mcp")
|
|
38
|
+
except PackageNotFoundError: # running from a source checkout
|
|
39
|
+
return "0.0.0+local"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_server():
|
|
43
|
+
"""Build the MCP server and register every Windows tool on it."""
|
|
44
|
+
if _SDK_GENERATION == 2:
|
|
45
|
+
# In v2 only `name` is positional-safe: pass everything else by keyword.
|
|
46
|
+
server = _Server(SERVER_NAME, version=_package_version())
|
|
47
|
+
else:
|
|
48
|
+
server = _Server(SERVER_NAME)
|
|
49
|
+
register_all_tools(server)
|
|
50
|
+
return server
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
mcp = create_server()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _log_dir() -> str:
|
|
57
|
+
base = os.environ.get("LOCALAPPDATA") or tempfile.gettempdir()
|
|
58
|
+
return os.path.join(base, "windows-server-mcp")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _harden_stdio() -> None:
|
|
62
|
+
"""Make sure logging can never block the server.
|
|
63
|
+
|
|
64
|
+
When the server is started with ``pythonw.exe`` by a bridge/launcher,
|
|
65
|
+
``stderr`` is often a pipe nobody reads. Once ~64 KB of log lines fill it,
|
|
66
|
+
the next ``write`` blocks forever and every tool call "hangs" until the
|
|
67
|
+
process is killed. We send our own logs and ``stderr`` to files instead.
|
|
68
|
+
``stdout`` is left alone: it carries the MCP protocol.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
directory = _log_dir()
|
|
72
|
+
os.makedirs(directory, exist_ok=True)
|
|
73
|
+
log_file = os.path.join(directory, "server.log")
|
|
74
|
+
err_file = os.path.join(directory, "stderr.log")
|
|
75
|
+
|
|
76
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
77
|
+
log_file, maxBytes=1_000_000, backupCount=2, encoding="utf-8"
|
|
78
|
+
)
|
|
79
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(threadName)s %(levelname)s %(message)s"))
|
|
80
|
+
package_logger = logging.getLogger("windows_server_mcp")
|
|
81
|
+
package_logger.setLevel(logging.INFO)
|
|
82
|
+
package_logger.addHandler(handler)
|
|
83
|
+
package_logger.propagate = False
|
|
84
|
+
|
|
85
|
+
if os.path.exists(err_file) and os.path.getsize(err_file) > 5_000_000:
|
|
86
|
+
os.remove(err_file)
|
|
87
|
+
err_stream = open(err_file, "a", buffering=1, encoding="utf-8", errors="replace")
|
|
88
|
+
previous_stderr = sys.stderr
|
|
89
|
+
sys.stderr = err_stream
|
|
90
|
+
loggers = [logging.getLogger()] + [
|
|
91
|
+
lg for lg in logging.root.manager.loggerDict.values() if isinstance(lg, logging.Logger)
|
|
92
|
+
]
|
|
93
|
+
for lg in loggers:
|
|
94
|
+
for existing in lg.handlers:
|
|
95
|
+
# Only re-point handlers that write to the old stderr; leave
|
|
96
|
+
# foreign handlers (e.g. pytest's capture handlers) untouched.
|
|
97
|
+
if (
|
|
98
|
+
isinstance(existing, logging.StreamHandler)
|
|
99
|
+
and not isinstance(existing, logging.FileHandler)
|
|
100
|
+
and getattr(existing, "stream", None) in (previous_stderr, sys.__stderr__)
|
|
101
|
+
):
|
|
102
|
+
existing.setStream(err_stream)
|
|
103
|
+
package_logger.info("server starting (python %s, sdk generation %s)", sys.executable, _SDK_GENERATION)
|
|
104
|
+
except Exception:
|
|
105
|
+
# Never let logging setup prevent the server from starting.
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main():
|
|
110
|
+
"""Run the server over stdio (the default transport)."""
|
|
111
|
+
_harden_stdio()
|
|
112
|
+
mcp.run()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Safe subprocess helpers shared by every tool.
|
|
2
|
+
|
|
3
|
+
Why this module exists
|
|
4
|
+
----------------------
|
|
5
|
+
The MCP server talks to the client over stdio. A child process that
|
|
6
|
+
|
|
7
|
+
* inherits the server's ``stdin`` (the MCP channel) can block waiting for input
|
|
8
|
+
(``pause``, installers, ``Read-Host``...), and
|
|
9
|
+
* has no ``timeout`` never returns if PowerShell hangs (``Get-Counter``,
|
|
10
|
+
Windows Update COM calls, a UAC prompt nobody can see...).
|
|
11
|
+
|
|
12
|
+
When that happens the tool call never completes and the MCP client eventually
|
|
13
|
+
gives up with its own timeout. Every external command should go through
|
|
14
|
+
:func:`run` / :func:`run_powershell` so it always has a deadline, never reads
|
|
15
|
+
from the MCP channel, never pops a console window and is killed together with
|
|
16
|
+
all of its children when the deadline expires.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Any, Sequence
|
|
25
|
+
|
|
26
|
+
#: Used when a caller does not pass an explicit ``timeout``.
|
|
27
|
+
DEFAULT_TIMEOUT = 60
|
|
28
|
+
|
|
29
|
+
#: Exit code reported when a command was killed because it timed out
|
|
30
|
+
#: (same convention as GNU ``timeout``).
|
|
31
|
+
TIMEOUT_RETURNCODE = 124
|
|
32
|
+
|
|
33
|
+
_IS_WINDOWS = sys.platform == "win32"
|
|
34
|
+
_CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
35
|
+
_CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
#: Prepended to every PowerShell ``-Command`` script. Windows PowerShell 5.1
|
|
39
|
+
#: writes to a pipe using the console's OEM code page (cp437/cp850...), which
|
|
40
|
+
#: turns accents and emojis into garbage ("é", "?", Chinese-looking text) once
|
|
41
|
+
#: Python decodes it. Forcing UTF-8 (without BOM) on both sides fixes that.
|
|
42
|
+
UTF8_PREAMBLE = (
|
|
43
|
+
"try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) } catch {}; "
|
|
44
|
+
"try { [Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false) } catch {}; "
|
|
45
|
+
"$OutputEncoding = New-Object System.Text.UTF8Encoding($false); "
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_powershell_command(args: Any) -> bool:
|
|
50
|
+
if isinstance(args, (str, bytes)) or not args:
|
|
51
|
+
return False
|
|
52
|
+
exe = os.path.basename(str(args[0])).lower()
|
|
53
|
+
if exe not in ("powershell", "powershell.exe", "pwsh", "pwsh.exe"):
|
|
54
|
+
return False
|
|
55
|
+
return any(str(a).lower() in ("-command", "-c") for a in args[:-1])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def with_utf8_preamble(args: Sequence[str]) -> list[str]:
|
|
59
|
+
"""Return *args* with the UTF-8 preamble added to the PowerShell script."""
|
|
60
|
+
argv = list(args)
|
|
61
|
+
for index, value in enumerate(argv[:-1]):
|
|
62
|
+
if str(value).lower() in ("-command", "-c") and not argv[index + 1].startswith(UTF8_PREAMBLE):
|
|
63
|
+
argv[index + 1] = UTF8_PREAMBLE + argv[index + 1]
|
|
64
|
+
break
|
|
65
|
+
return argv
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def powershell_command(script: str, *, non_interactive: bool = True) -> list[str]:
|
|
69
|
+
"""Build the argv used to run *script* with Windows PowerShell."""
|
|
70
|
+
argv = ["powershell", "-NoProfile"]
|
|
71
|
+
if non_interactive:
|
|
72
|
+
argv.append("-NonInteractive")
|
|
73
|
+
argv += ["-Command", UTF8_PREAMBLE + script]
|
|
74
|
+
return argv
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def kill_process_tree(proc: subprocess.Popen) -> None:
|
|
78
|
+
"""Kill *proc* and every process it spawned."""
|
|
79
|
+
try:
|
|
80
|
+
if _IS_WINDOWS:
|
|
81
|
+
subprocess.run(
|
|
82
|
+
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
|
83
|
+
stdin=subprocess.DEVNULL,
|
|
84
|
+
stdout=subprocess.DEVNULL,
|
|
85
|
+
stderr=subprocess.DEVNULL,
|
|
86
|
+
timeout=10,
|
|
87
|
+
creationflags=_CREATE_NO_WINDOW,
|
|
88
|
+
)
|
|
89
|
+
else: # pragma: no cover - only used when developing on Linux/macOS
|
|
90
|
+
import signal
|
|
91
|
+
|
|
92
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
try:
|
|
96
|
+
proc.kill()
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _timeout_message(args: Any, timeout: float) -> str:
|
|
102
|
+
return (
|
|
103
|
+
f"Command timed out after {timeout:g}s and was terminated. "
|
|
104
|
+
"Try a narrower query, or raise the timeout if the task is expected to be slow."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run(
|
|
109
|
+
args: Sequence[str] | str,
|
|
110
|
+
*,
|
|
111
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
112
|
+
capture_output: bool = True,
|
|
113
|
+
text: bool = True,
|
|
114
|
+
check: bool = False,
|
|
115
|
+
**kwargs: Any,
|
|
116
|
+
) -> subprocess.CompletedProcess:
|
|
117
|
+
"""Drop-in replacement for :func:`subprocess.run` that cannot hang.
|
|
118
|
+
|
|
119
|
+
Differences from ``subprocess.run``:
|
|
120
|
+
|
|
121
|
+
* ``timeout`` defaults to :data:`DEFAULT_TIMEOUT`. Pass ``None`` only if
|
|
122
|
+
you really want to wait forever.
|
|
123
|
+
* ``stdin`` is always closed (``DEVNULL``) so the child can never read from
|
|
124
|
+
the MCP stdio channel.
|
|
125
|
+
* No console window is created on Windows.
|
|
126
|
+
* On timeout the whole process tree is killed and a ``CompletedProcess``
|
|
127
|
+
with ``returncode == 124`` is *returned* (not raised). ``stderr``
|
|
128
|
+
contains an explanatory message, so the existing
|
|
129
|
+
``if result.returncode != 0: return result.stderr`` code paths report the
|
|
130
|
+
timeout to the model instead of blowing up.
|
|
131
|
+
"""
|
|
132
|
+
kwargs.pop("stdin", None)
|
|
133
|
+
if _is_powershell_command(args):
|
|
134
|
+
args = with_utf8_preamble(args)
|
|
135
|
+
if text:
|
|
136
|
+
kwargs.setdefault("encoding", "utf-8")
|
|
137
|
+
if capture_output:
|
|
138
|
+
kwargs.setdefault("stdout", subprocess.PIPE)
|
|
139
|
+
kwargs.setdefault("stderr", subprocess.PIPE)
|
|
140
|
+
if text:
|
|
141
|
+
kwargs.setdefault("errors", "replace")
|
|
142
|
+
flags = kwargs.pop("creationflags", 0) | _CREATE_NO_WINDOW
|
|
143
|
+
if not _IS_WINDOWS: # pragma: no cover
|
|
144
|
+
kwargs.setdefault("start_new_session", True)
|
|
145
|
+
flags = 0
|
|
146
|
+
else:
|
|
147
|
+
flags |= _CREATE_NEW_PROCESS_GROUP
|
|
148
|
+
|
|
149
|
+
proc = subprocess.Popen(
|
|
150
|
+
args,
|
|
151
|
+
stdin=subprocess.DEVNULL,
|
|
152
|
+
text=text,
|
|
153
|
+
creationflags=flags,
|
|
154
|
+
**kwargs,
|
|
155
|
+
)
|
|
156
|
+
try:
|
|
157
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
158
|
+
except subprocess.TimeoutExpired:
|
|
159
|
+
kill_process_tree(proc)
|
|
160
|
+
try:
|
|
161
|
+
stdout, stderr = proc.communicate(timeout=5)
|
|
162
|
+
except Exception:
|
|
163
|
+
stdout, stderr = ("" if text else b""), ("" if text else b"")
|
|
164
|
+
message = _timeout_message(args, timeout or 0)
|
|
165
|
+
if text:
|
|
166
|
+
stderr = f"{(stderr or '').rstrip()}\n{message}".strip()
|
|
167
|
+
else:
|
|
168
|
+
stderr = (stderr or b"") + b"\n" + message.encode()
|
|
169
|
+
return subprocess.CompletedProcess(args, TIMEOUT_RETURNCODE, stdout, stderr)
|
|
170
|
+
except BaseException:
|
|
171
|
+
kill_process_tree(proc)
|
|
172
|
+
raise
|
|
173
|
+
|
|
174
|
+
result = subprocess.CompletedProcess(args, proc.returncode, stdout, stderr)
|
|
175
|
+
if check and result.returncode != 0:
|
|
176
|
+
raise subprocess.CalledProcessError(
|
|
177
|
+
result.returncode, args, output=stdout, stderr=stderr
|
|
178
|
+
)
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_powershell(
|
|
183
|
+
script: str,
|
|
184
|
+
*,
|
|
185
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
186
|
+
non_interactive: bool = True,
|
|
187
|
+
) -> subprocess.CompletedProcess:
|
|
188
|
+
"""Run a PowerShell script with a deadline and a closed stdin."""
|
|
189
|
+
return run(powershell_command(script, non_interactive=non_interactive), timeout=timeout)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def spawn_detached(args: Sequence[str]) -> subprocess.Popen:
|
|
193
|
+
"""Start a GUI program (e.g. Explorer) without waiting for it or sharing stdio."""
|
|
194
|
+
return subprocess.Popen(
|
|
195
|
+
args,
|
|
196
|
+
stdin=subprocess.DEVNULL,
|
|
197
|
+
stdout=subprocess.DEVNULL,
|
|
198
|
+
stderr=subprocess.DEVNULL,
|
|
199
|
+
creationflags=_CREATE_NO_WINDOW if _IS_WINDOWS else 0,
|
|
200
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Tools package
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from windows_server_mcp.shell import TIMEOUT_RETURNCODE, run
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import psutil
|
|
8
|
+
|
|
9
|
+
from windows_server_mcp.registry import windows_server_mcp_tool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _format_time(timestamp: float) -> str:
|
|
13
|
+
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _signature_status(exe_path: Optional[str]) -> str:
|
|
17
|
+
if not exe_path or not os.path.exists(exe_path):
|
|
18
|
+
return "Unknown"
|
|
19
|
+
|
|
20
|
+
escaped_path = exe_path.replace("'", "''")
|
|
21
|
+
ps_cmd = (
|
|
22
|
+
"Get-AuthenticodeSignature -LiteralPath "
|
|
23
|
+
f"'{escaped_path}' | "
|
|
24
|
+
"Select-Object Status,SignerCertificate | ConvertTo-Json -Depth 3"
|
|
25
|
+
)
|
|
26
|
+
try:
|
|
27
|
+
result = run(
|
|
28
|
+
["powershell", "-NoProfile", "-Command", ps_cmd],
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
timeout=10,
|
|
32
|
+
)
|
|
33
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
34
|
+
return "Unknown"
|
|
35
|
+
return result.stdout.strip()
|
|
36
|
+
except Exception:
|
|
37
|
+
return "Unknown"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@windows_server_mcp_tool()
|
|
41
|
+
def get_process_details(pid: int) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Return detailed process metadata for a PID, including command line, parent,
|
|
44
|
+
executable path, resource usage, open files, network connections, and signature status.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
proc = psutil.Process(pid)
|
|
48
|
+
with proc.oneshot():
|
|
49
|
+
parent = proc.parent()
|
|
50
|
+
memory = proc.memory_info()
|
|
51
|
+
cpu_times = proc.cpu_times()
|
|
52
|
+
exe_path = proc.exe() if proc.exe else ""
|
|
53
|
+
lines = [
|
|
54
|
+
f"Name: {proc.name()}",
|
|
55
|
+
f"PID: {proc.pid}",
|
|
56
|
+
f"Status: {proc.status()}",
|
|
57
|
+
f"User: {proc.username()}",
|
|
58
|
+
f"Created: {_format_time(proc.create_time())}",
|
|
59
|
+
f"Parent: {parent.name()} (PID: {parent.pid})" if parent else "Parent: None",
|
|
60
|
+
f"Executable: {exe_path}",
|
|
61
|
+
f"Command Line: {' '.join(proc.cmdline())}",
|
|
62
|
+
f"Working Directory: {proc.cwd()}",
|
|
63
|
+
f"Threads: {proc.num_threads()}",
|
|
64
|
+
f"Handles: {proc.num_handles() if hasattr(proc, 'num_handles') else 'Unknown'}",
|
|
65
|
+
f"CPU Time: user={cpu_times.user:.2f}s system={cpu_times.system:.2f}s",
|
|
66
|
+
f"Memory RSS: {memory.rss / (1024 ** 2):.2f} MB",
|
|
67
|
+
f"Memory VMS: {memory.vms / (1024 ** 2):.2f} MB",
|
|
68
|
+
f"Signature: {_signature_status(exe_path)}",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
open_files = proc.open_files()
|
|
73
|
+
lines.append("\nOpen Files:")
|
|
74
|
+
lines.extend(f" {file.path}" for file in open_files[:20])
|
|
75
|
+
if len(open_files) > 20:
|
|
76
|
+
lines.append(f" ... and {len(open_files) - 20} more")
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
lines.append(f"\nOpen Files: unavailable ({exc})")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
connections = proc.net_connections(kind="inet")
|
|
82
|
+
lines.append("\nNetwork Connections:")
|
|
83
|
+
for conn in connections[:20]:
|
|
84
|
+
laddr = f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else ""
|
|
85
|
+
raddr = f"{conn.raddr.ip}:{conn.raddr.port}" if conn.raddr else ""
|
|
86
|
+
lines.append(f" {laddr} -> {raddr} [{conn.status}]")
|
|
87
|
+
if len(connections) > 20:
|
|
88
|
+
lines.append(f" ... and {len(connections) - 20} more")
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
lines.append(f"\nNetwork Connections: unavailable ({exc})")
|
|
91
|
+
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
except psutil.NoSuchProcess:
|
|
94
|
+
return f"Error: No process found with PID {pid}."
|
|
95
|
+
except psutil.AccessDenied:
|
|
96
|
+
return f"Error: Access denied reading process PID {pid}."
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
return f"Error getting process details: {exc}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@windows_server_mcp_tool()
|
|
102
|
+
def get_process_tree(pid: int = 0) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Show parent-child process relationships.
|
|
105
|
+
Args:
|
|
106
|
+
pid: Root PID to inspect. Use 0 to show top-level process trees.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
processes = {proc.pid: proc for proc in psutil.process_iter(["pid", "name", "ppid"])}
|
|
110
|
+
children: dict[int, list[psutil.Process]] = {}
|
|
111
|
+
for proc in processes.values():
|
|
112
|
+
try:
|
|
113
|
+
children.setdefault(proc.ppid(), []).append(proc)
|
|
114
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
roots = [processes[pid]] if pid else [
|
|
118
|
+
proc for proc in processes.values() if proc.ppid() not in processes
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
lines: list[str] = []
|
|
122
|
+
|
|
123
|
+
def walk(proc: psutil.Process, depth: int) -> None:
|
|
124
|
+
try:
|
|
125
|
+
indent = " " * depth
|
|
126
|
+
lines.append(f"{indent}- {proc.name()} (PID: {proc.pid})")
|
|
127
|
+
for child in sorted(children.get(proc.pid, []), key=lambda item: item.name().lower()):
|
|
128
|
+
walk(child, depth + 1)
|
|
129
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
for root in sorted(roots, key=lambda item: item.name().lower()):
|
|
133
|
+
walk(root, 0)
|
|
134
|
+
|
|
135
|
+
return "\n".join(lines[:500]) if lines else "No process tree data found."
|
|
136
|
+
except KeyError:
|
|
137
|
+
return f"Error: No process found with PID {pid}."
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
return f"Error building process tree: {exc}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_LOCK_SCAN_CODE = r"""
|
|
143
|
+
import os, sys, psutil
|
|
144
|
+
target = os.path.abspath(sys.argv[1]).lower()
|
|
145
|
+
for proc in psutil.process_iter(["pid", "name"]):
|
|
146
|
+
try:
|
|
147
|
+
for f in proc.open_files():
|
|
148
|
+
if os.path.abspath(f.path).lower() == target:
|
|
149
|
+
print(f"PID: {proc.pid} | Name: {proc.name()} | Path: {f.path}", flush=True)
|
|
150
|
+
break
|
|
151
|
+
except Exception:
|
|
152
|
+
continue
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _console_python() -> str:
|
|
157
|
+
"""Return python.exe even when the server itself runs under pythonw.exe."""
|
|
158
|
+
exe = sys.executable
|
|
159
|
+
if os.path.basename(exe).lower() == "pythonw.exe":
|
|
160
|
+
candidate = os.path.join(os.path.dirname(exe), "python.exe")
|
|
161
|
+
if os.path.exists(candidate):
|
|
162
|
+
return candidate
|
|
163
|
+
return exe
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@windows_server_mcp_tool()
|
|
167
|
+
def find_process_locking_file(file_path: str) -> str:
|
|
168
|
+
"""
|
|
169
|
+
Identify processes that appear to have a file open.
|
|
170
|
+
Note: Windows may hide some handles without Administrator privileges.
|
|
171
|
+
The scan runs in a separate process with a 45s limit, because
|
|
172
|
+
psutil's open_files() can block forever on some Windows handles.
|
|
173
|
+
"""
|
|
174
|
+
result = run([_console_python(), "-c", _LOCK_SCAN_CODE, file_path], timeout=45)
|
|
175
|
+
matches = [line for line in (result.stdout or "").splitlines() if line.startswith("PID:")]
|
|
176
|
+
if matches:
|
|
177
|
+
return "\n".join(matches)
|
|
178
|
+
if result.returncode == TIMEOUT_RETURNCODE:
|
|
179
|
+
return (
|
|
180
|
+
"The handle scan timed out (some process handle blocked it). "
|
|
181
|
+
f"No match found before the limit for '{file_path}'. "
|
|
182
|
+
"Try PowerShell/handle.exe or Resource Monitor as an alternative."
|
|
183
|
+
)
|
|
184
|
+
return f"No visible process handles found for '{file_path}'. Try running as Administrator for deeper handle visibility."
|