nullprint-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
nullprint_mcp/tools.py ADDED
@@ -0,0 +1,457 @@
1
+ """The tools themselves.
2
+
3
+ Plain functions, undecorated: tests call them directly, `server.build_server()`
4
+ registers them. Each one maps onto daemon endpoints and adds nothing but
5
+ argument checking and a readable error.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from . import daemon
10
+ from .daemon import DaemonError
11
+
12
+ # Keys a daemon build may use for the live-debugging endpoint of a session.
13
+ # Accepting several keeps this tool stable whichever name the engine settles on.
14
+ _CDP_KEYS = ("cdp_endpoint", "ws_endpoint", "cdp_url", "webSocketDebuggerUrl")
15
+
16
+ _NO_CDP_NOTE = (
17
+ "This identity's browser core does not publish a CDP endpoint — only the Chrome "
18
+ "core does. Drive it through the daemon's /sessions/{name}/* commands instead "
19
+ "(goto, snapshot, click, type, screenshot, ...)."
20
+ )
21
+
22
+
23
+ def _identity_name(name: str) -> str:
24
+ """Validate an identity name before it becomes a URL path segment."""
25
+ if not isinstance(name, str) or not name.strip():
26
+ raise ValueError("name is required: the identity's name, e.g. 'bing-us'")
27
+ clean = name.strip()
28
+ if "/" in clean or clean in (".", ".."):
29
+ raise ValueError(f"invalid identity name {name!r}: '/' and '.'/'..' are not allowed")
30
+ return clean
31
+
32
+
33
+ def profiles_list() -> list[dict]:
34
+ """List every identity in this installation, with its live status.
35
+
36
+ Each entry carries: name, os, engine (which browser core backs it),
37
+ created, proxy (the exit server only — never credentials), status
38
+ ("running" when a browser is open for it, otherwise "idle"), and meta
39
+ (the local ledger: group, tags, note, last_launched).
40
+
41
+ Call this first to find the exact name to pass to profile_start /
42
+ profile_stop; names are case-sensitive.
43
+ """
44
+ return daemon.request("GET", "/profiles").json()
45
+
46
+
47
+ def profile_start(name: str, headless: bool = False) -> dict:
48
+ """Open a browser session for an identity and return the endpoint to drive it.
49
+
50
+ The identity keeps its frozen fingerprint, its exit proxy, and its logged-in
51
+ cookies — starting it does not re-randomize anything. Starting an identity
52
+ that already has a session open is a no-op that returns the existing one.
53
+
54
+ Args:
55
+ name: identity name, exactly as profiles_list reports it.
56
+ headless: run without a visible window. Default False (headed), which
57
+ is what sites expect and what you want for anything login-shaped.
58
+
59
+ Returns the session record: name, opened_at, idle_seconds, url (the page it
60
+ is on), plus cdp_endpoint — a ws:// DevTools address you can attach your own
61
+ automation to. Only the Chrome browser core publishes one; on the Firefox
62
+ core it is None and cdp_note says what to use instead.
63
+
64
+ Prefer the daemon's own commands over attaching to cdp_endpoint. A session
65
+ driven from outside bypasses Nullprint's consistency guards, so an outside
66
+ tool can set a viewport, locale, timezone, or header that contradicts the
67
+ identity's frozen fingerprint and make it detectable. Attach at your own
68
+ risk, and change nothing the identity did not already claim.
69
+
70
+ Fails with a readable message if the identity does not exist (404), if too
71
+ many sessions are already warm (429), or if the daemon is not running.
72
+ """
73
+ clean = _identity_name(name)
74
+ session = daemon.request(
75
+ "POST", f"/sessions/{clean}", json={"headless": bool(headless)}
76
+ ).json()
77
+
78
+ out = dict(session)
79
+ out["cdp_endpoint"] = next((session[k] for k in _CDP_KEYS if session.get(k)), None)
80
+ if out["cdp_endpoint"] is None:
81
+ out["cdp_note"] = _NO_CDP_NOTE
82
+ return out
83
+
84
+
85
+ def profile_stop(name: str) -> dict:
86
+ """Stop an identity's browser, however it was started.
87
+
88
+ Cookies and login state live in the identity's own storage and survive the
89
+ stop; whatever was typed into an open form does not. Stop identities you are
90
+ done with — a running identity holds a warm browser and keeps its login
91
+ state exposed.
92
+
93
+ Two things can be running for one identity and this closes either: the warm
94
+ session profile_start opens (the daemon drives it), and a window the desktop
95
+ app launched on its own. The session is closed first; if there is none, the
96
+ identity-level stop is used instead, which also terminates a launched window
97
+ and is a no-op on an identity that is already idle.
98
+
99
+ Args:
100
+ name: identity name, exactly as profiles_list reports it.
101
+
102
+ Returns {name, stopped, closed}: `stopped` is False when the identity was
103
+ already idle, and `closed` lists what was actually shut down.
104
+
105
+ Fails with a readable message if the identity does not exist, or if the
106
+ daemon is not running.
107
+ """
108
+ clean = _identity_name(name)
109
+ try:
110
+ daemon.request("DELETE", f"/sessions/{clean}")
111
+ return {"name": clean, "stopped": True, "closed": ["session"]}
112
+ except DaemonError as e:
113
+ # 409 "session not open" is the usual answer for an identity that was
114
+ # launched as a window rather than opened as a session; 404 is allowed
115
+ # for too in case a daemon build reports it that way. Anything else
116
+ # (daemon down, 422, ...) is a real failure and must not be swallowed.
117
+ if e.status not in (404, 409):
118
+ raise
119
+ result = daemon.request("POST", f"/profiles/{clean}/stop").json()
120
+ closed = result.get("stopped") or []
121
+ return {"name": clean, "stopped": bool(closed), "closed": closed,
122
+ "status": result.get("status")}
123
+
124
+
125
+ def profile_create(name: str, engine: str | None = None, proxy: str | None = None,
126
+ group: str | None = None, tags: list[str] | None = None,
127
+ note: str | None = None, urls: list[str] | None = None) -> dict:
128
+ """Create a new identity. Its fingerprint is generated and frozen on the spot.
129
+
130
+ You do not choose the fingerprint — the point of the product is that every
131
+ identity gets one self-consistent, frozen set of traits that stays
132
+ byte-identical across restarts. Do not create identities speculatively: they
133
+ count against the installation's quota.
134
+
135
+ Args:
136
+ name: the new identity's name. Must be unique — creating a name that
137
+ already exists fails rather than overwriting it.
138
+ engine: which browser core backs the identity. Leave unset to use this
139
+ installation's default; an unknown value is rejected with the list
140
+ of accepted ones. The `engine` field in profiles_list shows what
141
+ existing identities use.
142
+ proxy: exit proxy URL, e.g. "http://user:pass@host:port" or
143
+ "socks5://host:port" — any vendor, bring your own. Omit it and the
144
+ identity runs on a direct connection. Test a URL with proxy_check
145
+ before binding it here.
146
+ group, tags, note: the local ledger — which account, email, or purpose
147
+ this identity is bound to. Free text, searchable in the app, and
148
+ returned under `meta` by profiles_list.
149
+ urls: startup tabs — pages opened automatically when the identity starts.
150
+
151
+ Returns {name, os, created}. Fails with a readable message if the name is
152
+ taken (409), if the quota is reached or the installation is unauthorized
153
+ (403), or if an argument is rejected (422).
154
+ """
155
+ clean = _identity_name(name)
156
+ body = {"name": clean, "engine": engine, "proxy": proxy,
157
+ "group": group, "tags": tags, "note": note, "urls": urls}
158
+ return daemon.request(
159
+ "POST", "/profiles", json={k: v for k, v in body.items() if v is not None}
160
+ ).json()
161
+
162
+
163
+ def profile_checkup(name: str, live: bool = False) -> dict:
164
+ """Run the consistency checkup on an identity — is its fingerprint coherent?
165
+
166
+ This is the check competitors cannot make: every trait has one correct value
167
+ given the identity's OS, browser core, and exit IP, so each item gets a
168
+ verdict rather than just being displayed. Run it after changing an
169
+ identity's proxy, and before trusting an identity with anything that matters.
170
+
171
+ Args:
172
+ name: identity name, exactly as profiles_list reports it.
173
+ live: False (default) derives everything from the stored record — fast,
174
+ no side effects. True additionally STARTS THE BROWSER to measure the
175
+ real TLS/JA3 fingerprint and User-Agent against the browser core's
176
+ baseline. That takes tens of seconds and opens a window, so use it
177
+ when you actually need measured proof, not for a routine look.
178
+
179
+ Returns {name, engine, live, summary, checks}. `summary` is "pass", "warn",
180
+ or "fail" — the worst verdict among the checks. Each entry in `checks` has
181
+ an id, a label, a verdict ("pass"/"warn"/"fail"/"skip"), and a detail
182
+ string explaining it. "skip" means the check needs live=True, or does not
183
+ apply (a direct-connection identity has no exit geography to align).
184
+ """
185
+ clean = _identity_name(name)
186
+ return daemon.request(
187
+ "GET", f"/profiles/{clean}/checkup", params={"live": bool(live)}
188
+ ).json()
189
+
190
+
191
+ def proxy_check(url: str) -> dict:
192
+ """Test a proxy URL and report where it actually comes out. No identity needed.
193
+
194
+ Use it before binding a proxy to an identity with profile_create: a dead or
195
+ slow exit found here costs nothing, the same exit found halfway through a
196
+ signup costs the account.
197
+
198
+ Args:
199
+ url: the proxy URL to test, e.g. "http://user:pass@host:port" or
200
+ "socks5://host:port".
201
+
202
+ Returns {ok: true, exit_ip, country, latency_ms} when the proxy works, or
203
+ {ok: false, error} when it does not. A dead proxy is an ANSWER, not a
204
+ failure — check the `ok` field rather than assuming success. Note that
205
+ `country` can be null even on a working proxy if the exit IP cannot be
206
+ geolocated.
207
+
208
+ This tests a bare URL. To test the proxy an identity is already bound to,
209
+ the daemon's per-identity check is a separate endpoint not yet exposed here.
210
+ """
211
+ if not isinstance(url, str) or not url.strip():
212
+ raise ValueError("url is required: the proxy to test, e.g. 'socks5://host:port'")
213
+ return daemon.request("POST", "/proxy-check", json={"url": url.strip()}).json()
214
+
215
+
216
+ def profile_delete(name: str) -> dict:
217
+ """Move an identity to the recycle bin. Recoverable — this is not erasure.
218
+
219
+ The identity's browser storage, cookies and logged-in sessions go to the
220
+ recycle bin intact and profile_restore brings them back unchanged. It stops
221
+ counting against the installation's quota while it sits there, and it no
222
+ longer appears in profiles_list.
223
+
224
+ A running identity is refused (409) rather than killed under itself — stop
225
+ it with profile_stop first, then delete. Deleting a name that is already in
226
+ the recycle bin replaces the older copy, so the older one is lost.
227
+
228
+ There is deliberately NO tool here for permanent deletion. Emptying the
229
+ recycle bin destroys logged-in accounts with no way back, so it is a manual
230
+ action in the desktop app only — a person does it, looking at what they are
231
+ about to lose. Do not look for a way around this; tell the human instead.
232
+
233
+ Args:
234
+ name: identity name, exactly as profiles_list reports it.
235
+
236
+ Fails with a readable message if the identity does not exist (404), or if it
237
+ is still running (409).
238
+ """
239
+ clean = _identity_name(name)
240
+ daemon.request("DELETE", f"/profiles/{clean}")
241
+ return {"name": clean, "deleted": True, "recoverable": True,
242
+ "note": "in the recycle bin; profile_restore brings it back"}
243
+
244
+
245
+ def trash_list() -> list[dict]:
246
+ """List the identities in the recycle bin — what is still recoverable.
247
+
248
+ Read-only. This is how you find out what can be restored: deleted
249
+ identities do not appear in profiles_list, so without this you could only
250
+ restore a name you already knew. Pair it with profile_restore.
251
+
252
+ Each entry carries name, engine, os, created, and deleted_at. Newest
253
+ deletion first. An empty list means the recycle bin is empty.
254
+
255
+ Nothing here is gone yet — everything listed still has its cookies and
256
+ login state and comes back intact via profile_restore.
257
+ """
258
+ return daemon.request("GET", "/trash").json()
259
+
260
+
261
+ def profile_restore(name: str) -> dict:
262
+ """Bring an identity back from the recycle bin, exactly as it was.
263
+
264
+ Its frozen fingerprint, proxy binding, cookies and login state all come
265
+ back — restoring is not re-creating, so nothing is regenerated and no
266
+ logged-in account is lost.
267
+
268
+ Args:
269
+ name: the trashed identity's name.
270
+
271
+ Returns {name, engine, created}. Fails with a readable message if nothing by
272
+ that name is in the recycle bin (404), or if a live identity has taken the
273
+ name since (409) — rename or delete that one first, then restore.
274
+ """
275
+ clean = _identity_name(name)
276
+ return daemon.request("POST", f"/trash/{clean}/restore").json()
277
+
278
+
279
+ def session_goto(name: str, url: str) -> dict:
280
+ """Navigate a session's current page to a URL.
281
+
282
+ Open the identity's session with profile_start first — this drives an
283
+ already-open session, it does not open one itself.
284
+
285
+ Args:
286
+ name: identity name, exactly as profiles_list reports it.
287
+ url: the URL to navigate to, e.g. "https://example.com/login".
288
+
289
+ Returns {url}: the page's URL after navigation. Fails with a readable
290
+ message if the session is not open (409 — call profile_start first) or
291
+ the identity does not exist (404).
292
+ """
293
+ clean = _identity_name(name)
294
+ if not isinstance(url, str) or not url.strip():
295
+ raise ValueError("url is required: the page to navigate to, e.g. 'https://example.com'")
296
+ return daemon.request(
297
+ "POST", f"/sessions/{clean}/goto", json={"url": url.strip()}
298
+ ).json()
299
+
300
+
301
+ def session_snapshot(name: str) -> dict:
302
+ """Take an accessibility-tree snapshot of the session's current page.
303
+
304
+ This is the primary way to see what is on a page before acting on it —
305
+ read it to find the roles/names/selectors to pass to session_click_selector
306
+ or session_fill_selector. Requires a session opened with profile_start first.
307
+
308
+ Args:
309
+ name: identity name, exactly as profiles_list reports it.
310
+
311
+ Returns {snapshot}: the page's accessibility tree as text. Fails with a
312
+ readable message if the session is not open (409 — call profile_start
313
+ first) or the identity does not exist (404).
314
+ """
315
+ clean = _identity_name(name)
316
+ return daemon.request("GET", f"/sessions/{clean}/snapshot").json()
317
+
318
+
319
+ def session_click_selector(name: str, selector: str) -> dict:
320
+ """Click an element on the session's current page, located by a CSS selector.
321
+
322
+ Requires a session opened with profile_start first. `selector` is a CSS
323
+ selector (e.g. "button#submit", "a.nav-link") — not an XPath, not a role/name
324
+ pair (use session_snapshot to find one).
325
+
326
+ Args:
327
+ name: identity name, exactly as profiles_list reports it.
328
+ selector: CSS selector for the element to click.
329
+
330
+ Returns {ok: true}. Fails with a readable message if the session is not
331
+ open (409 — call profile_start first), the identity does not exist (404),
332
+ or the selector never matches / the click times out (502).
333
+ """
334
+ clean = _identity_name(name)
335
+ if not isinstance(selector, str) or not selector.strip():
336
+ raise ValueError("selector is required: a CSS selector, e.g. 'button#submit'")
337
+ return daemon.request(
338
+ "POST", f"/sessions/{clean}/click_selector", json={"selector": selector.strip()}
339
+ ).json()
340
+
341
+
342
+ def session_fill_selector(name: str, selector: str, text: str) -> dict:
343
+ """Fill a text field on the session's current page, located by a CSS selector.
344
+
345
+ Uses Playwright's fill(), which drives React/Vue-controlled inputs correctly
346
+ (setting the DOM value directly does not). Requires a session opened with
347
+ profile_start first. `selector` is a CSS selector, not an XPath.
348
+
349
+ Args:
350
+ name: identity name, exactly as profiles_list reports it.
351
+ selector: CSS selector for the input to fill.
352
+ text: the text to put in the field, replacing whatever was there.
353
+
354
+ Returns {ok: true}. Fails with a readable message if the session is not
355
+ open (409 — call profile_start first), the identity does not exist (404),
356
+ or the selector never matches / the fill times out (502).
357
+ """
358
+ clean = _identity_name(name)
359
+ if not isinstance(selector, str) or not selector.strip():
360
+ raise ValueError("selector is required: a CSS selector, e.g. 'input[name=email]'")
361
+ return daemon.request(
362
+ "POST", f"/sessions/{clean}/fill_selector",
363
+ json={"selector": selector.strip(), "value": text},
364
+ ).json()
365
+
366
+
367
+ def session_press(name: str, key: str) -> dict:
368
+ """Press a keyboard key on the session's current page (whatever is focused).
369
+
370
+ Requires a session opened with profile_start first. Use Playwright key
371
+ names, e.g. "Enter", "Tab", "Escape", "Control+A".
372
+
373
+ Args:
374
+ name: identity name, exactly as profiles_list reports it.
375
+ key: the key (or chord) to press.
376
+
377
+ Returns {ok: true}. Fails with a readable message if the session is not
378
+ open (409 — call profile_start first) or the identity does not exist (404).
379
+ """
380
+ clean = _identity_name(name)
381
+ return daemon.request(
382
+ "POST", f"/sessions/{clean}/press", json={"key": key}
383
+ ).json()
384
+
385
+
386
+ def session_eval(name: str, expr: str) -> dict:
387
+ """Evaluate a JavaScript expression on the session's current page.
388
+
389
+ Requires a session opened with profile_start first. A page-JS error is
390
+ data to read, not a tool failure: the daemon returns {"ok": false,
391
+ "error", "error_type"} instead of raising when the expression throws.
392
+
393
+ Args:
394
+ name: identity name, exactly as profiles_list reports it.
395
+ expr: the JavaScript expression to evaluate in the page, e.g.
396
+ "document.title" or "document.querySelectorAll('a').length".
397
+
398
+ Returns the {ok, result} envelope: {"ok": true, "result": <value>} on
399
+ success. `result` is whatever the expression evaluates to as JSON —
400
+ string, number, boolean, object, array, or null — not necessarily a
401
+ string; do not assume its type. Fails with a readable message (raises,
402
+ rather than the {ok: false} envelope above) if the session is not open
403
+ (409 — call profile_start first) or the identity does not exist (404).
404
+ """
405
+ clean = _identity_name(name)
406
+ if not isinstance(expr, str) or not expr.strip():
407
+ raise ValueError("expr is required: the JavaScript expression to evaluate")
408
+ return daemon.request(
409
+ "POST", f"/sessions/{clean}/eval", json={"expr": expr}
410
+ ).json()
411
+
412
+
413
+ def session_tabs(name: str) -> dict:
414
+ """List the session's open tabs, with the currently active one marked.
415
+
416
+ Requires a session opened with profile_start first. Use this to find the
417
+ index to pass to session_switch_tab.
418
+
419
+ Args:
420
+ name: identity name, exactly as profiles_list reports it.
421
+
422
+ Returns {tabs, active}: `tabs` is a list of {index, url}, `active` is the
423
+ index of the tab currently in front. Fails with a readable message if the
424
+ session is not open (409 — call profile_start first) or the identity does
425
+ not exist (404).
426
+ """
427
+ clean = _identity_name(name)
428
+ return daemon.request("GET", f"/sessions/{clean}/tabs").json()
429
+
430
+
431
+ def session_switch_tab(name: str, index: int) -> dict:
432
+ """Bring one of the session's open tabs to the front and make it the active one.
433
+
434
+ Requires a session opened with profile_start first. Get `index` from
435
+ session_tabs — indices are only valid as of that snapshot, a tab opened or
436
+ closed since shifts them.
437
+
438
+ Args:
439
+ name: identity name, exactly as profiles_list reports it.
440
+ index: the tab's index, from session_tabs.
441
+
442
+ Returns {index, url} for the now-active tab. Fails with a readable message
443
+ if the session is not open (409 — call profile_start first), the identity
444
+ does not exist (404), or the index is out of range (422).
445
+ """
446
+ clean = _identity_name(name)
447
+ return daemon.request(
448
+ "POST", f"/sessions/{clean}/switch_tab", json={"index": index}
449
+ ).json()
450
+
451
+
452
+ TOOLS = (profiles_list, profile_start, profile_stop,
453
+ profile_create, profile_checkup, proxy_check,
454
+ profile_delete, trash_list, profile_restore,
455
+ session_goto, session_snapshot, session_click_selector,
456
+ session_fill_selector, session_press, session_eval,
457
+ session_tabs, session_switch_tab)
@@ -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,10 @@
1
+ nullprint_mcp/__init__.py,sha256=Fvhusg9kWpfG_A1FylE9q27R_yr1lT4fNdTNede2jdE,462
2
+ nullprint_mcp/__main__.py,sha256=6QxRVy0ioVQf8E6SFeB5EG4DkWUefnWwKAb40U0CtN0,1345
3
+ nullprint_mcp/daemon.py,sha256=XWVshPQ-aoeRj9FQpzbQHFrxEDLEliFPjUx-XLUKjxg,6437
4
+ nullprint_mcp/server.py,sha256=dtADSG_qMSlDt-hIghkMQ9MEqTsrz9h9AZS7uJfvUbQ,1101
5
+ nullprint_mcp/tools.py,sha256=maYvONouzIMXQqkaJd_AKMjc9zqEYkH49lIJ_bG-qI0,20491
6
+ nullprint_mcp-0.1.0.dist-info/METADATA,sha256=dExg9VQBta-qaayZruncREodFCLXS_e0bBjXSVQ9bcA,8538
7
+ nullprint_mcp-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ nullprint_mcp-0.1.0.dist-info/entry_points.txt,sha256=sZ3_9w3e0Xr5A_hGIJvknIeTFBR0nAqH_kQlMQNczNI,62
9
+ nullprint_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=QOckAHb4qakTd_YPlFQxVOfEbBOBam8U7b6yHU2Smc0,1070
10
+ nullprint_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nullprint-mcp = nullprint_mcp.__main__:main
@@ -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.