mcp-reqcheck 0.1.0__tar.gz

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.
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.3
2
+ Name: mcp-reqcheck
3
+ Version: 0.1.0
4
+ Summary: MCP server for reqCheck
5
+ Author: Hongjin Chen
6
+ Author-email: Hongjin Chen <188007812+hjchen114514@users.noreply.github.com>
7
+ Requires-Dist: mcp[cli]>=2.1.1
8
+ Requires-Dist: pyyaml>=6.0
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+
12
+ # ReqCheck MCP server
13
+
14
+ Finds ambiguous terms in a requirements document, checked against
15
+ **ISO/IEC/IEEE 29148:2018**, and hands the client the question templates needed
16
+ to ask the author what they actually meant.
17
+
18
+ Detection is deterministic — regex over a versioned rule catalog, no model
19
+ involved — so the same text always produces the same findings, and every
20
+ finding cites the clause it came from.
21
+
22
+ Part of [ReqCheck](https://github.com/hjchen114514/ReqCheck), which also ships a
23
+ web app for the same checks.
24
+
25
+ ## What it exposes
26
+
27
+ | Kind | Name | Purpose |
28
+ |---|---|---|
29
+ | Tool | `ambiguity_analysis(text)` | Every flagged term with its statement, severity, explanation, ISO citation, and question templates |
30
+ | Resource | `reqcheck://knowledge-base` | The full rule catalog as authored, with citations |
31
+ | Prompt | `check_requirements(text)` | Analyses a document and reports findings in a fixed format |
32
+
33
+ ## Install from PyPI
34
+
35
+ Once published, nothing needs cloning.
36
+
37
+ **Claude Code:**
38
+
39
+ ```bash
40
+ claude mcp add reqcheck -- uvx mcp-reqcheck
41
+ ```
42
+
43
+ **Claude Desktop** — add to
44
+ `~/Library/Application Support/Claude/claude_desktop_config.json`:
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "ReqCheck": { "command": "uvx", "args": ["mcp-reqcheck"] }
50
+ }
51
+ }
52
+ ```
53
+
54
+ Then restart Desktop.
55
+
56
+ ## Install from source
57
+
58
+ **1. Install uv** (skip if you already have it):
59
+
60
+ ```bash
61
+ curl -LsSf https://astral.sh/uv/install.sh | sh
62
+ ```
63
+
64
+ **2. Get the code:**
65
+
66
+ ```bash
67
+ git clone https://github.com/hjchen114514/ReqCheck.git
68
+ cd ReqCheck/apps/mcp_reqCheck
69
+ ```
70
+
71
+ **3a. Claude Desktop:**
72
+
73
+ ```bash
74
+ uv run mcp install src/mcp_reqcheck/server.py --with PyYAML
75
+ ```
76
+
77
+ Restart Desktop. `ReqCheck` should show green under Settings → Developer.
78
+
79
+ **3b. Claude Code** — needs the absolute path. Print it from this folder:
80
+
81
+ ```bash
82
+ pwd
83
+ # /Users/you/ReqCheck/apps/mcp_reqCheck
84
+ ```
85
+
86
+ ```bash
87
+ claude mcp add reqcheck -- uv run --with "mcp[cli]" --with PyYAML mcp run <paste-path>/src/mcp_reqcheck/server.py
88
+ ```
89
+
90
+ Check it with `claude mcp list`.
91
+
92
+ > **`--with PyYAML` is required when running from source.** The client launches
93
+ > the server from its own working directory, so uv never reads this project's
94
+ > `pyproject.toml` and builds an environment from the `--with` flags alone.
95
+ > Without it the catalog loader fails on `import yaml` and the client reports
96
+ > "Server disconnected". Installed users don't need it — `uvx` resolves
97
+ > declared dependencies.
98
+
99
+ ## Use it
100
+
101
+ Ask your client something like:
102
+
103
+ > Check these requirements for ambiguity:
104
+ > The system shall be user-friendly and load significantly faster than the old site.
105
+
106
+ It calls `ambiguity_analysis`, gets back the flagged terms with their reasons
107
+ and citations, and asks you follow-up questions rather than rewriting your
108
+ requirement for you.
109
+
110
+ ## Develop
111
+
112
+ ```bash
113
+ uv run mcp dev src/mcp_reqcheck/server.py
114
+ ```
115
+
116
+ Opens the MCP Inspector, where you can call the tool, read the resource, and
117
+ render the prompt without a client.
118
+
119
+ ### Layout
120
+
121
+ `server.py` holds the records, the rule catalog, the analyser, and the MCP
122
+ surface in one module, with `ambiguity_rules.yaml` beside it. That is
123
+ deliberate: a client may load the file directly (`mcp run server.py`, with no
124
+ package around it) or run the installed console script (`uvx mcp-reqcheck`,
125
+ with one), and imports between sibling files resolve differently in those two
126
+ cases. Importing nothing of its own makes both behave identically.
127
+
128
+ Two details in that file must stay as they are, or the file-based launch
129
+ breaks:
130
+
131
+ - **no `from __future__ import annotations`** — it turns annotations into
132
+ strings, which `dataclasses` resolves through `sys.modules[cls.__module__]`,
133
+ and that is `None` for a directly-loaded file
134
+ - **`Pattern[str]`, not `re.Pattern[str]`** — same reason
135
+
136
+ The catalog and analyser are copied from `apps/api`. Rule changes made there
137
+ need copying here too.
138
+
139
+ ## Publishing
140
+ ```bash
141
+ uv build
142
+ uv publish --index testpypi # sandbox first
143
+ uv publish # then the real index
144
+ ```
@@ -0,0 +1,133 @@
1
+ # ReqCheck MCP server
2
+
3
+ Finds ambiguous terms in a requirements document, checked against
4
+ **ISO/IEC/IEEE 29148:2018**, and hands the client the question templates needed
5
+ to ask the author what they actually meant.
6
+
7
+ Detection is deterministic — regex over a versioned rule catalog, no model
8
+ involved — so the same text always produces the same findings, and every
9
+ finding cites the clause it came from.
10
+
11
+ Part of [ReqCheck](https://github.com/hjchen114514/ReqCheck), which also ships a
12
+ web app for the same checks.
13
+
14
+ ## What it exposes
15
+
16
+ | Kind | Name | Purpose |
17
+ |---|---|---|
18
+ | Tool | `ambiguity_analysis(text)` | Every flagged term with its statement, severity, explanation, ISO citation, and question templates |
19
+ | Resource | `reqcheck://knowledge-base` | The full rule catalog as authored, with citations |
20
+ | Prompt | `check_requirements(text)` | Analyses a document and reports findings in a fixed format |
21
+
22
+ ## Install from PyPI
23
+
24
+ Once published, nothing needs cloning.
25
+
26
+ **Claude Code:**
27
+
28
+ ```bash
29
+ claude mcp add reqcheck -- uvx mcp-reqcheck
30
+ ```
31
+
32
+ **Claude Desktop** — add to
33
+ `~/Library/Application Support/Claude/claude_desktop_config.json`:
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "ReqCheck": { "command": "uvx", "args": ["mcp-reqcheck"] }
39
+ }
40
+ }
41
+ ```
42
+
43
+ Then restart Desktop.
44
+
45
+ ## Install from source
46
+
47
+ **1. Install uv** (skip if you already have it):
48
+
49
+ ```bash
50
+ curl -LsSf https://astral.sh/uv/install.sh | sh
51
+ ```
52
+
53
+ **2. Get the code:**
54
+
55
+ ```bash
56
+ git clone https://github.com/hjchen114514/ReqCheck.git
57
+ cd ReqCheck/apps/mcp_reqCheck
58
+ ```
59
+
60
+ **3a. Claude Desktop:**
61
+
62
+ ```bash
63
+ uv run mcp install src/mcp_reqcheck/server.py --with PyYAML
64
+ ```
65
+
66
+ Restart Desktop. `ReqCheck` should show green under Settings → Developer.
67
+
68
+ **3b. Claude Code** — needs the absolute path. Print it from this folder:
69
+
70
+ ```bash
71
+ pwd
72
+ # /Users/you/ReqCheck/apps/mcp_reqCheck
73
+ ```
74
+
75
+ ```bash
76
+ claude mcp add reqcheck -- uv run --with "mcp[cli]" --with PyYAML mcp run <paste-path>/src/mcp_reqcheck/server.py
77
+ ```
78
+
79
+ Check it with `claude mcp list`.
80
+
81
+ > **`--with PyYAML` is required when running from source.** The client launches
82
+ > the server from its own working directory, so uv never reads this project's
83
+ > `pyproject.toml` and builds an environment from the `--with` flags alone.
84
+ > Without it the catalog loader fails on `import yaml` and the client reports
85
+ > "Server disconnected". Installed users don't need it — `uvx` resolves
86
+ > declared dependencies.
87
+
88
+ ## Use it
89
+
90
+ Ask your client something like:
91
+
92
+ > Check these requirements for ambiguity:
93
+ > The system shall be user-friendly and load significantly faster than the old site.
94
+
95
+ It calls `ambiguity_analysis`, gets back the flagged terms with their reasons
96
+ and citations, and asks you follow-up questions rather than rewriting your
97
+ requirement for you.
98
+
99
+ ## Develop
100
+
101
+ ```bash
102
+ uv run mcp dev src/mcp_reqcheck/server.py
103
+ ```
104
+
105
+ Opens the MCP Inspector, where you can call the tool, read the resource, and
106
+ render the prompt without a client.
107
+
108
+ ### Layout
109
+
110
+ `server.py` holds the records, the rule catalog, the analyser, and the MCP
111
+ surface in one module, with `ambiguity_rules.yaml` beside it. That is
112
+ deliberate: a client may load the file directly (`mcp run server.py`, with no
113
+ package around it) or run the installed console script (`uvx mcp-reqcheck`,
114
+ with one), and imports between sibling files resolve differently in those two
115
+ cases. Importing nothing of its own makes both behave identically.
116
+
117
+ Two details in that file must stay as they are, or the file-based launch
118
+ breaks:
119
+
120
+ - **no `from __future__ import annotations`** — it turns annotations into
121
+ strings, which `dataclasses` resolves through `sys.modules[cls.__module__]`,
122
+ and that is `None` for a directly-loaded file
123
+ - **`Pattern[str]`, not `re.Pattern[str]`** — same reason
124
+
125
+ The catalog and analyser are copied from `apps/api`. Rule changes made there
126
+ need copying here too.
127
+
128
+ ## Publishing
129
+ ```bash
130
+ uv build
131
+ uv publish --index testpypi # sandbox first
132
+ uv publish # then the real index
133
+ ```
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "mcp-reqcheck"
3
+ version = "0.1.0"
4
+ description = "MCP server for reqCheck"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "mcp[cli]>=2.1.1",
9
+ "PyYAML>=6.0",
10
+ ]
11
+
12
+ [[project.authors]]
13
+ name = "Hongjin Chen"
14
+ email = "188007812+hjchen114514@users.noreply.github.com"
15
+
16
+ [project.scripts]
17
+ mcp-reqcheck = "mcp_reqcheck:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.12.6,<0.13.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [[tool.uv.index]]
24
+ name = "testpypi"
25
+ url = "https://test.pypi.org/simple/"
26
+ publish-url = "https://test.pypi.org/legacy/"
27
+ explicit = true
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "mcp-reqcheck"
3
+ version = "0.1.0"
4
+ description = "MCP server for reqCheck"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Hongjin Chen", email = "188007812+hjchen114514@users.noreply.github.com" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "mcp[cli]>=2.1.1",
12
+ # The rule catalog ships as ambiguity_rules.yaml beside server.py.
13
+ "PyYAML>=6.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ mcp-reqcheck = "mcp_reqcheck:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.12.6,<0.13.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [[tool.uv.index]]
24
+ name= "testpypi"
25
+ url="https://test.pypi.org/simple/"
26
+ publish-url="https://test.pypi.org/legacy/"
27
+ explicit=true
@@ -0,0 +1,17 @@
1
+ """ReqCheck MCP server package."""
2
+
3
+
4
+ def main() -> None:
5
+ """Console-script entry point. `uvx mcp-reqcheck` lands here.
6
+
7
+ The import is deferred so that `import mcp_reqcheck` on its own does not
8
+ build the server. server.py imports back through this package, and a
9
+ module-level import here would load and initialise it twice, compiling the
10
+ rule catalog once per copy.
11
+ """
12
+ from mcp_reqcheck.server import mcp
13
+
14
+ mcp.run()
15
+
16
+
17
+ __all__ = ["main"]
@@ -0,0 +1,237 @@
1
+ # ReqCheck ambiguity knowledge base
2
+ #
3
+ # Every rule below is derived from ISO/IEC/IEEE 29148:2018 (see sources.md).
4
+ # Rules are team-authored paraphrases with citations, not copied standard text.
5
+ #
6
+ # Detection is purely deterministic: C3 AmbiguityAnalyzer matches these
7
+ # phrases with word boundaries and computes exact character offsets.
8
+ # No AI is involved in detection.
9
+ #
10
+ # Severity guide:
11
+ # high - the requirement almost certainly cannot be verified as written
12
+ # medium - the requirement is likely to be read two different ways
13
+ # low - worth a look, but frequently acceptable in context
14
+
15
+ catalog_version: "1.0.0"
16
+
17
+ sources:
18
+ ISO29148:
19
+ title: "ISO/IEC/IEEE 29148:2018 - Systems and software engineering - Life cycle processes - Requirements engineering"
20
+ short_name: "ISO/IEC/IEEE 29148:2018"
21
+ clause: "p.22 (well-formed requirements, unbounded and ambiguous terms); p.12 (Unambiguous)"
22
+ url: "https://www.iso.org/standard/72089.html"
23
+
24
+ rules:
25
+ # --- p.22: "superlatives (such as 'best', 'most')" -----------------------
26
+ - id: AMB-SUPER-001
27
+ phrases:
28
+ ["best", "worst", "most", "least", "optimal", "optimum",
29
+ "state of the art", "state-of-the-art", "cutting edge", "world class"]
30
+ category: superlative
31
+ category_label: "Superlative"
32
+ severity: high
33
+ explanation: >-
34
+ Superlatives assert an extreme without naming a baseline or a way to
35
+ measure it, so no test can confirm the requirement was met.
36
+ question_templates:
37
+ - "Compared with what baseline is this the best or the most? Name the alternative."
38
+ - "What measurable value would demonstrate this extreme was achieved?"
39
+ source: ISO29148
40
+
41
+ # --- p.22: "subjective language (such as 'user friendly', 'easy to use',
42
+ # 'cost effective')" ----------------------------------------
43
+ - id: AMB-SUBJ-001
44
+ phrases:
45
+ ["user friendly", "user-friendly", "easy to use", "easy", "simple to use",
46
+ "cost effective", "cost-effective", "efficient", "efficiently", "robust",
47
+ "flexible", "seamless", "seamlessly", "intuitive", "convenient",
48
+ "user experience", "modern", "clean", "nice", "good", "powerful"]
49
+ category: subjective_language
50
+ category_label: "Subjective language"
51
+ severity: high
52
+ explanation: >-
53
+ Subjective language expresses an opinion rather than a property of the
54
+ system, so two readers can reasonably disagree about whether the
55
+ requirement has been satisfied.
56
+ question_templates:
57
+ - "What specific, observable behaviour would prove this requirement is satisfied?"
58
+ - "Who judges this, and what measured value counts as passing?"
59
+ source: ISO29148
60
+
61
+ # --- p.22: "vague pronouns (such as 'it', 'this', 'that')" ---------------
62
+ - id: AMB-PRON-001
63
+ phrases: ["it", "this", "that", "they", "them", "these", "those", "such"]
64
+ category: vague_pronoun
65
+ category_label: "Vague pronoun"
66
+ severity: low
67
+ explanation: >-
68
+ A pronoun with no explicit antecedent forces the reader to guess which
69
+ noun it refers to, which permits more than one interpretation.
70
+ question_templates:
71
+ - "Which exact noun does this pronoun refer to? Replace the pronoun with that noun."
72
+ source: ISO29148
73
+
74
+ # --- p.22: "ambiguous terms such as adverbs and adjectives (such as
75
+ # 'almost always', 'significant', 'minimal')" -----------------
76
+ - id: AMB-ADV-001
77
+ phrases:
78
+ ["almost always", "almost never", "significant", "significantly",
79
+ "minimal", "minimally", "quickly", "rapidly", "fast", "slow", "timely",
80
+ "approximately", "about", "nearly", "roughly", "several", "some",
81
+ "many", "few", "various", "sufficient", "sufficiently", "adequate",
82
+ "adequately", "reasonable", "reasonably", "appropriate",
83
+ "appropriately", "acceptable", "normal", "typical", "typically",
84
+ "regularly", "periodically", "frequently", "occasionally", "large",
85
+ "small", "high", "low", "quality", "as soon as possible",
86
+ "in real time", "real-time", "instantly", "immediately"]
87
+ category: ambiguous_modifier
88
+ category_label: "Ambiguous adverb or adjective"
89
+ severity: medium
90
+ explanation: >-
91
+ Ambiguous adverbs and adjectives describe a degree without a number or a
92
+ unit, so the threshold that separates pass from fail is undefined.
93
+ question_templates:
94
+ - "What exact number, unit, and tolerance replaces this word?"
95
+ - "At what measured value does this requirement pass, and at what value does it fail?"
96
+ source: ISO29148
97
+
98
+ # --- p.22: "ambiguous logical statements (such as 'or', 'and/or')"
99
+ # NOTE 1: consider multiple requirements when encountering these.
100
+ # Split into two rules: bare 'and' is far more common in ordinary prose,
101
+ # so it carries low severity to keep the signal readable.
102
+ - id: AMB-LOGIC-001
103
+ phrases: ["and/or", "or"]
104
+ category: ambiguous_logic
105
+ category_label: "Ambiguous logical statement"
106
+ severity: medium
107
+ explanation: >-
108
+ Joining conditions with 'or' or 'and/or' leaves it unclear whether one,
109
+ several, or all of them are required. The standard advises splitting
110
+ such a statement into multiple requirements.
111
+ question_templates:
112
+ - "Is this one requirement or several? Split it into one statement per testable condition."
113
+ - "Must every joined condition hold, or is any single one sufficient?"
114
+ source: ISO29148
115
+
116
+ - id: AMB-LOGIC-002
117
+ phrases: ["and"]
118
+ category: ambiguous_logic
119
+ category_label: "Ambiguous logical statement"
120
+ severity: low
121
+ explanation: >-
122
+ A requirement joined by 'and' often hides two separate requirements,
123
+ each of which needs its own acceptance test.
124
+ question_templates:
125
+ - "Does this sentence contain more than one testable requirement? If so, split it."
126
+ source: ISO29148
127
+
128
+ # --- p.22: "open-ended, non-verifiable terms (such as 'provide support',
129
+ # 'but not limited to', 'as a minimum')" ---------------------
130
+ - id: AMB-OPEN-001
131
+ phrases:
132
+ ["provide support", "support for", "including but not limited to",
133
+ "but not limited to", "as a minimum", "at a minimum", "and so on",
134
+ "such as", "among others", "to be determined", "tbd", "etc.", "etc",
135
+ "and more", "e.g.", "for example"]
136
+ category: open_ended
137
+ category_label: "Open-ended, non-verifiable term"
138
+ severity: high
139
+ explanation: >-
140
+ Open-ended terms leave the boundary of the requirement undefined, so it
141
+ can never be shown to be completely implemented.
142
+ question_templates:
143
+ - "List every item this covers. What is explicitly excluded?"
144
+ - "What is the complete, closed set of cases this requirement applies to?"
145
+ source: ISO29148
146
+
147
+ # --- p.22: "comparative phrases (such as 'better than', 'higher quality')"
148
+ - id: AMB-COMP-001
149
+ phrases:
150
+ ["better than", "worse than", "higher quality", "lower cost",
151
+ "faster than", "slower than", "more than before", "improved",
152
+ "enhanced", "superior", "at least as good as", "comparable to",
153
+ "similar to", "as good as"]
154
+ category: comparative
155
+ category_label: "Comparative phrase"
156
+ severity: high
157
+ explanation: >-
158
+ A comparative phrase needs both a named reference point and a
159
+ measurement method. Without both, the comparison cannot be verified.
160
+ question_templates:
161
+ - "Better than what, measured how, and by how much?"
162
+ - "What is the reference system or previous value being compared against?"
163
+ source: ISO29148
164
+
165
+ # --- p.22: "loopholes (such as 'if possible', 'as appropriate',
166
+ # 'as applicable')" ---------------------------------------
167
+ - id: AMB-LOOP-001
168
+ phrases:
169
+ ["if possible", "if needed", "if necessary", "if required",
170
+ "as appropriate", "as applicable", "as required", "as needed",
171
+ "where feasible", "where possible", "to the extent practical",
172
+ "to the extent possible", "optionally", "preferably", "ideally"]
173
+ category: loophole
174
+ category_label: "Loophole"
175
+ severity: high
176
+ explanation: >-
177
+ A loophole makes the requirement conditional on unstated judgement,
178
+ which lets an implementation skip it entirely and still claim conformance.
179
+ question_templates:
180
+ - "Under exactly which stated condition does this apply? Write that condition as the trigger."
181
+ - "If the condition is not met, what must the system do instead?"
182
+ source: ISO29148
183
+
184
+ # --- p.22: "terms that imply totality (such as 'all', 'always', 'never',
185
+ # 'every')". NOTE 2: very difficult to verify. -----------------
186
+ - id: AMB-TOTAL-001
187
+ phrases:
188
+ ["all", "always", "never", "every", "none", "any", "everything",
189
+ "nothing", "anyone", "everyone", "each and every", "at all times",
190
+ "100% of the time", "under all circumstances", "completely", "fully",
191
+ "totally", "entirely"]
192
+ category: totality
193
+ category_label: "Term implying totality"
194
+ severity: medium
195
+ explanation: >-
196
+ Terms implying totality are very difficult to verify, because exhaustive
197
+ testing of every possible case is usually impossible.
198
+ question_templates:
199
+ - "Which finite, enumerable set does this actually cover, and how will it be tested?"
200
+ - "Can this be restated as a measurable rate or a bounded set instead of an absolute?"
201
+ source: ISO29148
202
+
203
+ # --- p.12 "Unambiguous", and the shall/should distinction the standard
204
+ # relies on throughout. ---------------------------------------------
205
+ - id: AMB-MODAL-001
206
+ phrases:
207
+ ["should", "may", "might", "could", "can", "would", "will be able to",
208
+ "is expected to", "are expected to", "ought to", "is supposed to"]
209
+ category: weak_modal
210
+ category_label: "Weak modal verb"
211
+ severity: medium
212
+ explanation: >-
213
+ Only 'shall' expresses a binding requirement. A weak modal leaves it
214
+ unclear whether the behaviour is mandatory, so it may never be
215
+ implemented or tested.
216
+ question_templates:
217
+ - "Is this mandatory? If so, replace the modal verb with 'shall'."
218
+ - "If it is genuinely optional, move it out of the requirements into a note."
219
+ source: ISO29148
220
+
221
+ # --- p.22: "incomplete references (not specifying the reference with its
222
+ # date and version number)" ---------------------------------
223
+ - id: AMB-REF-001
224
+ phrases:
225
+ ["the standard", "the specification", "the document", "the manual",
226
+ "applicable standards", "relevant standards", "industry standard",
227
+ "the guidelines", "per the spec", "see documentation"]
228
+ category: incomplete_reference
229
+ category_label: "Incomplete reference"
230
+ severity: medium
231
+ explanation: >-
232
+ A reference without its title, date, and version number cannot be
233
+ resolved to one exact document, so the scope of verification is unclear.
234
+ question_templates:
235
+ - "Which exact document, in which dated version, and which clause within it?"
236
+ - "Which specific parts of that reference apply to this requirement?"
237
+ source: ISO29148
@@ -0,0 +1,561 @@
1
+ """ReqCheck MCP server.
2
+
3
+ Finds ambiguous terms in a requirements document, checked against
4
+ ISO/IEC/IEEE 29148:2018. Detection is regex over a versioned catalog -- no
5
+ model is involved, so identical text always produces identical findings, each
6
+ traceable to a numbered rule and a clause of the standard.
7
+
8
+ Everything lives in this one module on purpose. A client may launch this file
9
+ directly (`mcp run server.py`, where there is no package around it) or through
10
+ the installed console script (`uvx mcp-reqcheck`, where there is). Imports
11
+ between sibling files resolve differently in those two cases, so this file
12
+ imports nothing of its own and both launch styles behave identically.
13
+
14
+ The rule catalog stays in ambiguity_rules.yaml beside this file: it is data,
15
+ not code, and is read at import.
16
+
17
+ The catalog and analyser below are copied from apps/api in the ReqCheck web
18
+ application. Rule changes made there need copying here too.
19
+ """
20
+
21
+ # Deliberately no `from __future__ import annotations`. It turns every
22
+ # annotation into a string, and dataclasses then resolves those strings via
23
+ # sys.modules[cls.__module__] -- which is None when `mcp run <file>` loads
24
+ # this file directly without registering it as a module. Python 3.13
25
+ # evaluates these annotations natively, so nothing is lost.
26
+
27
+ import re
28
+ from dataclasses import dataclass
29
+ from re import Pattern
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ import yaml
34
+ from mcp.server import MCPServer
35
+
36
+
37
+ # --------------------------------------------------------------- records
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Statement:
42
+ """One requirement sentence, with absolute offsets into the document."""
43
+
44
+ index: int
45
+ text: str
46
+ start_offset: int
47
+ end_offset: int
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Finding:
52
+ """An exact-span ambiguity issue tied to one catalog rule."""
53
+
54
+ rule_id: str
55
+ category: str
56
+ severity: str
57
+ explanation: str
58
+ source_id: str
59
+ start_offset: int
60
+ end_offset: int
61
+ matched_text: str
62
+ statement_index: int
63
+ id: str | None = None
64
+ status: str = "open"
65
+
66
+ @property
67
+ def length(self) -> int:
68
+ return self.end_offset - self.start_offset
69
+
70
+
71
+ # --------------------------------------------------------------- catalog
72
+
73
+
74
+ VALID_SEVERITIES = {"low", "medium", "high"}
75
+ DEFAULT_CATALOG_PATH = Path(__file__).parent / "ambiguity_rules.yaml"
76
+
77
+ REQUIRED_FIELDS = (
78
+ "id",
79
+ "phrases",
80
+ "category",
81
+ "severity",
82
+ "explanation",
83
+ "question_templates",
84
+ "source",
85
+ )
86
+
87
+
88
+ class CatalogError(Exception):
89
+ """Raised when the YAML catalog is missing, malformed, or inconsistent."""
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ #dataclass here is used to create a simple class that holds data. The frozen=True parameter makes the class immutable, meaning that its attributes cannot be changed after it is created.
94
+ class AmbiguityRule:
95
+ id: str
96
+ phrases: tuple[str, ...]
97
+ category: str
98
+ category_label: str
99
+ severity: str
100
+ explanation: str
101
+ question_templates: tuple[str, ...]
102
+ source: str
103
+ # Annotated without the `re.` prefix: dataclasses resolves dotted
104
+ # annotations through sys.modules, which `mcp run <file>` does not
105
+ # populate for a directly-loaded file.
106
+ pattern: Pattern[str]
107
+
108
+
109
+ class AmbiguityRuleCatalog:
110
+ """C2 - loads, validates, compiles, and serves the versioned rule catalog.
111
+
112
+ Behaviour this class owns:
113
+ - reads a versioned YAML catalog and refuses to load a broken one
114
+ - rejects duplicate rule ids, invalid severities, and unknown source ids
115
+ - compiles each rule's phrase list into one word-boundary regex, with
116
+ longer phrases taking precedence over shorter ones
117
+ - serves explanations, severities, sources, and question templates to
118
+ C3 (detection) and C5 (coaching)
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ catalog_version: str,
124
+ rules: list[AmbiguityRule],
125
+ sources: dict[str, dict[str, str]],
126
+ ) -> None:
127
+ self._catalog_version = catalog_version
128
+ self._rules = rules
129
+ self._sources = sources
130
+ self._by_id = {rule.id: rule for rule in rules}
131
+
132
+ # ---------------------------------------------------------------- load
133
+
134
+ @classmethod
135
+ # a classmethod is used here so that the catalog can be loaded without instantiating the class
136
+ def from_yaml(cls, path: Path | None = None) -> "AmbiguityRuleCatalog":
137
+ path = path or DEFAULT_CATALOG_PATH
138
+ if not path.exists():
139
+ raise CatalogError(f"Catalog file not found: {path}")
140
+
141
+ try:
142
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
143
+ except yaml.YAMLError as exc:
144
+ raise CatalogError(f"Catalog is not valid YAML: {exc}") from exc
145
+
146
+ version = raw.get("catalog_version")
147
+ if not version:
148
+ raise CatalogError("catalog_version is required")
149
+
150
+ sources = raw.get("sources") or {}
151
+ if not sources:
152
+ raise CatalogError("at least one source is required")
153
+
154
+ entries = raw.get("rules") or []
155
+ if not entries:
156
+ raise CatalogError("catalog contains no rules")
157
+
158
+ rules: list[AmbiguityRule] = []
159
+ seen: set[str] = set()
160
+ for entry in entries:
161
+ rule = cls._build_rule(entry, sources)
162
+ #cls here is used to refer to the class itself.
163
+ if rule.id in seen:
164
+ raise CatalogError(f"duplicate rule id: {rule.id}")
165
+ seen.add(rule.id)
166
+ rules.append(rule)
167
+
168
+ return cls(str(version), rules, sources)
169
+
170
+ @staticmethod
171
+ def _build_rule(
172
+ entry: dict, sources: dict[str, dict[str, str]]
173
+ ) -> AmbiguityRule:
174
+ # isinstance here is used to check if the entry is a dictionary. If it is not, a CatalogError is raised.
175
+ if not isinstance(entry, dict):
176
+ raise CatalogError(f"rule entry is not a mapping: {entry!r}")
177
+
178
+ rule_id = entry.get("id", "<unknown>")
179
+ for field_name in REQUIRED_FIELDS:
180
+ if not entry.get(field_name):
181
+ raise CatalogError(f"rule {rule_id} is missing '{field_name}'")
182
+
183
+ severity = entry["severity"]
184
+ if severity not in VALID_SEVERITIES:
185
+ raise CatalogError(
186
+ f"rule {rule_id} has invalid severity '{severity}' "
187
+ f"(expected one of {sorted(VALID_SEVERITIES)})"
188
+ )
189
+
190
+ source = entry["source"]
191
+ if source not in sources:
192
+ raise CatalogError(f"rule {rule_id} cites unknown source '{source}'")
193
+
194
+ # Longest phrase first, so 'and/or' beats 'or' and
195
+ # 'including but not limited to' beats 'but not limited to'.
196
+ phrases = sorted(
197
+ {p.strip().lower() for p in entry["phrases"] if str(p).strip()},
198
+ #key=len here is used to sort the phrases by length in descending order.
199
+ key=len,
200
+ reverse=True,
201
+ )
202
+ if not phrases:
203
+ raise CatalogError(f"rule {rule_id} has no usable phrases")
204
+
205
+ # (?<!\w) and (?!\w) act as word boundaries that still work when a
206
+ # phrase begins or ends with punctuation, such as 'etc.' or 'e.g.'.
207
+ # Plain \b would fail on those because '.' is not a word character.
208
+
209
+ #pattern here simply is to compile the phrases into a regular expression pattern that can be used to match the phrases in the text.
210
+ #This allows for matching phrases that may contain punctuation or special characters.
211
+ alternation = "|".join(re.escape(p) for p in phrases)
212
+ try:
213
+ pattern = re.compile(rf"(?<!\w)(?:{alternation})(?!\w)", re.IGNORECASE)
214
+ except re.error as exc:
215
+ raise CatalogError(f"rule {rule_id} produced an invalid pattern: {exc}") from exc
216
+
217
+ # Human-readable name shown in the UI. Falls back to a readable form
218
+ # of the slug so a rule is never displayed as raw snake_case.
219
+ category_label = entry.get("category_label") or entry["category"].replace("_", " ").capitalize()
220
+
221
+ return AmbiguityRule(
222
+ id=entry["id"],
223
+ phrases=tuple(phrases),
224
+ category=entry["category"],
225
+ category_label=category_label,
226
+ severity=severity,
227
+ explanation=" ".join(str(entry["explanation"]).split()),
228
+ question_templates=tuple(entry["question_templates"]),
229
+ source=source,
230
+ pattern=pattern,
231
+ #pattern here is used to compile the phrases into a regular expression pattern that can be used to match the phrases in the text.
232
+ # pattern replaces the phrases.
233
+ )
234
+
235
+ # ---------------------------------------------------------------- query
236
+ #property here is used to define a read-only attribute that can be accessed like a regular attribute, but is computed on-the-fly when accessed. In this case, it returns the catalog version of the rule catalog.
237
+ @property
238
+ def catalog_version(self) -> str:
239
+ return self._catalog_version
240
+
241
+ @property
242
+ def rules(self) -> list[AmbiguityRule]:
243
+ return list(self._rules)
244
+
245
+ def get(self, rule_id: str) -> AmbiguityRule:
246
+ if rule_id not in self._by_id:
247
+ raise CatalogError(f"unknown rule id: {rule_id}")
248
+ return self._by_id[rule_id]
249
+
250
+ def question_templates_for(self, rule_id: str) -> list[str]:
251
+ return list(self.get(rule_id).question_templates)
252
+
253
+ def source_for(self, rule_id: str) -> dict[str, str]:
254
+ return dict(self._sources.get(self.get(rule_id).source, {}))
255
+
256
+ def display_for(self, rule_id: str) -> dict[str, str]:
257
+ """Human-readable labels for the UI.
258
+
259
+ Internal identifiers such as AMB-SUBJ-001 and 'subjective_language'
260
+ are traceability data, not something a student should have to decode.
261
+ """
262
+ try:
263
+ rule = self.get(rule_id)
264
+ except CatalogError:
265
+ # A finding stored under a rule that has since left the catalog.
266
+ return {
267
+ "category_label": "Unknown category",
268
+ "source_label": "Unknown source",
269
+ "source_clause": "",
270
+ }
271
+ source = self._sources.get(rule.source, {})
272
+ short = source.get("short_name") or source.get("title") or rule.source
273
+ clause = source.get("clause", "")
274
+ return {
275
+ "category_label": rule.category_label,
276
+ "source_label": f"Cited from {short}",
277
+ "source_clause": clause,
278
+ }
279
+
280
+ def describe(self) -> dict[str, object]:
281
+ """Summary used by the UI to show which catalog produced a finding."""
282
+ return {
283
+ "catalog_version": self._catalog_version,
284
+ "rule_count": len(self._rules),
285
+ "phrase_count": sum(len(r.phrases) for r in self._rules),
286
+ "sources": self._sources,
287
+ }
288
+
289
+
290
+ # -------------------------------------------------------------- analyser
291
+
292
+
293
+ SEVERITY_RANK = {"high": 0, "medium": 1, "low": 2}
294
+
295
+ # A statement ends at sentence punctuation followed by whitespace, or at any
296
+ # run of newlines (so bulleted or numbered requirement lists split correctly).
297
+ _STATEMENT_BREAK = re.compile(r"(?<=[.!?])\s+|\n+")
298
+
299
+
300
+ class AmbiguityAnalyzer:
301
+ """C3 - deterministic exact-span detection.
302
+
303
+ No AI, no network, no randomness, no clock. The same text and the same
304
+ catalog always produce byte-identical findings, which is what makes the
305
+ detection explainable and reproducible.
306
+
307
+ Behaviour this class owns:
308
+ - segments a document into statements while preserving absolute offsets
309
+ - runs every compiled catalog rule over the text
310
+ - resolves overlaps by a fixed precedence so results never vary
311
+ - computes exact character offsets for highlighting
312
+ """
313
+
314
+ def __init__(self, catalog: AmbiguityRuleCatalog) -> None:
315
+ self._catalog = catalog
316
+
317
+ @property
318
+ def catalog_version(self) -> str:
319
+ return self._catalog.catalog_version
320
+
321
+ # ------------------------------------------------------------ segment
322
+
323
+ def segment(self, text: str) -> list[Statement]:
324
+ """Split text into statements, keeping absolute offsets into `text`.
325
+
326
+ Offsets are computed from match positions rather than by searching for
327
+ each chunk, so repeated identical sentences still get correct, distinct
328
+ offsets.
329
+ """
330
+
331
+ #this method returns all statements(sentence chunks) in the text, with their start and end offsets
332
+ statements: list[Statement] = []
333
+ index = 0
334
+ cursor = 0
335
+
336
+ # separator is the match object for the next statement break, which is either
337
+ # a sentence-ending punctuation followed by whitespace, or any run of newlines.
338
+ for separator in _STATEMENT_BREAK.finditer(text):
339
+ chunk = text[cursor : separator.start()]
340
+ if chunk.strip():
341
+ statements.append(
342
+ Statement(
343
+ index=index,
344
+ text=chunk,
345
+ start_offset=cursor,
346
+ end_offset=separator.start(),
347
+ )
348
+ )
349
+ index += 1
350
+ cursor = separator.end()
351
+
352
+ tail = text[cursor:]
353
+ if tail.strip():
354
+ statements.append(
355
+ Statement(
356
+ index=index,
357
+ text=tail,
358
+ start_offset=cursor,
359
+ end_offset=len(text),
360
+ )
361
+ )
362
+
363
+ return statements
364
+
365
+ # ------------------------------------------------------------ analyze
366
+
367
+ def analyze(self, text: str) -> list[Finding]:
368
+ """Return non-overlapping findings, ordered by position."""
369
+ if not text or not text.strip():
370
+ return []
371
+ #statements later is used to find the statement index for each finding, which is needed for context when asking C5 questions.
372
+ statements = self.segment(text)
373
+ candidates: list[Finding] = []
374
+
375
+ for rule in self._catalog.rules:
376
+ # instead of phrases, use patterns for actual phrases as specified in rule_catalog
377
+ # this for loop is to find the matches of the rule's pattern in the text and create Finding objects for each match
378
+ for match in rule.pattern.finditer(text):
379
+ start, end = match.start(), match.end()
380
+ candidates.append(
381
+ Finding(
382
+ rule_id=rule.id,
383
+ category=rule.category,
384
+ severity=rule.severity,
385
+ explanation=rule.explanation,
386
+ source_id=rule.source,
387
+ start_offset=start,
388
+ end_offset=end,
389
+ matched_text=text[start:end],
390
+ statement_index=self._statement_index_for(statements, start),
391
+ # for each finding, we find the statement index that contains the start offset of the match. This is used for context when asking C5 questions.
392
+ )
393
+ )
394
+
395
+ # Fixed precedence, so the result never depends on rule ordering:
396
+ # 1. earliest start position
397
+ # 2. longest span ('as a minimum' beats 'minimum')
398
+ # 3. highest severity (high beats medium beats low)
399
+ # 4. rule id (alphabetical, purely to break remaining ties)
400
+ candidates.sort(
401
+ key=lambda f: (
402
+ f.start_offset,
403
+ -f.length,
404
+ SEVERITY_RANK[f.severity],
405
+ f.rule_id,
406
+ )
407
+ )
408
+
409
+ #this for loop is just ot make sure that the findings do not overlap. If a finding overlaps with the previous one, it is discarded.
410
+ accepted: list[Finding] = []
411
+ last_end = -1
412
+ for finding in candidates:
413
+ if finding.start_offset >= last_end:
414
+ accepted.append(finding)
415
+ last_end = finding.end_offset
416
+ return accepted
417
+
418
+
419
+ def statement_for_offset(self, text: str, offset: int) -> str:
420
+ """The full statement containing `offset`. Used by C5 for prompt context."""
421
+ for statement in self.segment(text):
422
+ if statement.start_offset <= offset < statement.end_offset:
423
+ return statement.text.strip()
424
+ return text.strip()
425
+
426
+ @staticmethod
427
+ def _statement_index_for(statements: list[Statement], offset: int) -> int:
428
+ for statement in statements:
429
+ if statement.start_offset <= offset < statement.end_offset:
430
+ return statement.index
431
+ return 0
432
+
433
+
434
+ # ------------------------------------------------------------ mcp server
435
+
436
+
437
+ mcp = MCPServer("ReqCheck")
438
+
439
+ # Built once at import. Loading is pure file IO and the catalog is read-only,
440
+ # so every request reuses the compiled patterns.
441
+ _catalog = AmbiguityRuleCatalog.from_yaml()
442
+ _analyzer = AmbiguityAnalyzer(_catalog)
443
+
444
+
445
+ @mcp.tool()
446
+ def ambiguity_analysis(text: str) -> dict[str, Any]:
447
+ """Find ambiguous terms in a requirements document, checked against
448
+ ISO/IEC/IEEE 29148:2018.
449
+
450
+ Detection is fully deterministic: the same text always returns the same
451
+ findings, with no model involved and every finding traceable to a numbered
452
+ rule and a clause of the standard.
453
+
454
+ Each finding carries the flagged term, the statement it appears in, why it
455
+ was flagged, the standard it came from, and question templates for that
456
+ rule.
457
+
458
+ Use the question templates to ask the user what they actually mean --
459
+ what measurable value, what baseline, whose judgement. Do not rewrite the
460
+ requirement for them; the point is that they decide the wording.
461
+
462
+ Args:
463
+ text: The requirements document. Plain text -- extract it first if the
464
+ source is a PDF or Word file.
465
+ """
466
+ findings = _analyzer.analyze(text)
467
+ # Segment once and index it, rather than re-scanning the document for every
468
+ # finding, which would be quadratic on a long document.
469
+ statements = {s.index: s.text.strip() for s in _analyzer.segment(text)}
470
+
471
+ results: list[dict[str, Any]] = []
472
+ for finding in findings:
473
+ display = _catalog.display_for(finding.rule_id)
474
+ results.append({
475
+ "matched_text": finding.matched_text,
476
+ "statement": statements.get(finding.statement_index, ""),
477
+ "statement_index": finding.statement_index,
478
+ "rule_id": finding.rule_id,
479
+ "category": finding.category,
480
+ "category_label": display["category_label"],
481
+ "severity": finding.severity,
482
+ "explanation": finding.explanation,
483
+ "source_id": finding.source_id,
484
+ "source_label": display["source_label"],
485
+ "source_clause": display["source_clause"],
486
+ "question_templates": list(
487
+ _catalog.question_templates_for(finding.rule_id)
488
+ ),
489
+ # Kept because matched_text alone is ambiguous when the same term
490
+ # appears more than once; these identify which occurrence.
491
+ "start_offset": finding.start_offset,
492
+ "end_offset": finding.end_offset,
493
+ })
494
+
495
+ return {
496
+ "catalog_version": _catalog.catalog_version,
497
+ "statement_count": len(statements),
498
+ "finding_count": len(results),
499
+ "findings": results,
500
+ }
501
+
502
+
503
+ @mcp.resource(
504
+ "reqcheck://knowledge-base",
505
+ name="ReqCheck ambiguity rule catalog",
506
+ description=(
507
+ "The full YAML knowledge base: every rule, its phrases, severity, "
508
+ "explanation, question templates, and ISO/IEC/IEEE 29148 citation."
509
+ ),
510
+ mime_type="text/yaml",
511
+ )
512
+ def knowledge_base() -> str:
513
+ """The rule catalog exactly as authored, including source citations."""
514
+ return DEFAULT_CATALOG_PATH.read_text(encoding="utf-8")
515
+
516
+
517
+ @mcp.prompt(
518
+ name="check_requirements",
519
+ title="Check requirements for ambiguity",
520
+ description=(
521
+ "Analyse a requirements document with ReqCheck and report every "
522
+ "ambiguous term with follow-up questions."
523
+ ),
524
+ )
525
+ def check_requirements(text: str) -> str:
526
+ """Ask the client to analyse a document and report findings in a fixed shape."""
527
+ return f"""Use the ReqCheck MCP tool `ambiguity_analysis` to find the ambiguous terms in the requirements below.
528
+
529
+ Report every finding in exactly this format, numbered, one block per finding:
530
+
531
+ **N. <matched_text>**
532
+ - Statement: <statement>
533
+ - Location: statement <statement_index> of the document. If the source was a PDF or Word file, give the page and section instead.
534
+ - Source: <source_label>, <source_clause>
535
+ - Severity: <severity>
536
+ - Category: <category_label>
537
+ - Why: <explanation>
538
+ - Questions to answer:
539
+ 1. ...
540
+ 2. ...
541
+ 3. ...
542
+
543
+ For the questions, adapt the `question_templates` for that finding to this specific statement, using what you know about the document. Ask what measurable value, baseline, or acceptance condition the author means.
544
+
545
+ Do not rewrite the requirement, and do not suggest a number, unit, or threshold yourself -- ask the author for it. Do not show the question templates verbatim.
546
+
547
+ If there are no findings, say the document has no terms matching the catalog and note that this checks wording, not completeness.
548
+
549
+ Here are the requirements:
550
+
551
+ {text}
552
+ """
553
+
554
+
555
+ def main() -> None:
556
+ """Entry point for the `mcp-reqcheck` script and `python server.py`."""
557
+ mcp.run()
558
+
559
+
560
+ if __name__ == "__main__":
561
+ main()