nullprint-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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HENGCHENG LLC
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.
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.5
2
+ Name: nullprint-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Nullprint, the multi-profile browser: create, launch, check and drive browser profiles from any MCP-capable AI agent.
5
+ Project-URL: Homepage, https://nullprint.ai
6
+ Project-URL: Documentation, https://nullprint.ai/docs/mcp/
7
+ Project-URL: Source, https://github.com/nullprintai/nullprint-mcp
8
+ Author-email: Nullprint <support@nullprint.ai>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: automation,browser,fingerprint,mcp,model-context-protocol,nullprint
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: httpx
17
+ Requires-Dist: mcp<2,>=1.27
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # nullprint-mcp
23
+
24
+ An [MCP](https://modelcontextprotocol.io) server for [Nullprint](https://nullprint.ai), the multi-profile browser. It lets any MCP-capable AI agent (Claude Code, Claude Desktop, OpenAI Codex, Kimi, …) create browser profiles, launch them, run consistency check-ups and drive the pages inside a running profile.
25
+
26
+ The server is a thin layer over the local Nullprint daemon's HTTP API: every tool maps to one daemon endpoint. The daemon, the browser kernels and the fingerprint engine ship with the Nullprint desktop app and are not part of this package.
27
+
28
+ ## Requirements
29
+
30
+ - Nullprint desktop app installed and running (Windows or macOS), signed in. Download: https://nullprint.ai
31
+ - Python 3.11+ on the same machine. The easiest way to run the server is [uv](https://docs.astral.sh/uv/), which installs the package on first use.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ # Claude Code
37
+ claude mcp add nullprint -- uvx nullprint-mcp
38
+
39
+ # OpenAI Codex
40
+ codex mcp add nullprint -- uvx nullprint-mcp
41
+ ```
42
+
43
+ Claude Desktop, Kimi and other clients that use an `mcpServers` config file:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "nullprint": {
49
+ "command": "uvx",
50
+ "args": ["nullprint-mcp"]
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ Without uv: `pip install nullprint-mcp`, then use `nullprint-mcp` as the command (no args).
57
+
58
+ No port or key to configure: the server reads the running daemon's port and API key from `~/.nullprint/daemon.json`, which the desktop app writes on start-up. To point at a daemon elsewhere, pass `--daemon-url http://host:port` (or `--port 7801` for localhost) and set `NULLPRINT_API_KEY`. Resolution order, most explicit first:
59
+
60
+ 1. `--daemon-url` / `--port`
61
+ 2. `NULLPRINT_DAEMON_URL`
62
+ 3. `NULLPRINT_PORT`
63
+ 4. `~/.nullprint/daemon.json` (`NULLPRINT_HOME` changes the directory)
64
+
65
+ If the daemon is not running the tools say so plainly instead of throwing a connection trace.
66
+
67
+ ## Tools
68
+
69
+ | Tool | What it does | Daemon endpoint |
70
+ |---|---|---|
71
+ | `profiles_list` | List every profile with status (running / idle), exit and ledger metadata | `GET /profiles` |
72
+ | `profile_start` | Open a browser session for a profile; returns the driving endpoints | `POST /sessions/{name}` |
73
+ | `profile_stop` | Stop that profile's browser (session or app-launched window) | `DELETE /sessions/{name}` → falls back to `POST /profiles/{name}/stop` |
74
+ | `profile_create` | Create a profile (fingerprint generated and frozen), optionally with proxy, ledger and start pages | `POST /profiles` |
75
+ | `profile_checkup` | Consistency check-up, returns `{summary, checks}` | `GET /profiles/{name}/checkup?live=` |
76
+ | `proxy_check` | Stateless proxy test: exit IP, country, latency | `POST /proxy-check` |
77
+ | `profile_delete` | Soft delete to the recycle bin (recoverable); refused while running | `DELETE /profiles/{name}` |
78
+ | `trash_list` | Read-only list of recoverable profiles in the recycle bin | `GET /trash` |
79
+ | `profile_restore` | Restore from the recycle bin with fingerprint, proxy and logins intact | `POST /trash/{name}/restore` |
80
+ | `session_goto` | Navigate the session's current page | `POST /sessions/{name}/goto` |
81
+ | `session_snapshot` | Accessibility tree of the current page (find selectors, read state) | `GET /sessions/{name}/snapshot` |
82
+ | `session_click_selector` | Click an element by CSS selector | `POST /sessions/{name}/click_selector` |
83
+ | `session_fill_selector` | Fill an input by CSS selector | `POST /sessions/{name}/fill_selector` |
84
+ | `session_press` | Send a key (e.g. `Enter`) | `POST /sessions/{name}/press` |
85
+ | `session_eval` | Run JavaScript on the page, returns `{ok, result}` | `POST /sessions/{name}/eval` |
86
+ | `session_tabs` | List the session's tabs and the active one | `GET /sessions/{name}/tabs` |
87
+ | `session_switch_tab` | Bring a tab to the front | `POST /sessions/{name}/switch_tab` |
88
+
89
+ Notes that matter when writing prompts:
90
+
91
+ - **`session_*` tools need an open session.** They drive the warm session opened by `profile_start`; calling them on a profile without one returns 409. Selectors are CSS, not XPath or role/name; call `session_snapshot` first when you do not know a selector.
92
+ - **`session_eval` returns an envelope.** A page-side JavaScript error comes back as data (`{ok: false, error, error_type}`), not as a tool failure; `result` follows the expression's type.
93
+ - **`profile_stop` has two levels.** A profile can be "running" as a warm session or as a window launched from the app. The tool tries the session first and falls back to the app-level stop, which is idempotent for idle profiles. It returns `{name, stopped, closed}`.
94
+ - **`profile_create` does not hard-code a kernel.** Leave `engine` empty to use the app's default; unknown values come back as a 422 listing the options.
95
+ - **`proxy_check` failures are data.** An unreachable proxy returns `{ok: false, error}`; only an unreachable daemon raises.
96
+ - **`profile_start` returns `cdp_endpoint`** (a `ws://` DevTools address) for Chrome kernels, so external tools can attach. A session driven from outside bypasses Nullprint's consistency guards: an outside tool can set a viewport, language or time zone that contradicts the profile's frozen fingerprint and make it detectable. Prefer the `session_*` tools; if you must attach, do not change anything the profile has not declared.
97
+
98
+ ## What is deliberately not exposed
99
+
100
+ Permanently deleting a profile (`DELETE /trash/{name}`) destroys its logins and cannot be undone. It is not a tool on purpose: emptying the recycle bin is a human action in the desktop app. `profile_delete`'s description tells the agent to say so instead of working around it, and a test scans every tool's source to keep the purge endpoint out. Screenshot-to-disk and file-writing tools are left out for the same reason (local path safety).
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ pip install -e ".[dev]"
106
+ pytest tests
107
+ nullprint-mcp --help
108
+ ```
109
+
110
+ Tools live in `nullprint_mcp/tools.py` (the `TOOLS` tuple); `server.py` and `__main__.py` do not change when a tool is added. Nothing may be printed to stdout: stdout is the MCP channel.
111
+
112
+ ## License
113
+
114
+ MIT. Copyright (c) 2026 HENGCHENG LLC.
115
+
116
+ ---
117
+
118
+ ## 中文说明
119
+
120
+ 这是 [Nullprint](https://nullprint.ai)(多身份指纹浏览器)的 MCP 服务:Claude Code、Claude Desktop、OpenAI Codex、Kimi 等任何支持 MCP 的 AI 都可以用它建身份、启动浏览器、做一致性体检、驱动身份里的网页。它只是本地 daemon HTTP 接口上的薄薄一层,每个工具对应 daemon 的一个端点;daemon、浏览器内核和指纹引擎随 Nullprint 桌面应用发布,不在本包内。
121
+
122
+ **前提**:本机装好并登录 Nullprint 桌面应用;本机有 Python 3.11+,推荐用 [uv](https://docs.astral.sh/uv/)(首次运行自动装包)。
123
+
124
+ **接入**:
125
+
126
+ ```bash
127
+ claude mcp add nullprint -- uvx nullprint-mcp # Claude Code
128
+ codex mcp add nullprint -- uvx nullprint-mcp # OpenAI Codex
129
+ ```
130
+
131
+ Claude Desktop、Kimi 等走配置文件的客户端,在 `mcpServers` 里加 `{"command": "uvx", "args": ["nullprint-mcp"]}`。没有 uv 就 `pip install nullprint-mcp`,命令改为 `nullprint-mcp`。
132
+
133
+ 不用配端口和密钥:服务自动读桌面应用写在 `~/.nullprint/daemon.json` 里的端口和 API Key。要连别的机器上的 daemon,传 `--daemon-url` 并设 `NULLPRINT_API_KEY`。
134
+
135
+ **17 个工具**:`profiles_list`、`profile_start`、`profile_stop`、`profile_create`、`profile_checkup`、`proxy_check`、`profile_delete`、`trash_list`、`profile_restore`,以及 8 个会话工具 `session_goto / snapshot / click_selector / fill_selector / press / eval / tabs / switch_tab`(先 `profile_start` 再用)。彻底删除身份故意不做成工具:清空回收站只能在桌面应用里由人操作。
@@ -0,0 +1,114 @@
1
+ # nullprint-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server for [Nullprint](https://nullprint.ai), the multi-profile browser. It lets any MCP-capable AI agent (Claude Code, Claude Desktop, OpenAI Codex, Kimi, …) create browser profiles, launch them, run consistency check-ups and drive the pages inside a running profile.
4
+
5
+ The server is a thin layer over the local Nullprint daemon's HTTP API: every tool maps to one daemon endpoint. The daemon, the browser kernels and the fingerprint engine ship with the Nullprint desktop app and are not part of this package.
6
+
7
+ ## Requirements
8
+
9
+ - Nullprint desktop app installed and running (Windows or macOS), signed in. Download: https://nullprint.ai
10
+ - Python 3.11+ on the same machine. The easiest way to run the server is [uv](https://docs.astral.sh/uv/), which installs the package on first use.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ # Claude Code
16
+ claude mcp add nullprint -- uvx nullprint-mcp
17
+
18
+ # OpenAI Codex
19
+ codex mcp add nullprint -- uvx nullprint-mcp
20
+ ```
21
+
22
+ Claude Desktop, Kimi and other clients that use an `mcpServers` config file:
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "nullprint": {
28
+ "command": "uvx",
29
+ "args": ["nullprint-mcp"]
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ Without uv: `pip install nullprint-mcp`, then use `nullprint-mcp` as the command (no args).
36
+
37
+ No port or key to configure: the server reads the running daemon's port and API key from `~/.nullprint/daemon.json`, which the desktop app writes on start-up. To point at a daemon elsewhere, pass `--daemon-url http://host:port` (or `--port 7801` for localhost) and set `NULLPRINT_API_KEY`. Resolution order, most explicit first:
38
+
39
+ 1. `--daemon-url` / `--port`
40
+ 2. `NULLPRINT_DAEMON_URL`
41
+ 3. `NULLPRINT_PORT`
42
+ 4. `~/.nullprint/daemon.json` (`NULLPRINT_HOME` changes the directory)
43
+
44
+ If the daemon is not running the tools say so plainly instead of throwing a connection trace.
45
+
46
+ ## Tools
47
+
48
+ | Tool | What it does | Daemon endpoint |
49
+ |---|---|---|
50
+ | `profiles_list` | List every profile with status (running / idle), exit and ledger metadata | `GET /profiles` |
51
+ | `profile_start` | Open a browser session for a profile; returns the driving endpoints | `POST /sessions/{name}` |
52
+ | `profile_stop` | Stop that profile's browser (session or app-launched window) | `DELETE /sessions/{name}` → falls back to `POST /profiles/{name}/stop` |
53
+ | `profile_create` | Create a profile (fingerprint generated and frozen), optionally with proxy, ledger and start pages | `POST /profiles` |
54
+ | `profile_checkup` | Consistency check-up, returns `{summary, checks}` | `GET /profiles/{name}/checkup?live=` |
55
+ | `proxy_check` | Stateless proxy test: exit IP, country, latency | `POST /proxy-check` |
56
+ | `profile_delete` | Soft delete to the recycle bin (recoverable); refused while running | `DELETE /profiles/{name}` |
57
+ | `trash_list` | Read-only list of recoverable profiles in the recycle bin | `GET /trash` |
58
+ | `profile_restore` | Restore from the recycle bin with fingerprint, proxy and logins intact | `POST /trash/{name}/restore` |
59
+ | `session_goto` | Navigate the session's current page | `POST /sessions/{name}/goto` |
60
+ | `session_snapshot` | Accessibility tree of the current page (find selectors, read state) | `GET /sessions/{name}/snapshot` |
61
+ | `session_click_selector` | Click an element by CSS selector | `POST /sessions/{name}/click_selector` |
62
+ | `session_fill_selector` | Fill an input by CSS selector | `POST /sessions/{name}/fill_selector` |
63
+ | `session_press` | Send a key (e.g. `Enter`) | `POST /sessions/{name}/press` |
64
+ | `session_eval` | Run JavaScript on the page, returns `{ok, result}` | `POST /sessions/{name}/eval` |
65
+ | `session_tabs` | List the session's tabs and the active one | `GET /sessions/{name}/tabs` |
66
+ | `session_switch_tab` | Bring a tab to the front | `POST /sessions/{name}/switch_tab` |
67
+
68
+ Notes that matter when writing prompts:
69
+
70
+ - **`session_*` tools need an open session.** They drive the warm session opened by `profile_start`; calling them on a profile without one returns 409. Selectors are CSS, not XPath or role/name; call `session_snapshot` first when you do not know a selector.
71
+ - **`session_eval` returns an envelope.** A page-side JavaScript error comes back as data (`{ok: false, error, error_type}`), not as a tool failure; `result` follows the expression's type.
72
+ - **`profile_stop` has two levels.** A profile can be "running" as a warm session or as a window launched from the app. The tool tries the session first and falls back to the app-level stop, which is idempotent for idle profiles. It returns `{name, stopped, closed}`.
73
+ - **`profile_create` does not hard-code a kernel.** Leave `engine` empty to use the app's default; unknown values come back as a 422 listing the options.
74
+ - **`proxy_check` failures are data.** An unreachable proxy returns `{ok: false, error}`; only an unreachable daemon raises.
75
+ - **`profile_start` returns `cdp_endpoint`** (a `ws://` DevTools address) for Chrome kernels, so external tools can attach. A session driven from outside bypasses Nullprint's consistency guards: an outside tool can set a viewport, language or time zone that contradicts the profile's frozen fingerprint and make it detectable. Prefer the `session_*` tools; if you must attach, do not change anything the profile has not declared.
76
+
77
+ ## What is deliberately not exposed
78
+
79
+ Permanently deleting a profile (`DELETE /trash/{name}`) destroys its logins and cannot be undone. It is not a tool on purpose: emptying the recycle bin is a human action in the desktop app. `profile_delete`'s description tells the agent to say so instead of working around it, and a test scans every tool's source to keep the purge endpoint out. Screenshot-to-disk and file-writing tools are left out for the same reason (local path safety).
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ pip install -e ".[dev]"
85
+ pytest tests
86
+ nullprint-mcp --help
87
+ ```
88
+
89
+ Tools live in `nullprint_mcp/tools.py` (the `TOOLS` tuple); `server.py` and `__main__.py` do not change when a tool is added. Nothing may be printed to stdout: stdout is the MCP channel.
90
+
91
+ ## License
92
+
93
+ MIT. Copyright (c) 2026 HENGCHENG LLC.
94
+
95
+ ---
96
+
97
+ ## 中文说明
98
+
99
+ 这是 [Nullprint](https://nullprint.ai)(多身份指纹浏览器)的 MCP 服务:Claude Code、Claude Desktop、OpenAI Codex、Kimi 等任何支持 MCP 的 AI 都可以用它建身份、启动浏览器、做一致性体检、驱动身份里的网页。它只是本地 daemon HTTP 接口上的薄薄一层,每个工具对应 daemon 的一个端点;daemon、浏览器内核和指纹引擎随 Nullprint 桌面应用发布,不在本包内。
100
+
101
+ **前提**:本机装好并登录 Nullprint 桌面应用;本机有 Python 3.11+,推荐用 [uv](https://docs.astral.sh/uv/)(首次运行自动装包)。
102
+
103
+ **接入**:
104
+
105
+ ```bash
106
+ claude mcp add nullprint -- uvx nullprint-mcp # Claude Code
107
+ codex mcp add nullprint -- uvx nullprint-mcp # OpenAI Codex
108
+ ```
109
+
110
+ Claude Desktop、Kimi 等走配置文件的客户端,在 `mcpServers` 里加 `{"command": "uvx", "args": ["nullprint-mcp"]}`。没有 uv 就 `pip install nullprint-mcp`,命令改为 `nullprint-mcp`。
111
+
112
+ 不用配端口和密钥:服务自动读桌面应用写在 `~/.nullprint/daemon.json` 里的端口和 API Key。要连别的机器上的 daemon,传 `--daemon-url` 并设 `NULLPRINT_API_KEY`。
113
+
114
+ **17 个工具**:`profiles_list`、`profile_start`、`profile_stop`、`profile_create`、`profile_checkup`、`proxy_check`、`profile_delete`、`trash_list`、`profile_restore`,以及 8 个会话工具 `session_goto / snapshot / click_selector / fill_selector / press / eval / tabs / switch_tab`(先 `profile_start` 再用)。彻底删除身份故意不做成工具:清空回收站只能在桌面应用里由人操作。
@@ -0,0 +1,11 @@
1
+ """Nullprint MCP server — an open-protocol tool layer over the local daemon.
2
+
3
+ MCP is an open protocol, so any MCP client (Claude Code, OpenAI Codex, Kimi,
4
+ ...) drives Nullprint identities with the same server. This package is a thin
5
+ HTTP client: the daemon owns all behavior, every tool maps onto daemon
6
+ endpoints and passes daemon errors through as readable messages.
7
+
8
+ Run it as `nullprint-mcp` (stdio transport).
9
+ """
10
+
11
+ __all__ = ["daemon", "tools", "server"]
@@ -0,0 +1,39 @@
1
+ """Entry point: `nullprint-mcp` (also `python -m nullprint_mcp`), stdio transport.
2
+
3
+ Nothing may be printed to stdout — stdout IS the MCP channel.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+
9
+ from .daemon import DEFAULT_HOST
10
+ from .server import build_server
11
+
12
+
13
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
14
+ p = argparse.ArgumentParser(
15
+ prog="nullprint-mcp",
16
+ description="Nullprint MCP server (stdio) — drive browser identities from any MCP client.",
17
+ )
18
+ p.add_argument(
19
+ "--daemon-url",
20
+ help="full daemon base URL, e.g. http://127.0.0.1:7801. "
21
+ "Default: read the running daemon's port from ~/.nullprint/daemon.json.",
22
+ )
23
+ p.add_argument("--port", type=int, help=f"shorthand for --daemon-url http://{DEFAULT_HOST}:<port>")
24
+ p.add_argument("--host", default=DEFAULT_HOST, help=f"host to use with --port (default {DEFAULT_HOST})")
25
+ args = p.parse_args(argv)
26
+ if args.daemon_url and args.port:
27
+ p.error("pass either --daemon-url or --port, not both")
28
+ if args.port is not None:
29
+ args.daemon_url = f"http://{args.host}:{args.port}"
30
+ return args
31
+
32
+
33
+ def main(argv: list[str] | None = None) -> None:
34
+ args = _parse_args(argv)
35
+ build_server(args.daemon_url).run(transport="stdio")
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -0,0 +1,160 @@
1
+ """HTTP plumbing for the daemon: where it lives, and how its errors read.
2
+
3
+ The daemon binds a RANDOM free port by default and records it in
4
+ `<runtime dir>/daemon.json` (`NULLPRINT_PORT` pins it instead). There is no
5
+ fixed default port to hard-code, so the address is resolved per call — that
6
+ also means a daemon restart on a new port is picked up without restarting this
7
+ server.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import pathlib
14
+
15
+ import httpx
16
+
17
+ DEFAULT_HOST = "127.0.0.1"
18
+ TIMEOUT = 130 # a headed launch can take a while; same budget the CLI uses
19
+
20
+ _daemon_url: str | None = None # set by --daemon-url / --port at startup
21
+
22
+
23
+ class DaemonError(RuntimeError):
24
+ """Anything that stops a tool from answering: daemon down, or daemon said no.
25
+
26
+ The message is written for whoever reads the tool result — a human or an
27
+ AI client — so it names the address, the status, and what to do next.
28
+ `status` is the HTTP status when the daemon answered, None when it did not,
29
+ so a tool can branch on it (profile_stop falls back on 404/409).
30
+ """
31
+
32
+ def __init__(self, message: str, status: int | None = None):
33
+ super().__init__(message)
34
+ self.status = status
35
+
36
+
37
+ def set_daemon_url(url: str | None) -> None:
38
+ """Pin the daemon address (from --daemon-url / --port). None restores discovery."""
39
+ global _daemon_url
40
+ _daemon_url = url.rstrip("/") if url else None
41
+
42
+
43
+ def runtime_dir() -> pathlib.Path:
44
+ """NULLPRINT_HOME if set, else ~/.nullprint (where the desktop app keeps daemon.json)."""
45
+ home = os.environ.get("NULLPRINT_HOME")
46
+ if home:
47
+ return pathlib.Path(home).expanduser()
48
+ return pathlib.Path.home() / ".nullprint"
49
+
50
+
51
+ def base_url() -> str:
52
+ """Resolve the daemon's base URL, most explicit source first.
53
+
54
+ 1. `--daemon-url` / `--port` (set_daemon_url)
55
+ 2. `NULLPRINT_DAEMON_URL` (`SUPLOGIN_DAEMON_URL` still honoured for old setups)
56
+ 3. `NULLPRINT_PORT` (the daemon's own port pin)
57
+ 4. `<runtime dir>/daemon.json`, which the running daemon writes
58
+ """
59
+ if _daemon_url:
60
+ return _daemon_url
61
+ env_url = os.environ.get("NULLPRINT_DAEMON_URL") or os.environ.get("SUPLOGIN_DAEMON_URL")
62
+ if env_url:
63
+ return env_url.rstrip("/")
64
+ env_port = os.environ.get("NULLPRINT_PORT")
65
+ if env_port:
66
+ return f"http://{DEFAULT_HOST}:{env_port}"
67
+
68
+ path = runtime_dir() / "daemon.json"
69
+ try:
70
+ info = json.loads(path.read_text())
71
+ except OSError:
72
+ raise DaemonError(
73
+ f"the Nullprint daemon does not look like it is running: no {path}. "
74
+ "Start the daemon (the desktop app starts it for you), or point this "
75
+ "server at one with --daemon-url http://127.0.0.1:<port>."
76
+ ) from None
77
+ except ValueError:
78
+ raise DaemonError(
79
+ f"{path} is not valid JSON — the daemon may have been killed mid-write. "
80
+ "Restart the daemon, or pass --daemon-url."
81
+ ) from None
82
+ port = info.get("port")
83
+ if not port:
84
+ raise DaemonError(f"{path} has no 'port' field; restart the daemon or pass --daemon-url.")
85
+ return f"http://{DEFAULT_HOST}:{port}"
86
+
87
+
88
+ def api_key() -> str | None:
89
+ """daemon.json 里的 api_key;NULLPRINT_API_KEY 可覆盖(配合 --daemon-url 指向别的 daemon)。"""
90
+ env = os.environ.get("NULLPRINT_API_KEY")
91
+ if env:
92
+ return env
93
+ try:
94
+ return json.loads((runtime_dir() / "daemon.json").read_text()).get("api_key")
95
+ except (OSError, ValueError):
96
+ return None
97
+
98
+
99
+ _STATUS_HINT = {
100
+ 401: "API key missing or wrong — the daemon.json api_key is read automatically; "
101
+ "set NULLPRINT_API_KEY when using --daemon-url",
102
+ 403: "not authorized (license invalid or profile quota reached)",
103
+ 404: "not found",
104
+ 409: "conflict — it is already in that state, or busy",
105
+ 422: "the daemon rejected the arguments",
106
+ 429: "too many warm sessions; stop one first",
107
+ 502: "the browser worker failed to answer",
108
+ 503: "the daemon could not reach its backend",
109
+ }
110
+
111
+
112
+ def _license_text(d: dict) -> str:
113
+ """The license/quota 403 carries a {reason, limit, used} dict, not a sentence.
114
+ Say it in words — an AI client that reads 'quota_exceeded' should not have to
115
+ guess whether deleting something would help."""
116
+ used, limit = d.get("used"), d.get("limit")
117
+ counts = f" ({used} of {limit} identities used)" if None not in (used, limit) else ""
118
+ if d.get("reason") == "quota_exceeded":
119
+ return ("profile quota reached" + counts +
120
+ " — delete an identity you no longer need, or raise the plan's limit.")
121
+ return (f"this installation is not authorized (license state: {d.get('reason')})"
122
+ + counts + ". Authorize it from the desktop app, then retry.")
123
+
124
+
125
+ def _detail(resp: httpx.Response) -> str:
126
+ """Pull the readable part out of a FastAPI error body."""
127
+ try:
128
+ body = resp.json()
129
+ except ValueError:
130
+ return (resp.text or "").strip()[:400] or "(empty response body)"
131
+ detail = body.get("detail", body) if isinstance(body, dict) else body
132
+ if isinstance(detail, dict) and detail.get("reason") in ("api_key_required", "invalid_api_key"):
133
+ return detail["reason"] # 401 的说明已在 _STATUS_HINT 里,别当成授权问题
134
+ if isinstance(detail, dict) and "reason" in detail:
135
+ return _license_text(detail)
136
+ return detail if isinstance(detail, str) else json.dumps(detail, ensure_ascii=False)
137
+
138
+
139
+ def request(method: str, path: str, **kw) -> httpx.Response:
140
+ """Call the daemon. Raises DaemonError with a readable message on any failure."""
141
+ url = base_url()
142
+ headers = dict(kw.pop("headers", {}) or {})
143
+ k = api_key()
144
+ if k:
145
+ headers["X-API-Key"] = k
146
+ try:
147
+ with httpx.Client(timeout=TIMEOUT) as client:
148
+ resp = client.request(method, f"{url}{path}", headers=headers, **kw)
149
+ except httpx.HTTPError as e:
150
+ raise DaemonError(
151
+ f"cannot reach the Nullprint daemon at {url} ({type(e).__name__}: {e}). "
152
+ "Check that it is running, or pass --daemon-url with the right address."
153
+ ) from None
154
+ if resp.is_success:
155
+ return resp
156
+ hint = _STATUS_HINT.get(resp.status_code, "the daemon refused the request")
157
+ raise DaemonError(
158
+ f"daemon {method} {path} -> {resp.status_code} ({hint}): {_detail(resp)}",
159
+ status=resp.status_code,
160
+ )
@@ -0,0 +1,29 @@
1
+ """The MCP server object: name, instructions, and the tool registry."""
2
+ from __future__ import annotations
3
+
4
+ from mcp.server.fastmcp import FastMCP
5
+
6
+ from . import daemon
7
+ from .tools import TOOLS
8
+
9
+ SERVER_NAME = "nullprint"
10
+
11
+ INSTRUCTIONS = """Nullprint runs browser identities: each one is a separate browser
12
+ profile with its own frozen fingerprint, its own exit IP, and its own cookies, so
13
+ sites see independent people rather than one machine.
14
+
15
+ Typical flow: profiles_list to find the identity, profile_start to open its
16
+ browser, do the work, profile_stop when finished. Never leave a session open
17
+ after you are done — it holds a warm browser with live login state.
18
+
19
+ Identity names are case-sensitive and come from profiles_list; do not invent them."""
20
+
21
+
22
+ def build_server(daemon_url: str | None = None) -> FastMCP:
23
+ """Construct the server. daemon_url pins the daemon address; None discovers it."""
24
+ if daemon_url is not None:
25
+ daemon.set_daemon_url(daemon_url)
26
+ server = FastMCP(SERVER_NAME, instructions=INSTRUCTIONS)
27
+ for fn in TOOLS:
28
+ server.tool()(fn)
29
+ return server