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 ADDED
@@ -0,0 +1 @@
1
+ """MCP server for the NVDA screen reader."""
nvda_mcp/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Lets ``python -m nvda_mcp`` start the server."""
2
+
3
+ from .server import main
4
+
5
+ main()
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