ndi-mcp 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.
ndi_mcp-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nace AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ndi_mcp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: ndi-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for NDI (Nace Document Intelligence)
5
+ Keywords: ndi,mcp,document-intelligence,model-context-protocol
6
+ Author: Nace AI
7
+ Author-email: Nace AI <engineering@nace.ai>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: ndi-sdk>=0.7,<1
19
+ Requires-Dist: mcp>=1.28,<2
20
+ Requires-Python: >=3.11, <3.14
21
+ Project-URL: Homepage, https://github.com/nace-ai/audit-app/tree/main/sdk/ndi-mcp
22
+ Project-URL: Repository, https://github.com/nace-ai/audit-app
23
+ Project-URL: Changelog, https://github.com/nace-ai/audit-app/blob/main/sdk/ndi-mcp/CHANGELOG.md
24
+ Project-URL: Issues, https://github.com/nace-ai/audit-app/issues
25
+ Description-Content-Type: text/markdown
26
+
27
+ # NDI MCP
28
+
29
+ Local and hosted [Model Context Protocol](https://modelcontextprotocol.io) servers
30
+ for **NDI** (Nace Document Intelligence). Licensed **MIT**.
31
+
32
+ ```bash
33
+ pip install ndi-mcp
34
+ # or
35
+ uvx ndi-mcp login
36
+ uvx ndi-mcp
37
+ ```
38
+
39
+ Depends on the Apache-2.0 [`ndi-sdk`](https://pypi.org/project/ndi-sdk/) client.
40
+ Install and agent config details live in that package's README; prefer
41
+ `uvx ndi-mcp` over the older `ndi-sdk[mcp]` install string.
@@ -0,0 +1,15 @@
1
+ # NDI MCP
2
+
3
+ Local and hosted [Model Context Protocol](https://modelcontextprotocol.io) servers
4
+ for **NDI** (Nace Document Intelligence). Licensed **MIT**.
5
+
6
+ ```bash
7
+ pip install ndi-mcp
8
+ # or
9
+ uvx ndi-mcp login
10
+ uvx ndi-mcp
11
+ ```
12
+
13
+ Depends on the Apache-2.0 [`ndi-sdk`](https://pypi.org/project/ndi-sdk/) client.
14
+ Install and agent config details live in that package's README; prefer
15
+ `uvx ndi-mcp` over the older `ndi-sdk[mcp]` install string.
@@ -0,0 +1,77 @@
1
+ [project]
2
+ name = "ndi-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for NDI (Nace Document Intelligence)"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11,<3.14"
9
+ keywords = [
10
+ "ndi",
11
+ "mcp",
12
+ "document-intelligence",
13
+ "model-context-protocol",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "ndi-sdk>=0.7,<1",
27
+ "mcp>=1.28,<2",
28
+ ]
29
+
30
+ [[project.authors]]
31
+ name = "Nace AI"
32
+ email = "engineering@nace.ai"
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/nace-ai/audit-app/tree/main/sdk/ndi-mcp"
36
+ Repository = "https://github.com/nace-ai/audit-app"
37
+ Changelog = "https://github.com/nace-ai/audit-app/blob/main/sdk/ndi-mcp/CHANGELOG.md"
38
+ Issues = "https://github.com/nace-ai/audit-app/issues"
39
+
40
+ [project.scripts]
41
+ ndi-mcp = "ndi_mcp._cli:main"
42
+
43
+ [tool.uv.sources.ndi-sdk]
44
+ workspace = true
45
+
46
+ [tool.ruff]
47
+ target-version = "py311"
48
+ line-length = 130
49
+ fix = true
50
+
51
+ [tool.ruff.lint]
52
+ ignore = [
53
+ "D",
54
+ "ANN",
55
+ "PLR0913",
56
+ ]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+ pythonpath = ["src"]
61
+ asyncio_mode = "auto"
62
+ addopts = [
63
+ "--strict-markers",
64
+ "--tb=short",
65
+ ]
66
+
67
+ [dependency-groups]
68
+ dev = [
69
+ "pyrefly>=1.0.0,<2",
70
+ "pytest>=8.4.0",
71
+ "pytest-asyncio>=1.0.0",
72
+ "ruff>=0.12.0",
73
+ ]
74
+
75
+ [build-system]
76
+ requires = ["uv_build>=0.9.0,<0.10.0"]
77
+ build-backend = "uv_build"
@@ -0,0 +1,73 @@
1
+ [project]
2
+ name = "ndi-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for NDI (Nace Document Intelligence)"
5
+ authors = [
6
+ { name = "Nace AI", email = "engineering@nace.ai" }
7
+ ]
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.11,<3.14"
12
+ keywords = [
13
+ "ndi",
14
+ "mcp",
15
+ "document-intelligence",
16
+ "model-context-protocol",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = [
29
+ "ndi-sdk>=0.7,<1",
30
+ "mcp>=1.28,<2",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/nace-ai/audit-app/tree/main/sdk/ndi-mcp"
35
+ Repository = "https://github.com/nace-ai/audit-app"
36
+ Changelog = "https://github.com/nace-ai/audit-app/blob/main/sdk/ndi-mcp/CHANGELOG.md"
37
+ Issues = "https://github.com/nace-ai/audit-app/issues"
38
+
39
+ [project.scripts]
40
+ ndi-mcp = "ndi_mcp._cli:main"
41
+
42
+ [tool.uv.sources]
43
+ ndi-sdk = { workspace = true }
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "pyrefly>=1.0.0,<2",
48
+ "pytest>=8.4.0",
49
+ "pytest-asyncio>=1.0.0",
50
+ "ruff>=0.12.0",
51
+ ]
52
+
53
+ [build-system]
54
+ requires = ["uv_build>=0.9.0,<0.10.0"]
55
+ build-backend = "uv_build"
56
+
57
+ [tool.ruff]
58
+ target-version = "py311"
59
+ line-length = 130
60
+ fix = true
61
+
62
+ [tool.ruff.lint]
63
+ ignore = [
64
+ "D",
65
+ "ANN",
66
+ "PLR0913",
67
+ ]
68
+
69
+ [tool.pytest.ini_options]
70
+ testpaths = ["tests"]
71
+ pythonpath = ["src"]
72
+ asyncio_mode = "auto"
73
+ addopts = ["--strict-markers", "--tb=short"]
@@ -0,0 +1,12 @@
1
+ """MCP servers for NDI: local over stdio, hosted over Streamable HTTP.
2
+
3
+ Install with ``pip install ndi-mcp`` (MIT). The client library is the Apache-2.0
4
+ ``ndi-sdk`` package.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ndi_mcp.hosted import build_hosted_server
10
+ from ndi_mcp.server import build_server
11
+
12
+ __all__ = ["build_hosted_server", "build_server"]
@@ -0,0 +1,39 @@
1
+ """``ndi-mcp`` entry point: serve over stdio, or ``login`` to store a key."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from ndi_mcp._credentials import login
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> int:
12
+ parser = argparse.ArgumentParser(prog="ndi-mcp", description="NDI MCP server for coding agents.")
13
+ sub = parser.add_subparsers(dest="command")
14
+ login_p = sub.add_parser("login", help="Open a browser to authorize ndi-mcp and save the API key")
15
+ login_p.add_argument(
16
+ "--api-key",
17
+ nargs="?",
18
+ const="",
19
+ default=None,
20
+ metavar="KEY",
21
+ help="Paste a key instead of opening a browser. Omit KEY to type it hidden.",
22
+ )
23
+ args = parser.parse_args(argv)
24
+
25
+ if args.command == "login":
26
+ prompt = args.api_key == ""
27
+ key = args.api_key or None
28
+ path = login(api_key=key, prompt=prompt)
29
+ print(f"saved API key to {path}", file=sys.stderr)
30
+ return 0
31
+
32
+ from ndi_mcp import build_server
33
+
34
+ build_server().run(transport="stdio")
35
+ return 0
36
+
37
+
38
+ if __name__ == "__main__":
39
+ raise SystemExit(main())
@@ -0,0 +1,297 @@
1
+ """Resolve and store the API key the MCP server forwards to NDI.
2
+
3
+ Order: ``$NDI_API_KEY``, then ``~/.ndi/config.toml``. ``$NDI_BASE_URL`` wins
4
+ over a ``base_url`` in the file. A missing key is a startup failure with the
5
+ login hint, not a 401 on the first tool call.
6
+
7
+ ``ndi-mcp login`` opens a browser, polls the device-code endpoints, then writes
8
+ the minted key after a cheap authenticated call so a typo is caught before the
9
+ next agent session. ``$NDI_API_KEY`` and ``--api-key`` skip the browser.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import stat
16
+ import sys
17
+ import tempfile
18
+ import tomllib
19
+ import webbrowser
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass
22
+ from getpass import getpass
23
+ from pathlib import Path
24
+ from time import sleep as sleep_seconds
25
+
26
+ import httpx
27
+
28
+ from ndi_sdk._transport import DEFAULT_TIMEOUT
29
+ from ndi_sdk.client import API_KEY_ENV, BASE_URL_ENV, DEFAULT_BASE_URL, NdiClient
30
+
31
+ CONFIG_PATH_ENV = "NDI_CONFIG_PATH"
32
+ DEFAULT_CONFIG_PATH = Path.home() / ".ndi" / "config.toml"
33
+
34
+ Printer = Callable[[str], None]
35
+ OpenBrowser = Callable[[str], object]
36
+ Sleep = Callable[[float], None]
37
+
38
+
39
+ class CredentialsError(ValueError):
40
+ """No API key could be resolved."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Credentials:
45
+ api_key: str
46
+ base_url: str | None = None
47
+
48
+
49
+ def config_path() -> Path:
50
+ override = os.environ.get(CONFIG_PATH_ENV)
51
+ return Path(override) if override else DEFAULT_CONFIG_PATH
52
+
53
+
54
+ def resolve_credentials() -> Credentials:
55
+ """Use the env var, else the config file.
56
+
57
+ Raises:
58
+ CredentialsError: When neither source has a key.
59
+ """
60
+ api_key = os.environ.get(API_KEY_ENV)
61
+ base_url = os.environ.get(BASE_URL_ENV)
62
+ if not api_key or not base_url:
63
+ file_values = _read_config(config_path(), recover=bool(api_key))
64
+ api_key = api_key or file_values.get("api_key")
65
+ base_url = base_url or file_values.get("base_url")
66
+ if not api_key:
67
+ raise CredentialsError(f"no API key: set ${API_KEY_ENV} or run `ndi-mcp login`")
68
+ return Credentials(api_key=api_key, base_url=base_url)
69
+
70
+
71
+ def login(
72
+ *,
73
+ api_key: str | None = None,
74
+ prompt: bool = False,
75
+ get_pass: Callable[[str], str] = getpass,
76
+ http_client_factory: Callable[[], httpx.Client] | None = None,
77
+ open_browser: OpenBrowser | None = None,
78
+ sleep: Sleep | None = None,
79
+ printer: Printer | None = None,
80
+ ) -> Path:
81
+ """Authorize, validate the key, and write ``~/.ndi/config.toml`` as ``0600``.
82
+
83
+ Default is a browser device-code flow. Pass ``api_key`` (or set
84
+ ``$NDI_API_KEY``) to skip it. ``prompt=True`` types a hidden key on a TTY.
85
+ """
86
+ existing = _read_config(config_path(), recover=True)
87
+ base_url = os.environ.get(BASE_URL_ENV) or existing.get("base_url")
88
+ _reject_control_chars(base_url, field="base_url")
89
+ owned = http_client_factory is None
90
+ http_client = http_client_factory() if http_client_factory is not None else httpx.Client(timeout=DEFAULT_TIMEOUT)
91
+ try:
92
+ key = _resolve_login_key(
93
+ api_key=api_key,
94
+ prompt=prompt,
95
+ get_pass=get_pass,
96
+ http_client=http_client,
97
+ base_url=base_url,
98
+ open_browser=open_browser,
99
+ sleep=sleep,
100
+ printer=printer,
101
+ )
102
+ with NdiClient(api_key=key, base_url=base_url, http_client=http_client) as client:
103
+ client.workspaces.list(limit=1)
104
+ return write_config(api_key=key, base_url=base_url, path=config_path())
105
+ finally:
106
+ if owned:
107
+ http_client.close()
108
+
109
+
110
+ def write_config(*, api_key: str, path: Path | None = None, base_url: str | None = None) -> Path:
111
+ target = path or config_path()
112
+ target.parent.mkdir(parents=True, exist_ok=True)
113
+ existing = _read_config(target, recover=True)
114
+ resolved_base = base_url if base_url is not None else existing.get("base_url")
115
+ _reject_control_chars(resolved_base, field="base_url")
116
+ lines = [f'api_key = "{_escape_toml(api_key)}"\n']
117
+ if resolved_base:
118
+ lines.append(f'base_url = "{_escape_toml(resolved_base)}"\n')
119
+ fd, tmp_name = tempfile.mkstemp(prefix=".ndi-config-", suffix=".toml", dir=target.parent)
120
+ tmp_path = Path(tmp_name)
121
+ try:
122
+ try:
123
+ _chmod_fd(fd)
124
+ _write_all(fd, "".join(lines).encode())
125
+ finally:
126
+ os.close(fd)
127
+ os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
128
+ os.replace(tmp_path, target)
129
+ except BaseException:
130
+ tmp_path.unlink(missing_ok=True)
131
+ raise
132
+ return target
133
+
134
+
135
+ def _resolve_login_key(
136
+ *,
137
+ api_key: str | None,
138
+ prompt: bool,
139
+ get_pass: Callable[[str], str],
140
+ http_client: httpx.Client,
141
+ base_url: str | None,
142
+ open_browser: OpenBrowser | None,
143
+ sleep: Sleep | None,
144
+ printer: Printer | None,
145
+ ) -> str:
146
+ if prompt:
147
+ if not sys.stdin.isatty():
148
+ raise CredentialsError("pass --api-key KEY or run `ndi-mcp login` in a terminal")
149
+ api_key = get_pass("NDI API key: ")
150
+ if api_key is None:
151
+ env_key = os.environ.get(API_KEY_ENV)
152
+ if env_key:
153
+ api_key = env_key
154
+ else:
155
+ return _device_login(
156
+ http_client=http_client,
157
+ base_url=base_url,
158
+ open_browser=open_browser,
159
+ sleep=sleep,
160
+ printer=printer,
161
+ )
162
+ return _require_key(api_key)
163
+
164
+
165
+ def _device_login(
166
+ *,
167
+ http_client: httpx.Client,
168
+ base_url: str | None,
169
+ open_browser: OpenBrowser | None,
170
+ sleep: Sleep | None,
171
+ printer: Printer | None,
172
+ ) -> str:
173
+ host = (base_url or DEFAULT_BASE_URL).rstrip("/")
174
+ try:
175
+ issued = http_client.post(f"{host}/v1/cli/device/code")
176
+ except httpx.HTTPError as exc:
177
+ raise CredentialsError("could not reach NDI; check $NDI_BASE_URL") from exc
178
+ payload = _json_object(issued)
179
+ if issued.status_code != 200:
180
+ raise CredentialsError("could not start login; run `ndi-mcp login` again")
181
+ device_code = payload.get("device_code")
182
+ user_code = payload.get("user_code")
183
+ complete = payload.get("verification_uri_complete")
184
+ if not isinstance(device_code, str) or not isinstance(user_code, str) or not isinstance(complete, str):
185
+ raise CredentialsError("could not start login; run `ndi-mcp login` again")
186
+ interval = _positive_int(payload.get("interval"), default=5)
187
+ expires_in = _positive_int(payload.get("expires_in"), default=600)
188
+ write = printer or (lambda line: print(line, file=sys.stderr))
189
+ write(f"Open {complete}")
190
+ write(f"Confirm this code: {user_code}")
191
+ opener = open_browser or webbrowser.open
192
+ opener(complete)
193
+ wait = sleep or sleep_seconds
194
+ elapsed = 0.0
195
+ poll_wait = interval
196
+ while elapsed < expires_in:
197
+ try:
198
+ token = http_client.post(f"{host}/v1/cli/device/token", json={"device_code": device_code})
199
+ except httpx.HTTPError as exc:
200
+ raise CredentialsError("could not reach NDI; check $NDI_BASE_URL") from exc
201
+ if token.status_code == 200:
202
+ body = _json_object(token)
203
+ key = body.get("api_key")
204
+ if not isinstance(key, str):
205
+ raise CredentialsError("login failed; run `ndi-mcp login` again")
206
+ return _require_key(key)
207
+ error = _device_error(token)
208
+ if error == "authorization_pending":
209
+ wait(poll_wait)
210
+ elapsed += poll_wait
211
+ continue
212
+ if error == "slow_down":
213
+ poll_wait = max(poll_wait * 2, 1)
214
+ wait(poll_wait)
215
+ elapsed += poll_wait
216
+ continue
217
+ raise CredentialsError(_device_error_message(error))
218
+ raise CredentialsError("login expired; run `ndi-mcp login` again")
219
+
220
+
221
+ def _require_key(key: str) -> str:
222
+ if not key:
223
+ raise CredentialsError("API key must not be empty")
224
+ if any(ord(ch) < 32 or ord(ch) == 127 for ch in key):
225
+ raise CredentialsError("API key must not contain control characters")
226
+ return key
227
+
228
+
229
+ def _json_object(response: httpx.Response) -> dict[str, object]:
230
+ try:
231
+ payload = response.json()
232
+ except ValueError:
233
+ return {}
234
+ return payload if isinstance(payload, dict) else {}
235
+
236
+
237
+ def _device_error(response: httpx.Response) -> str:
238
+ error = _json_object(response).get("error")
239
+ return error if isinstance(error, str) else "expired_token"
240
+
241
+
242
+ def _device_error_message(error: str) -> str:
243
+ if error == "access_denied":
244
+ return "login was denied; run `ndi-mcp login` again"
245
+ if error == "expired_token":
246
+ return "login expired; run `ndi-mcp login` again"
247
+ return "login failed; run `ndi-mcp login` again"
248
+
249
+
250
+ def _positive_int(value: object, *, default: int) -> int:
251
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
252
+ return default
253
+ return value
254
+
255
+
256
+ def _chmod_fd(fd: int) -> None:
257
+ chmod = getattr(os, "fchmod", None)
258
+ if callable(chmod):
259
+ chmod(fd, stat.S_IRUSR | stat.S_IWUSR)
260
+
261
+
262
+ def _write_all(fd: int, data: bytes) -> None:
263
+ view = memoryview(data)
264
+ while view:
265
+ written = os.write(fd, view)
266
+ if written == 0:
267
+ raise OSError("short write")
268
+ view = view[written:]
269
+
270
+
271
+ def _read_config(path: Path, *, recover: bool = False) -> dict[str, str]:
272
+ if not path.is_file():
273
+ return {}
274
+ try:
275
+ with path.open("rb") as handle:
276
+ parsed = tomllib.load(handle)
277
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError):
278
+ if recover:
279
+ return {}
280
+ raise
281
+ values: dict[str, str] = {}
282
+ for key in ("api_key", "base_url"):
283
+ value = parsed.get(key)
284
+ if isinstance(value, str) and value:
285
+ values[key] = value
286
+ return values
287
+
288
+
289
+ def _reject_control_chars(value: str | None, *, field: str) -> None:
290
+ if value is None:
291
+ return
292
+ if any(ord(ch) < 32 or ord(ch) == 127 for ch in value):
293
+ raise CredentialsError(f"{field} must not contain control characters")
294
+
295
+
296
+ def _escape_toml(value: str) -> str:
297
+ return value.replace("\\", "\\\\").replace('"', '\\"')
@@ -0,0 +1,40 @@
1
+ """``get_documentation``: current SDK usage, not training memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.resources import files
6
+ from typing import Any
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ from ndi_mcp._payloads import raise_as_tool_error
11
+
12
+ TOPICS = (
13
+ "quickstart",
14
+ "auth",
15
+ "parse",
16
+ "extract",
17
+ "split",
18
+ "classify",
19
+ "ground",
20
+ "upload",
21
+ "workspaces",
22
+ "jobs",
23
+ )
24
+
25
+
26
+ def topic_text(topic: str) -> str:
27
+ if topic not in TOPICS:
28
+ raise ValueError(f"unknown topic {topic!r}; choose one of: {', '.join(TOPICS)}")
29
+ return files("ndi_mcp").joinpath("docs").joinpath(f"{topic}.md").read_text()
30
+
31
+
32
+ def register_doc_tools(mcp: FastMCP) -> None:
33
+ @mcp.tool()
34
+ async def get_documentation(topic: str) -> dict[str, Any]:
35
+ """Return current NDI SDK usage for a topic. Call this before writing integration code."""
36
+ try:
37
+ return {"topic": topic, "text": topic_text(topic)}
38
+ except ValueError as exc:
39
+ raise_as_tool_error(exc)
40
+ raise