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.
- karma_connect-1.0.0/PKG-INFO +84 -0
- karma_connect-1.0.0/README.md +77 -0
- karma_connect-1.0.0/karma_connect/__init__.py +3 -0
- karma_connect-1.0.0/karma_connect/__main__.py +6 -0
- karma_connect-1.0.0/karma_connect/cli.py +279 -0
- karma_connect-1.0.0/karma_connect/hosts.py +231 -0
- karma_connect-1.0.0/karma_connect/pair.py +149 -0
- karma_connect-1.0.0/karma_connect/signcheck.py +238 -0
- karma_connect-1.0.0/karma_connect.egg-info/PKG-INFO +84 -0
- karma_connect-1.0.0/karma_connect.egg-info/SOURCES.txt +17 -0
- karma_connect-1.0.0/karma_connect.egg-info/dependency_links.txt +1 -0
- karma_connect-1.0.0/karma_connect.egg-info/entry_points.txt +4 -0
- karma_connect-1.0.0/karma_connect.egg-info/top_level.txt +1 -0
- karma_connect-1.0.0/pyproject.toml +20 -0
- karma_connect-1.0.0/setup.cfg +4 -0
- karma_connect-1.0.0/tests/test_hosts.py +213 -0
- karma_connect-1.0.0/tests/test_package_version.py +20 -0
- karma_connect-1.0.0/tests/test_pair_cli.py +50 -0
- karma_connect-1.0.0/tests/test_signcheck.py +73 -0
|
@@ -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,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}
|