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 +21 -0
- ndi_mcp-0.1.0/PKG-INFO +41 -0
- ndi_mcp-0.1.0/README.md +15 -0
- ndi_mcp-0.1.0/pyproject.toml +77 -0
- ndi_mcp-0.1.0/pyproject.toml.orig +73 -0
- ndi_mcp-0.1.0/src/ndi_mcp/__init__.py +12 -0
- ndi_mcp-0.1.0/src/ndi_mcp/_cli.py +39 -0
- ndi_mcp-0.1.0/src/ndi_mcp/_credentials.py +297 -0
- ndi_mcp-0.1.0/src/ndi_mcp/_docs.py +40 -0
- ndi_mcp-0.1.0/src/ndi_mcp/_documents.py +205 -0
- ndi_mcp-0.1.0/src/ndi_mcp/_jobs.py +98 -0
- ndi_mcp-0.1.0/src/ndi_mcp/_local_files.py +30 -0
- ndi_mcp-0.1.0/src/ndi_mcp/_payloads.py +304 -0
- ndi_mcp-0.1.0/src/ndi_mcp/_run.py +21 -0
- ndi_mcp-0.1.0/src/ndi_mcp/_source.py +51 -0
- ndi_mcp-0.1.0/src/ndi_mcp/_workspaces.py +342 -0
- ndi_mcp-0.1.0/src/ndi_mcp/docs/auth.md +20 -0
- ndi_mcp-0.1.0/src/ndi_mcp/docs/classify.md +17 -0
- ndi_mcp-0.1.0/src/ndi_mcp/docs/extract.md +20 -0
- ndi_mcp-0.1.0/src/ndi_mcp/docs/ground.md +15 -0
- ndi_mcp-0.1.0/src/ndi_mcp/docs/jobs.md +13 -0
- ndi_mcp-0.1.0/src/ndi_mcp/docs/parse.md +25 -0
- ndi_mcp-0.1.0/src/ndi_mcp/docs/quickstart.md +21 -0
- ndi_mcp-0.1.0/src/ndi_mcp/docs/split.md +17 -0
- ndi_mcp-0.1.0/src/ndi_mcp/docs/upload.md +22 -0
- ndi_mcp-0.1.0/src/ndi_mcp/docs/workspaces.md +21 -0
- ndi_mcp-0.1.0/src/ndi_mcp/hosted.py +177 -0
- ndi_mcp-0.1.0/src/ndi_mcp/server.py +83 -0
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.
|
ndi_mcp-0.1.0/README.md
ADDED
|
@@ -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
|