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.
- nullprint_mcp/__init__.py +11 -0
- nullprint_mcp/__main__.py +39 -0
- nullprint_mcp/daemon.py +160 -0
- nullprint_mcp/server.py +29 -0
- nullprint_mcp/tools.py +457 -0
- nullprint_mcp-0.1.0.dist-info/METADATA +135 -0
- nullprint_mcp-0.1.0.dist-info/RECORD +10 -0
- nullprint_mcp-0.1.0.dist-info/WHEEL +4 -0
- nullprint_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- nullprint_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|
nullprint_mcp/daemon.py
ADDED
|
@@ -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
|
+
)
|
nullprint_mcp/server.py
ADDED
|
@@ -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,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.
|