nvda-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.
- nvda_mcp/__init__.py +1 -0
- nvda_mcp/__main__.py +5 -0
- nvda_mcp/audit.py +155 -0
- nvda_mcp/bridge_client.py +122 -0
- nvda_mcp/scenario.py +83 -0
- nvda_mcp/server.py +1464 -0
- nvda_mcp-1.0.0.dist-info/METADATA +234 -0
- nvda_mcp-1.0.0.dist-info/RECORD +12 -0
- nvda_mcp-1.0.0.dist-info/WHEEL +4 -0
- nvda_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- nvda_mcp-1.0.0.dist-info/licenses/COPYING.txt +338 -0
- nvda_mcp-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +77 -0
nvda_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP server for the NVDA screen reader."""
|
nvda_mcp/__main__.py
ADDED
nvda_mcp/audit.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Analyses a recorded walk through an application's keyboard focus order."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# Roles a keyboard user must be able to identify by name alone.
|
|
7
|
+
ROLES_NEEDING_NAMES = frozenset(
|
|
8
|
+
{
|
|
9
|
+
"button",
|
|
10
|
+
"link",
|
|
11
|
+
"check box",
|
|
12
|
+
"radio button",
|
|
13
|
+
"combo box",
|
|
14
|
+
"edit",
|
|
15
|
+
"menu item",
|
|
16
|
+
"tab",
|
|
17
|
+
"toggle button",
|
|
18
|
+
"slider",
|
|
19
|
+
"spin button",
|
|
20
|
+
"split button",
|
|
21
|
+
"menu button",
|
|
22
|
+
"tree view item",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def focus_signature(focus: dict[str, Any]) -> tuple:
|
|
28
|
+
"""Identifies a focused control well enough to notice cycles and stuck focus."""
|
|
29
|
+
return (
|
|
30
|
+
focus.get("name"),
|
|
31
|
+
focus.get("role"),
|
|
32
|
+
focus.get("appName"),
|
|
33
|
+
tuple(focus.get("location") or ()),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class AuditStep:
|
|
39
|
+
index: int
|
|
40
|
+
focus: dict[str, Any]
|
|
41
|
+
spoken: str
|
|
42
|
+
issues: list[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"step": self.index,
|
|
47
|
+
"spoken": self.spoken,
|
|
48
|
+
"name": self.focus.get("name"),
|
|
49
|
+
"role": self.focus.get("role"),
|
|
50
|
+
"states": self.focus.get("states", []),
|
|
51
|
+
"issues": self.issues,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class AuditReport:
|
|
57
|
+
key: str
|
|
58
|
+
app_name: str | None
|
|
59
|
+
steps: list[AuditStep] = field(default_factory=list)
|
|
60
|
+
stop_reason: str = ""
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def issue_count(self) -> int:
|
|
64
|
+
return sum(len(step.issues) for step in self.steps)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict[str, Any]:
|
|
67
|
+
return {
|
|
68
|
+
"key": self.key,
|
|
69
|
+
"appName": self.app_name,
|
|
70
|
+
"stopReason": self.stop_reason,
|
|
71
|
+
"issueCount": self.issue_count,
|
|
72
|
+
"steps": [step.to_dict() for step in self.steps],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def to_markdown(self) -> str:
|
|
76
|
+
lines = [
|
|
77
|
+
f"# Focus order audit: {self.app_name or 'unknown app'}",
|
|
78
|
+
"",
|
|
79
|
+
f"Pressed **{self.key}** {len(self.steps) - 1} times. Stopped because: {self.stop_reason}.",
|
|
80
|
+
f"Found **{self.issue_count}** issue(s).",
|
|
81
|
+
"",
|
|
82
|
+
"| Step | NVDA said | Role | Name | Issues |",
|
|
83
|
+
"|---|---|---|---|---|",
|
|
84
|
+
]
|
|
85
|
+
for step in self.steps:
|
|
86
|
+
cells = [
|
|
87
|
+
str(step.index),
|
|
88
|
+
step.spoken or "(nothing)",
|
|
89
|
+
step.focus.get("role") or "",
|
|
90
|
+
step.focus.get("name") or "",
|
|
91
|
+
"; ".join(step.issues),
|
|
92
|
+
]
|
|
93
|
+
lines.append(
|
|
94
|
+
"| " + " | ".join(cell.replace("|", "\\|").replace("\n", " ") for cell in cells) + " |"
|
|
95
|
+
)
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class FocusOrderAuditor:
|
|
100
|
+
"""Accumulates steps and decides when the walk should stop.
|
|
101
|
+
|
|
102
|
+
Feed it the starting focus, then each new focus with what NVDA said;
|
|
103
|
+
:meth:`add_step` returns a stop reason once the walk is complete.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, key: str, start_focus: dict[str, Any], start_spoken: str = "") -> None:
|
|
107
|
+
self.report = AuditReport(key=key, app_name=start_focus.get("appName"))
|
|
108
|
+
self._start_signature = focus_signature(start_focus)
|
|
109
|
+
self._first_step_by_label: dict[tuple, int] = {}
|
|
110
|
+
self._unmoved_count = 0
|
|
111
|
+
self._record(AuditStep(0, start_focus, start_spoken))
|
|
112
|
+
|
|
113
|
+
def add_step(self, focus: dict[str, Any], spoken: str) -> str | None:
|
|
114
|
+
step = AuditStep(len(self.report.steps), focus, spoken)
|
|
115
|
+
previous = self.report.steps[-1]
|
|
116
|
+
signature = focus_signature(focus)
|
|
117
|
+
stop_reason = None
|
|
118
|
+
if focus.get("appName") != self.report.app_name:
|
|
119
|
+
step.issues.append(f"Focus left the application (now in {focus.get('appName') or 'unknown'})")
|
|
120
|
+
stop_reason = "focus left the application"
|
|
121
|
+
elif signature == self._start_signature:
|
|
122
|
+
stop_reason = "focus returned to where the audit started"
|
|
123
|
+
elif signature == focus_signature(previous.focus):
|
|
124
|
+
self._unmoved_count += 1
|
|
125
|
+
step.issues.append("Focus did not move: possible keyboard trap, or the end of the focus order")
|
|
126
|
+
if self._unmoved_count >= 2:
|
|
127
|
+
stop_reason = "focus stopped moving"
|
|
128
|
+
else:
|
|
129
|
+
self._unmoved_count = 0
|
|
130
|
+
if not spoken:
|
|
131
|
+
step.issues.append("NVDA said nothing when this control got focus")
|
|
132
|
+
self._record(step)
|
|
133
|
+
if stop_reason:
|
|
134
|
+
self.report.stop_reason = stop_reason
|
|
135
|
+
return stop_reason
|
|
136
|
+
|
|
137
|
+
def finish(self, reason: str) -> AuditReport:
|
|
138
|
+
if not self.report.stop_reason:
|
|
139
|
+
self.report.stop_reason = reason
|
|
140
|
+
return self.report
|
|
141
|
+
|
|
142
|
+
def _record(self, step: AuditStep) -> None:
|
|
143
|
+
focus = step.focus
|
|
144
|
+
role = focus.get("role")
|
|
145
|
+
name = (focus.get("name") or "").strip()
|
|
146
|
+
if role in ROLES_NEEDING_NAMES and not name:
|
|
147
|
+
step.issues.append(f"Unlabeled {role}: it has no accessible name")
|
|
148
|
+
if name and step.index > 0 and focus_signature(focus) != self._start_signature:
|
|
149
|
+
label = (name, role)
|
|
150
|
+
first = self._first_step_by_label.setdefault(label, step.index)
|
|
151
|
+
if first != step.index and focus_signature(focus) != focus_signature(
|
|
152
|
+
self.report.steps[first].focus
|
|
153
|
+
):
|
|
154
|
+
step.issues.append(f"Same name and role as step {first}: users cannot tell them apart")
|
|
155
|
+
self.report.steps.append(step)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Client for the loopback bridge run by the NVDA MCP add-on."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
SUPPORTED_PROTOCOL_VERSION = 3
|
|
12
|
+
DEFAULT_TIMEOUT_SECONDS = 10.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_connection_file() -> Path:
|
|
16
|
+
"""The file the add-on writes in NVDA's user configuration directory.
|
|
17
|
+
|
|
18
|
+
Set NVDA_MCP_CONNECTION_FILE for portable copies of NVDA, whose configuration lives elsewhere.
|
|
19
|
+
"""
|
|
20
|
+
override = os.environ.get("NVDA_MCP_CONNECTION_FILE")
|
|
21
|
+
if override:
|
|
22
|
+
return Path(override)
|
|
23
|
+
return Path(os.environ.get("APPDATA", "")) / "nvda" / "nvdaMcp" / "connection.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NvdaUnavailableError(Exception):
|
|
27
|
+
"""NVDA, or the add-on's bridge, cannot be reached."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NvdaBridgeError(Exception):
|
|
31
|
+
"""The add-on received the request but reported a failure."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, code: str, message: str) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.code = code
|
|
36
|
+
self.message = message
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def server_version() -> str:
|
|
40
|
+
try:
|
|
41
|
+
return metadata.version("nvda-mcp")
|
|
42
|
+
except metadata.PackageNotFoundError:
|
|
43
|
+
return "unknown"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def version_mismatch_message(info: dict[str, Any]) -> str:
|
|
47
|
+
"""Says which of the add-on and the server is older, and how to update it."""
|
|
48
|
+
addon_protocol = info.get("protocolVersion")
|
|
49
|
+
addon_version = info.get("addonVersion") or "an older version"
|
|
50
|
+
mismatch = (
|
|
51
|
+
f"The NVDA MCP add-on ({addon_version}) and this nvda-mcp server ({server_version()}) do not match: "
|
|
52
|
+
f"the add-on speaks bridge protocol {addon_protocol}, the server {SUPPORTED_PROTOCOL_VERSION}."
|
|
53
|
+
)
|
|
54
|
+
if isinstance(addon_protocol, int) and addon_protocol > SUPPORTED_PROTOCOL_VERSION:
|
|
55
|
+
return (
|
|
56
|
+
f"{mismatch} Update the server: in NVDA, open Settings, NVDA MCP category, choose your client, "
|
|
57
|
+
"press Copy setup command and run what it copies. "
|
|
58
|
+
"If you installed the server with pip or pipx, upgrade it there instead."
|
|
59
|
+
)
|
|
60
|
+
return f"{mismatch} Update the add-on: in NVDA, open Tools, Add-on Store, Updatable add-ons."
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_NOT_RUNNING_HINT = (
|
|
64
|
+
"Make sure NVDA is running, the NVDA MCP add-on is installed and enabled "
|
|
65
|
+
"(NVDA Settings, NVDA MCP category), and NVDA was restarted after installing it."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BridgeClient:
|
|
70
|
+
"""Calls bridge methods. Reads the connection file on every call, so it survives NVDA restarts."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self, connection_file: Path | None = None, transport: httpx.AsyncBaseTransport | None = None
|
|
74
|
+
) -> None:
|
|
75
|
+
self._connection_file = connection_file or default_connection_file()
|
|
76
|
+
self._transport = transport
|
|
77
|
+
self.client_name: str | None = None
|
|
78
|
+
"""The MCP client's name, such as "Claude Code"; sent with every call so NVDA can show it."""
|
|
79
|
+
|
|
80
|
+
def _read_connection(self) -> dict[str, Any]:
|
|
81
|
+
try:
|
|
82
|
+
info = json.loads(self._connection_file.read_text(encoding="utf-8"))
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
raise NvdaUnavailableError(
|
|
85
|
+
f"NVDA is not reachable: {self._connection_file} does not exist. {_NOT_RUNNING_HINT}"
|
|
86
|
+
)
|
|
87
|
+
except (OSError, ValueError) as error:
|
|
88
|
+
raise NvdaUnavailableError(
|
|
89
|
+
f"Could not read {self._connection_file}: {error}. {_NOT_RUNNING_HINT}"
|
|
90
|
+
)
|
|
91
|
+
if info.get("protocolVersion") != SUPPORTED_PROTOCOL_VERSION:
|
|
92
|
+
raise NvdaUnavailableError(version_mismatch_message(info))
|
|
93
|
+
return info
|
|
94
|
+
|
|
95
|
+
async def call(
|
|
96
|
+
self, method: str, params: dict[str, Any] | None = None, timeout: float = DEFAULT_TIMEOUT_SECONDS
|
|
97
|
+
) -> Any:
|
|
98
|
+
info = self._read_connection()
|
|
99
|
+
url = f"http://127.0.0.1:{info['port']}/rpc"
|
|
100
|
+
headers = {"Authorization": f"Bearer {info['token']}"}
|
|
101
|
+
params = dict(params or {})
|
|
102
|
+
if self.client_name:
|
|
103
|
+
params["clientName"] = self.client_name
|
|
104
|
+
try:
|
|
105
|
+
async with httpx.AsyncClient(transport=self._transport, timeout=timeout) as client:
|
|
106
|
+
response = await client.post(url, json={"method": method, "params": params}, headers=headers)
|
|
107
|
+
except httpx.TimeoutException:
|
|
108
|
+
raise NvdaUnavailableError(
|
|
109
|
+
f"NVDA did not answer {method} within {timeout:g} seconds. It may be busy or frozen; try again shortly."
|
|
110
|
+
)
|
|
111
|
+
except httpx.TransportError:
|
|
112
|
+
raise NvdaUnavailableError(f"NVDA is not reachable on port {info['port']}. {_NOT_RUNNING_HINT}")
|
|
113
|
+
try:
|
|
114
|
+
body = response.json()
|
|
115
|
+
except ValueError:
|
|
116
|
+
raise NvdaBridgeError(
|
|
117
|
+
"badResponse", f"The bridge returned HTTP {response.status_code} without JSON."
|
|
118
|
+
)
|
|
119
|
+
if not body.get("ok"):
|
|
120
|
+
error = body.get("error") or {}
|
|
121
|
+
raise NvdaBridgeError(error.get("code", "unknown"), error.get("message", "Unknown bridge error."))
|
|
122
|
+
return body["result"]
|
nvda_mcp/scenario.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Records what NVDA says during a scripted scenario and compares it with a saved baseline."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
BASELINE_FORMAT_VERSION = 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class StepRecord:
|
|
14
|
+
action: str
|
|
15
|
+
spoken: str
|
|
16
|
+
focus: str
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, str]:
|
|
19
|
+
return {"action": self.action, "spoken": self.spoken, "focus": self.focus}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Recording:
|
|
24
|
+
steps: list[StepRecord] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
return {"version": BASELINE_FORMAT_VERSION, "steps": [step.to_dict() for step in self.steps]}
|
|
28
|
+
|
|
29
|
+
def to_markdown(self) -> str:
|
|
30
|
+
lines = ["| Step | Action | NVDA said | Focus after |", "|---|---|---|---|"]
|
|
31
|
+
for number, step in enumerate(self.steps, 1):
|
|
32
|
+
cells = [str(number), step.action, step.spoken or "(nothing)", step.focus]
|
|
33
|
+
lines.append("| " + " | ".join(_cell(cell) for cell in cells) + " |")
|
|
34
|
+
return "\n".join(lines)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cell(text: str) -> str:
|
|
38
|
+
return text.replace("|", "\\|").replace("\n", " ")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def focus_label(focus: dict[str, Any]) -> str:
|
|
42
|
+
return f"{focus.get('name') or '(no name)'} ({focus.get('role') or '?'})"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_baseline(path: Path) -> Recording:
|
|
46
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
47
|
+
if data.get("version") != BASELINE_FORMAT_VERSION:
|
|
48
|
+
raise ValueError(f"{path} is not a baseline this version of nvda-mcp can read.")
|
|
49
|
+
return Recording([StepRecord(**step) for step in data["steps"]])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save_baseline(path: Path, recording: Recording) -> None:
|
|
53
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
path.write_text(json.dumps(recording.to_dict(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def compare(baseline: Recording, recording: Recording, ignore_pattern: str | None = None) -> list[str]:
|
|
58
|
+
"""Differences between a baseline and a new recording, one line each; empty when they match.
|
|
59
|
+
|
|
60
|
+
:param ignore_pattern: A regular expression for text that changes from run to run, such as times;
|
|
61
|
+
matches are ignored in both recordings.
|
|
62
|
+
"""
|
|
63
|
+
ignore = re.compile(ignore_pattern) if ignore_pattern else None
|
|
64
|
+
|
|
65
|
+
def normal(text: str) -> str:
|
|
66
|
+
return ignore.sub("…", text) if ignore else text
|
|
67
|
+
|
|
68
|
+
differences = []
|
|
69
|
+
if [step.action for step in baseline.steps] != [step.action for step in recording.steps]:
|
|
70
|
+
message = (
|
|
71
|
+
"The steps differ from the baseline's, so the runs cannot be compared. "
|
|
72
|
+
"Run with update_baseline to record a new baseline."
|
|
73
|
+
)
|
|
74
|
+
return [message]
|
|
75
|
+
for number, (old, new) in enumerate(zip(baseline.steps, recording.steps), 1):
|
|
76
|
+
if normal(old.spoken) != normal(new.spoken):
|
|
77
|
+
differences.append(
|
|
78
|
+
f"Step {number} ({new.action}): NVDA said {new.spoken or '(nothing)'!r}, "
|
|
79
|
+
f"baseline {old.spoken or '(nothing)'!r}"
|
|
80
|
+
)
|
|
81
|
+
if normal(old.focus) != normal(new.focus):
|
|
82
|
+
differences.append(f"Step {number} ({new.action}): focus on {new.focus}, baseline {old.focus}")
|
|
83
|
+
return differences
|