karma-connect 1.0.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,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: karma-connect
3
+ Version: 1.0.0
4
+ Summary: One command that wires the Karma MCP server into every agent host on this machine
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # karma-connect
9
+
10
+ One command that wires the **Karma MCP server** into every agent host on this machine.
11
+
12
+ ```bash
13
+ pip install ./packages/karma-connect
14
+ # or, without cloning the repo:
15
+ pip install "karma-connect @ git+https://github.com/AtoB101/Karma@main#subdirectory=packages/karma-connect"
16
+ karma-connect detect
17
+ karma-connect install
18
+ ```
19
+
20
+ Not on PyPI yet (`pip install karma-connect` returns 404); see
21
+ [`docs/mcp/karma-mcp-integration.md`](../../docs/mcp/karma-mcp-integration.md).
22
+
23
+ No JSON editing, no config file archaeology. It finds the hosts you already have
24
+ and writes one named server entry into each.
25
+
26
+ ## Which server it wires
27
+
28
+ `karma-mcp-server` (`packages/karma-mcp-server`) - the thin access layer that only
29
+ ever holds `KARMA_RUNTIME_KEY`. The older `karma-mcp` package is **not** used: it
30
+ wanted `KARMA_PRIVATE_KEY` injected into the MCP process, which the security audit
31
+ retired, and `karma-mcp-server` refuses to start when private key material is in its
32
+ environment. So `install` refuses `--env KARMA_PRIVATE_KEY=...` (or any mnemonic key)
33
+ instead of writing a server that can never boot.
34
+
35
+ `KARMA_RUNTIME_URL` defaults to the public node (`https://karma-network.ai`), so a
36
+ fresh install needs no `--env` at all. Everything else the agent needs arrives when
37
+ the owner approves the pairing.
38
+
39
+ ## What it touches
40
+
41
+ | host | config | key |
42
+ |---|---|---|
43
+ | Codex CLI | `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`) | `[mcp_servers.karma]` |
44
+ | Claude Code | `~/.claude.json` | `mcpServers.karma` |
45
+ | Cursor | `~/.cursor/mcp.json` | `mcpServers.karma` |
46
+ | Muse Code (Meta) | `${XDG_CONFIG_HOME:-~/.config}/muse/settings.json` | `mcpServers.karma` |
47
+ | OpenClaw | `$OPENCLAW_STATE_DIR/openclaw.json` (default `~/.openclaw/openclaw.json`) | `mcp.servers.karma` |
48
+ | VS Code | `<workspace>/.vscode/mcp.json` (only with `--workspace`) | `servers.karma` |
49
+
50
+ Only that one entry is touched; every other line is preserved. Running install
51
+ twice changes nothing the second time.
52
+
53
+ ## Gotchas it handles for you
54
+
55
+ - **Muse Code** wants `"type": "stdio"` (its docs page says `transport`, the binary
56
+ does not) plus `schema_version: 1`, and it drops the whole MCP block if
57
+ `mcp_servers` and `mcpServers` ever appear together. This tool only writes
58
+ `mcpServers`.
59
+ - **Codex** keeps MCP servers in TOML tables, not JSON, so the writer replaces just
60
+ the `[mcp_servers.karma]` block and leaves your `model`, plugins and marketplaces
61
+ alone.
62
+ - **OpenClaw** nests under `mcp.servers`, not `mcpServers`.
63
+
64
+ ## Commands
65
+
66
+ - `karma-connect detect` - which hosts exist here, and where their config lives
67
+ - `karma-connect install [--host codex,muse] [--dry-run]` - write the entry
68
+ - `karma-connect doctor` - is `karma-mcp-server` reachable, is every config readable, is this machine paired yet
69
+ - `karma-connect sign-check` - print the per-request signature this machine would send
70
+
71
+ ## After installing
72
+
73
+ 1. Restart the agent host so it reloads MCP servers.
74
+ 2. Have the agent call `karma_connect` (`agent_name` = whatever it calls itself). It
75
+ prints a `user_code` and a `verification_uri` - open that link in Karma Console,
76
+ check the `agent_fingerprint` on the card matches the one the agent showed you,
77
+ set the limits, and approve.
78
+ 3. Have the agent call `karma_connect_claim`. Approval **is** activation, so the
79
+ credentials come back directly - there is no 8-character code to relay.
80
+ 4. Credentials land in `~/.karma/agent.env` (0600). Nothing secret travels through chat.
81
+
82
+ The 8-character handoff code still exists as a fallback for machines that cannot
83
+ generate a local agent key, and the console can add an email-receipt gate as a third
84
+ lock when the approval carries `notify_email`.
@@ -0,0 +1,77 @@
1
+ # karma-connect
2
+
3
+ One command that wires the **Karma MCP server** into every agent host on this machine.
4
+
5
+ ```bash
6
+ pip install ./packages/karma-connect
7
+ # or, without cloning the repo:
8
+ pip install "karma-connect @ git+https://github.com/AtoB101/Karma@main#subdirectory=packages/karma-connect"
9
+ karma-connect detect
10
+ karma-connect install
11
+ ```
12
+
13
+ Not on PyPI yet (`pip install karma-connect` returns 404); see
14
+ [`docs/mcp/karma-mcp-integration.md`](../../docs/mcp/karma-mcp-integration.md).
15
+
16
+ No JSON editing, no config file archaeology. It finds the hosts you already have
17
+ and writes one named server entry into each.
18
+
19
+ ## Which server it wires
20
+
21
+ `karma-mcp-server` (`packages/karma-mcp-server`) - the thin access layer that only
22
+ ever holds `KARMA_RUNTIME_KEY`. The older `karma-mcp` package is **not** used: it
23
+ wanted `KARMA_PRIVATE_KEY` injected into the MCP process, which the security audit
24
+ retired, and `karma-mcp-server` refuses to start when private key material is in its
25
+ environment. So `install` refuses `--env KARMA_PRIVATE_KEY=...` (or any mnemonic key)
26
+ instead of writing a server that can never boot.
27
+
28
+ `KARMA_RUNTIME_URL` defaults to the public node (`https://karma-network.ai`), so a
29
+ fresh install needs no `--env` at all. Everything else the agent needs arrives when
30
+ the owner approves the pairing.
31
+
32
+ ## What it touches
33
+
34
+ | host | config | key |
35
+ |---|---|---|
36
+ | Codex CLI | `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`) | `[mcp_servers.karma]` |
37
+ | Claude Code | `~/.claude.json` | `mcpServers.karma` |
38
+ | Cursor | `~/.cursor/mcp.json` | `mcpServers.karma` |
39
+ | Muse Code (Meta) | `${XDG_CONFIG_HOME:-~/.config}/muse/settings.json` | `mcpServers.karma` |
40
+ | OpenClaw | `$OPENCLAW_STATE_DIR/openclaw.json` (default `~/.openclaw/openclaw.json`) | `mcp.servers.karma` |
41
+ | VS Code | `<workspace>/.vscode/mcp.json` (only with `--workspace`) | `servers.karma` |
42
+
43
+ Only that one entry is touched; every other line is preserved. Running install
44
+ twice changes nothing the second time.
45
+
46
+ ## Gotchas it handles for you
47
+
48
+ - **Muse Code** wants `"type": "stdio"` (its docs page says `transport`, the binary
49
+ does not) plus `schema_version: 1`, and it drops the whole MCP block if
50
+ `mcp_servers` and `mcpServers` ever appear together. This tool only writes
51
+ `mcpServers`.
52
+ - **Codex** keeps MCP servers in TOML tables, not JSON, so the writer replaces just
53
+ the `[mcp_servers.karma]` block and leaves your `model`, plugins and marketplaces
54
+ alone.
55
+ - **OpenClaw** nests under `mcp.servers`, not `mcpServers`.
56
+
57
+ ## Commands
58
+
59
+ - `karma-connect detect` - which hosts exist here, and where their config lives
60
+ - `karma-connect install [--host codex,muse] [--dry-run]` - write the entry
61
+ - `karma-connect doctor` - is `karma-mcp-server` reachable, is every config readable, is this machine paired yet
62
+ - `karma-connect sign-check` - print the per-request signature this machine would send
63
+
64
+ ## After installing
65
+
66
+ 1. Restart the agent host so it reloads MCP servers.
67
+ 2. Have the agent call `karma_connect` (`agent_name` = whatever it calls itself). It
68
+ prints a `user_code` and a `verification_uri` - open that link in Karma Console,
69
+ check the `agent_fingerprint` on the card matches the one the agent showed you,
70
+ set the limits, and approve.
71
+ 3. Have the agent call `karma_connect_claim`. Approval **is** activation, so the
72
+ credentials come back directly - there is no 8-character code to relay.
73
+ 4. Credentials land in `~/.karma/agent.env` (0600). Nothing secret travels through chat.
74
+
75
+ The 8-character handoff code still exists as a fallback for machines that cannot
76
+ generate a local agent key, and the console can add an email-receipt gate as a third
77
+ lock when the approval carries `notify_email`.
@@ -0,0 +1,3 @@
1
+ """karma-connect - wire the Karma MCP server into every agent host on this machine."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,6 @@
1
+ """Allow `python -m karma_connect`."""
2
+
3
+ from karma_connect.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,279 @@
1
+ """karma-connect CLI: detect agent hosts, install the Karma MCP server, verify it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.util
7
+ import json
8
+ import os
9
+ import shutil
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from karma_connect.hosts import (DEFAULT_COMMAND, DEFAULT_NAME, FORBIDDEN_ENV_KEYS,
14
+ KNOWN_ENV_KEYS, default_hosts, install, parse_env)
15
+
16
+ DEFAULT_MODULE = "karma_mcp_server"
17
+ #: Without KARMA_RUNTIME_URL the MCP server falls back to http://localhost:8000 and every
18
+ #: call dies with "All connection attempts failed" -- no user_code, no matching code.
19
+ #: Bake the public node in so an out-of-the-box install actually talks to Karma.
20
+ DEFAULT_ENV = {"KARMA_RUNTIME_URL": "https://karma-network.ai"}
21
+ #: GUI 之外的兜底:直接 ``python -m karma_mcp_server``(同一个包,不必装 console script)。
22
+ #: 老 ``karma_mcp``(私钥进进程)刻意不列 —— 装了也不能用。
23
+ FALLBACK_MODULES = ("karma_mcp_server",)
24
+
25
+
26
+ def _module_command(module: str, extra, env: dict):
27
+ """Run the MCP server as `<this python> -m <module>` and make it importable."""
28
+ top = module.split(".")[0]
29
+ spec = importlib.util.find_spec(top)
30
+ if spec is None or not spec.origin:
31
+ raise ValueError("module %r is not importable by %s" % (module, sys.executable))
32
+ root = str(Path(spec.origin).resolve().parents[1])
33
+ existing = [p for p in env.get("PYTHONPATH", "").split(os.pathsep) if p]
34
+ if root not in existing:
35
+ env["PYTHONPATH"] = os.pathsep.join([root, *existing])
36
+ return sys.executable, ["-m", module, *extra]
37
+
38
+
39
+ def _resolve(args):
40
+ env = parse_env(getattr(args, "env", None))
41
+ applied_defaults = []
42
+ for key, value in DEFAULT_ENV.items():
43
+ if not env.get(key):
44
+ env[key] = value
45
+ applied_defaults.append("%s=%s" % (key, value))
46
+ name = args.name or DEFAULT_NAME
47
+ command = args.command
48
+ extra = list(args.arg or [])
49
+
50
+ if args.module:
51
+ command, extra = _module_command(args.module, extra, env)
52
+ elif command is None:
53
+ if shutil.which(DEFAULT_COMMAND):
54
+ command = DEFAULT_COMMAND
55
+ else:
56
+ for module in FALLBACK_MODULES:
57
+ if importlib.util.find_spec(module.split(".")[0]) is not None:
58
+ command, extra = _module_command(module, extra, env)
59
+ break
60
+ else:
61
+ command = DEFAULT_COMMAND
62
+ return name, command, extra, env, applied_defaults
63
+
64
+
65
+ def result_path(host):
66
+ return str(host.config_path)
67
+
68
+
69
+ def hint_for(exc, host):
70
+ if isinstance(exc, PermissionError):
71
+ return "permission denied - this shell cannot write %s" % host.config_path
72
+ return "%s: %s" % (type(exc).__name__, exc)
73
+
74
+
75
+ def cmd_detect(args) -> int:
76
+ rows = []
77
+ for host in default_hosts(workspace=args.workspace):
78
+ rows.append({"host": host.key, "label": host.label,
79
+ "detected": host.detected(), "config": str(host.config_path)})
80
+ if args.json:
81
+ print(json.dumps(rows, indent=2, ensure_ascii=False))
82
+ return 0
83
+ print("agent hosts found on this machine:")
84
+ for row in rows:
85
+ print(" [%s] %-9s %-20s %s" % ("yes" if row["detected"] else "no ", row["host"],
86
+ row["label"], row["config"]))
87
+ return 0
88
+
89
+
90
+ def cmd_install(args) -> int:
91
+ name, command, extra, env, defaults = _resolve(args)
92
+ hosts = default_hosts(workspace=args.workspace)
93
+ if args.host and args.host != "all":
94
+ wanted = {h.strip() for h in args.host.split(",") if h.strip()}
95
+ unknown = wanted - {host.key for host in hosts}
96
+ if unknown:
97
+ print("unknown host(s): %s" % ", ".join(sorted(unknown)), file=sys.stderr)
98
+ return 2
99
+ hosts = [host for host in hosts if host.key in wanted]
100
+ else:
101
+ hosts = [host for host in hosts if host.detected()]
102
+
103
+ forbidden_env = sorted(key for key in env if key in FORBIDDEN_ENV_KEYS)
104
+ if forbidden_env:
105
+ print("refusing to install: %s must never be written into an MCP host config."
106
+ % ", ".join(forbidden_env), file=sys.stderr)
107
+ print(" karma-mcp-server refuses to start when private key material is in its "
108
+ "environment, so this entry would be dead on arrival.", file=sys.stderr)
109
+ print(" Karma wallet signatures always come from the owner's own wallet; the "
110
+ "agent side only ever holds a Runtime Key.", file=sys.stderr)
111
+ return 2
112
+ unknown_env = [key for key in env if key not in KNOWN_ENV_KEYS]
113
+ if unknown_env:
114
+ print("warning: unrecognized env key(s): %s" % ", ".join(unknown_env), file=sys.stderr)
115
+ print(" karma-mcp-server ignores these; pairing writes KARMA_RUNTIME_KEY on its own.",
116
+ file=sys.stderr)
117
+ if not hosts:
118
+ print("no agent host detected on this machine.", file=sys.stderr)
119
+ return 3
120
+ if not os.path.isabs(command) and shutil.which(command) is None:
121
+ print("warning: %r is not on PATH yet - the host will fail to start the server."
122
+ % command, file=sys.stderr)
123
+ print(" install it first: pip install ./packages/karma-mcp-server", file=sys.stderr)
124
+
125
+ print("server: %s %s" % (command, " ".join(extra)))
126
+ for key in defaults:
127
+ print(" default env: %s" % key)
128
+ changed = 0
129
+ failed = []
130
+ for host in hosts:
131
+ try:
132
+ result = install(host, name, command, extra, env, dry_run=args.dry_run)
133
+ except Exception as exc:
134
+ failed.append((host, exc))
135
+ print(" %-9s %-12s %s" % (host.key, "FAILED", result_path(host)))
136
+ print(" %-9s %s" % ("", hint_for(exc, host)))
137
+ continue
138
+ if args.dry_run:
139
+ state = "would write"
140
+ else:
141
+ state = "updated" if result["changed"] else "already set"
142
+ print(" %-9s %-12s %s" % (host.key, state, result["path"]))
143
+ changed += 1 if result["changed"] else 0
144
+
145
+ print()
146
+ if failed:
147
+ print("%d host(s) could not be written (the rest are done)." % len(failed))
148
+ print("Re-run the same command in a normal terminal (not the sandbox) to finish those.")
149
+ if args.dry_run:
150
+ print("dry run: %d file(s) would change. Re-run without --dry-run to apply." % changed)
151
+ return 0
152
+ print("done. %d file(s) changed." % changed)
153
+ print("next: restart the agent host so it reloads MCP servers, then have the agent call")
154
+ print(" karma_connect; check the agent_fingerprint against the Console card, approve")
155
+ print(" it there - that approval IS the activation - then have it call")
156
+ print(" karma_connect_claim.")
157
+ print(" credentials land in ~/.karma/agent.env (0600) - nothing secret in chat.")
158
+ return 0
159
+
160
+
161
+ def _local_runtime_key() -> str:
162
+ """本机凭据文件里的 Runtime Key(配对批准后由 karma_connect_claim 落盘)。"""
163
+ override = os.environ.get("KARMA_AGENT_ENV_PATH")
164
+ path = Path(override) if override else Path.home() / ".karma" / "agent.env"
165
+ try:
166
+ text = path.expanduser().read_text(encoding="utf-8")
167
+ except OSError:
168
+ return ""
169
+ for line in text.splitlines():
170
+ line = line.strip()
171
+ if line.startswith("KARMA_RUNTIME_KEY="):
172
+ return line.partition("=")[2].strip()
173
+ return ""
174
+
175
+
176
+ def cmd_doctor(args) -> int:
177
+ name, command, extra, env, _defaults = _resolve(args)
178
+ problems = []
179
+ notes = []
180
+ if not os.path.isabs(command) and shutil.which(command) is None:
181
+ problems.append("%r is not on PATH - pip install ./packages/karma-mcp-server first"
182
+ % command)
183
+ if not env.get("KARMA_RUNTIME_URL"):
184
+ problems.append("no KARMA_RUNTIME_URL: pairing would hit http://localhost:8000")
185
+ forbidden_env = sorted(key for key in env if key in FORBIDDEN_ENV_KEYS)
186
+ if forbidden_env:
187
+ problems.append("private key material in env (%s): karma-mcp-server refuses to start "
188
+ "with those" % ", ".join(forbidden_env))
189
+ if not env.get("KARMA_RUNTIME_KEY") and not _local_runtime_key():
190
+ notes.append("not paired yet - the agent can still call karma_connect, and the Runtime "
191
+ "Key lands in ~/.karma/agent.env once the owner approves")
192
+ for host in default_hosts(workspace=args.workspace):
193
+ if not host.detected():
194
+ continue
195
+ try:
196
+ path, _ = host.render(name, command, extra, env)
197
+ except Exception as exc:
198
+ problems.append("%s: %s" % (host.key, exc))
199
+ continue
200
+ print(" ok %-9s %s" % (host.key, path))
201
+ if problems or notes:
202
+ print()
203
+ for problem in problems:
204
+ print(" warn %s" % problem)
205
+ for note in notes:
206
+ print(" note %s" % note)
207
+ if problems:
208
+ return 1
209
+ print()
210
+ print("configs look good." if notes else "no problems found.")
211
+ return 0
212
+
213
+
214
+ def _add_common(parser):
215
+ parser.add_argument("--host", default="all",
216
+ help="all (default) or a comma list: codex,claude,cursor,muse,openclaw,vscode")
217
+ parser.add_argument("--name", default=DEFAULT_NAME, help="MCP server name (default %s)" % DEFAULT_NAME)
218
+ parser.add_argument("--command", default=None,
219
+ help="server command; default is %s if on PATH, else this python"
220
+ % DEFAULT_COMMAND)
221
+ parser.add_argument("--module", default=None,
222
+ help="run the server as `python -m <module>` (default: %s)"
223
+ % DEFAULT_MODULE)
224
+ parser.add_argument("--arg", action="append", default=[],
225
+ help="argument for the server command (repeatable)")
226
+ parser.add_argument("--env", action="append", default=[],
227
+ help="KEY=VALUE passed to the server (repeatable)")
228
+ parser.add_argument("--workspace", default=None, help="workspace path, enables the vscode host")
229
+
230
+
231
+ def cmd_sign_check(args) -> int:
232
+ """逐请求签名的自检 —— 实现在 karma_connect.signcheck,这里只转发参数。"""
233
+ from karma_connect import signcheck
234
+
235
+ argv = ["--path", args.path]
236
+ if args.runtime_url:
237
+ argv += ["--runtime-url", args.runtime_url]
238
+ if args.json:
239
+ argv.append("--json")
240
+ return signcheck.main(argv)
241
+
242
+
243
+ def build_parser() -> argparse.ArgumentParser:
244
+ parser = argparse.ArgumentParser(
245
+ prog="karma-connect",
246
+ description="Wire the Karma MCP server into every agent host on this machine.")
247
+ sub = parser.add_subparsers(dest="cmd", required=True)
248
+
249
+ detect = sub.add_parser("detect", help="list agent hosts found on this machine")
250
+ detect.add_argument("--json", action="store_true")
251
+ detect.add_argument("--workspace", default=None)
252
+ detect.set_defaults(func=cmd_detect)
253
+
254
+ inst = sub.add_parser("install", help="write the Karma MCP server into each detected host")
255
+ _add_common(inst)
256
+ inst.add_argument("--dry-run", action="store_true")
257
+ inst.set_defaults(func=cmd_install)
258
+
259
+ doctor = sub.add_parser("doctor", help="check that the install can work")
260
+ _add_common(doctor)
261
+ doctor.set_defaults(func=cmd_doctor)
262
+
263
+ sign = sub.add_parser(
264
+ "sign-check",
265
+ help="check the per-request Ed25519 signing path against a live node")
266
+ sign.add_argument("--runtime-url", default=None)
267
+ sign.add_argument("--path", default="/runtime/permissions")
268
+ sign.add_argument("--json", action="store_true")
269
+ sign.set_defaults(func=cmd_sign_check)
270
+ return parser
271
+
272
+
273
+ def main(argv=None) -> int:
274
+ args = build_parser().parse_args(argv)
275
+ return args.func(args)
276
+
277
+
278
+ if __name__ == "__main__":
279
+ raise SystemExit(main())
@@ -0,0 +1,231 @@
1
+ """Detect agent hosts on this machine and write Karma's MCP server into them.
2
+
3
+ Every host gets the same treatment: read its config, upsert one named MCP server
4
+ entry, leave everything else alone. Writes are idempotent, so running the
5
+ command twice changes nothing the second time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ DEFAULT_NAME = "karma"
16
+ #: 默认指向通用薄接入层 ``packages/karma-mcp-server``。**不要**改回老包 ``karma-mcp``:
17
+ #: 那个包把 ``KARMA_PRIVATE_KEY`` 注进 MCP 进程(安全审计判定作废),而
18
+ #: ``karma-mcp-server`` 启动时发现私钥类键就直接拒绝启动(红线 R1)。
19
+ DEFAULT_COMMAND = "karma-mcp-server"
20
+
21
+ #: ``karma-mcp-server`` 真正会读的键(契约见 packages/karma-mcp-server/README.md)。
22
+ #: 只用于提示「这个环境变量它根本不认」,不参与任何校验。
23
+ KNOWN_ENV_KEYS = (
24
+ "KARMA_RUNTIME_URL",
25
+ "KARMA_RUNTIME_KEY",
26
+ "KARMA_API_KEY",
27
+ "KARMA_AGENT_ID",
28
+ "KARMA_AGENT_PRIVATE_KEY",
29
+ "KARMA_AGENT_ENV_PATH",
30
+ "KARMA_MCP_STATE_DIR",
31
+ "KARMA_MCP_ENABLE_TIER2",
32
+ "KARMA_MCP_ENABLE_TIER3",
33
+ "KARMA_MCP_ENABLE_HTTP",
34
+ "KARMA_MCP_HTTP_TOKEN",
35
+ "KARMA_MCP_ISSUER_URL",
36
+ "KARMA_MCP_RESOURCE_URL",
37
+ "PYTHONPATH",
38
+ "PYTHONIOENCODING",
39
+ )
40
+
41
+ #: 绝不能写进宿主配置的键:``karma-mcp-server`` 启动时会检查它们并**拒绝启动**,
42
+ #: 写进去等于给宿主配一个起不来的 server。所以安装时直接拒绝(fail-closed)。
43
+ #: 老 ``karma-mcp`` 那套链上私钥模型(KARMA_PRIVATE_KEY / KARMA_MNEMONIC)一并列入。
44
+ FORBIDDEN_ENV_KEYS = (
45
+ "KARMA_PRIVATE_KEY",
46
+ "KARMA_MNEMONIC",
47
+ "MNEMONIC",
48
+ "USER_PRIVATE_KEY",
49
+ "DEPLOYER_PRIVATE_KEY",
50
+ )
51
+
52
+
53
+ def _home() -> Path:
54
+ return Path(os.path.expanduser("~"))
55
+
56
+
57
+ def xdg_config_home() -> Path:
58
+ value = os.environ.get("XDG_CONFIG_HOME")
59
+ return Path(value) if value else _home() / ".config"
60
+
61
+
62
+ def codex_home() -> Path:
63
+ return Path(os.environ.get("CODEX_HOME") or (_home() / ".codex"))
64
+
65
+
66
+ def parse_env(pairs):
67
+ """["KEY=VALUE", ...] -> dict, insertion order kept."""
68
+ out = {}
69
+ for pair in pairs or []:
70
+ if "=" not in pair:
71
+ raise ValueError("--env expects KEY=VALUE, got %r" % (pair,))
72
+ key, _, value = pair.partition("=")
73
+ key = key.strip()
74
+ if not key:
75
+ raise ValueError("--env expects KEY=VALUE, got %r" % (pair,))
76
+ out[key] = value
77
+ return out
78
+
79
+
80
+ # ---------------------------------------------------------------- TOML helpers
81
+
82
+ def _toml_string(value: str) -> str:
83
+ return json.dumps(value)
84
+
85
+
86
+ def _toml_array(values) -> str:
87
+ return "[" + ", ".join(_toml_string(v) for v in values) + "]"
88
+
89
+
90
+ def upsert_toml_table(text: str, table: str, body) -> str:
91
+ """Insert or replace [table], preserving every other line."""
92
+ header = "[%s]" % table
93
+ lines = text.splitlines()
94
+ start = next((i for i, line in enumerate(lines) if line.strip() == header), None)
95
+ if start is None:
96
+ out = text.rstrip("\n")
97
+ prefix = out + "\n\n" if out else ""
98
+ return prefix + "\n".join([header, *body]) + "\n"
99
+ end = len(lines)
100
+ for j in range(start + 1, len(lines)):
101
+ stripped = lines[j].strip()
102
+ if stripped.startswith("[") and stripped.endswith("]"):
103
+ if stripped.startswith(header[:-1] + "."):
104
+ continue
105
+ end = j
106
+ break
107
+ merged = [*lines[:start], header, *body]
108
+ tail = lines[end:]
109
+ if tail:
110
+ merged.append("")
111
+ merged.extend(tail)
112
+ return "\n".join(merged).rstrip("\n") + "\n"
113
+
114
+
115
+ # ---------------------------------------------------------------- JSON helpers
116
+
117
+ def read_json_object(path: Path) -> dict:
118
+ if not path.exists():
119
+ return {}
120
+ raw = path.read_text(encoding="utf-8").strip()
121
+ if not raw:
122
+ return {}
123
+ data = json.loads(raw)
124
+ if not isinstance(data, dict):
125
+ raise ValueError("%s does not hold a JSON object" % path)
126
+ return data
127
+
128
+
129
+ def write_json_object(path: Path, data: dict) -> None:
130
+ path.parent.mkdir(parents=True, exist_ok=True)
131
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
132
+
133
+
134
+ def json_upsert(data: dict, dotted: str, name: str, entry: dict) -> None:
135
+ node = data
136
+ parts = dotted.split(".")
137
+ for part in parts[:-1]:
138
+ child = node.get(part)
139
+ if not isinstance(child, dict):
140
+ child = {}
141
+ node[part] = child
142
+ node = child
143
+ bucket = node.get(parts[-1])
144
+ if not isinstance(bucket, dict):
145
+ bucket = {}
146
+ node[parts[-1]] = bucket
147
+ bucket[name] = entry
148
+
149
+
150
+ # ------------------------------------------------------------------- host spec
151
+
152
+ def build_entry(command: str, args, env: dict, style: str) -> dict:
153
+ if style == "muse":
154
+ return {"type": "stdio", "command": command, "args": list(args),
155
+ "env": dict(env), "mode": "optional"}
156
+ if style == "vscode":
157
+ return {"type": "stdio", "command": command, "args": list(args), "env": dict(env)}
158
+ if style == "openclaw":
159
+ return {"transport": "stdio", "command": command, "args": list(args), "env": dict(env)}
160
+ return {"command": command, "args": list(args), "env": dict(env)}
161
+
162
+
163
+ @dataclass
164
+ class Host:
165
+ key: str
166
+ label: str
167
+ style: str
168
+ config_path: Path
169
+ json_bucket: str = "mcpServers"
170
+ toml_table: str = "mcp_servers"
171
+ format: str = "json"
172
+ detect_paths: tuple = field(default_factory=tuple)
173
+
174
+ def detected(self) -> bool:
175
+ if not self.detect_paths:
176
+ return True
177
+ return any(path.exists() for path in self.detect_paths)
178
+
179
+ def render(self, name: str, command: str, args, env: dict):
180
+ """-> (path, new_text). Pure: no disk writes."""
181
+ if self.format == "toml":
182
+ current = (self.config_path.read_text(encoding="utf-8")
183
+ if self.config_path.exists() else "")
184
+ body = ["command = %s" % _toml_string(command),
185
+ "args = %s" % _toml_array(args)]
186
+ if env:
187
+ body.append("[%s.%s.env]" % (self.toml_table, name))
188
+ body.extend("%s = %s" % (k, _toml_string(v)) for k, v in env.items())
189
+ text = upsert_toml_table(current, "%s.%s" % (self.toml_table, name), body)
190
+ return self.config_path, text
191
+ data = read_json_object(self.config_path)
192
+ json_upsert(data, self.json_bucket, name, build_entry(command, args, env, self.style))
193
+ if self.style == "muse" and "schema_version" not in data:
194
+ data = {"schema_version": 1, **data}
195
+ return self.config_path, json.dumps(data, indent=2, ensure_ascii=False) + "\n"
196
+
197
+
198
+ def default_hosts(workspace=None):
199
+ home = _home()
200
+ cfg = xdg_config_home()
201
+ openclaw_state = Path(os.environ.get("OPENCLAW_STATE_DIR") or (home / ".openclaw"))
202
+ hosts = [
203
+ Host("codex", "Codex CLI", "plain", codex_home() / "config.toml",
204
+ toml_table="mcp_servers", format="toml", detect_paths=(codex_home(),)),
205
+ Host("claude", "Claude Code", "plain", home / ".claude.json",
206
+ detect_paths=(home / ".claude.json",)),
207
+ Host("cursor", "Cursor", "plain", home / ".cursor" / "mcp.json",
208
+ detect_paths=(home / ".cursor",)),
209
+ Host("muse", "Muse Code (Meta)", "muse", cfg / "muse" / "settings.json",
210
+ detect_paths=(cfg / "muse",)),
211
+ Host("openclaw", "OpenClaw", "openclaw", openclaw_state / "openclaw.json",
212
+ json_bucket="mcp.servers", detect_paths=(openclaw_state,)),
213
+ ]
214
+ if workspace is not None:
215
+ hosts.append(Host("vscode", "VS Code (this workspace)", "vscode",
216
+ Path(workspace) / ".vscode" / "mcp.json",
217
+ json_bucket="servers", detect_paths=(Path(workspace),)))
218
+ return hosts
219
+
220
+
221
+ def install(host: Host, name: str, command: str, args, env: dict, dry_run: bool = False):
222
+ """-> dict describing what happened."""
223
+ path, new_text = host.render(name, command, args, env)
224
+ if dry_run:
225
+ return {"host": host.key, "path": str(path), "changed": True, "dry_run": True}
226
+ before = path.read_text(encoding="utf-8") if path.exists() else None
227
+ if before == new_text:
228
+ return {"host": host.key, "path": str(path), "changed": False}
229
+ path.parent.mkdir(parents=True, exist_ok=True)
230
+ path.write_text(new_text, encoding="utf-8")
231
+ return {"host": host.key, "path": str(path), "changed": True}