karma-mcp-server 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.
- karma_mcp_server/__init__.py +7 -0
- karma_mcp_server/__main__.py +82 -0
- karma_mcp_server/agent_signing.py +185 -0
- karma_mcp_server/backend_client.py +228 -0
- karma_mcp_server/config.py +52 -0
- karma_mcp_server/credential_store.py +71 -0
- karma_mcp_server/credentials.py +78 -0
- karma_mcp_server/errors.py +121 -0
- karma_mcp_server/http_auth.py +64 -0
- karma_mcp_server/pairing.py +73 -0
- karma_mcp_server/redact.py +36 -0
- karma_mcp_server/server.py +1252 -0
- karma_mcp_server/wallet_messages.py +61 -0
- karma_mcp_server-0.1.0.dist-info/METADATA +98 -0
- karma_mcp_server-0.1.0.dist-info/RECORD +18 -0
- karma_mcp_server-0.1.0.dist-info/WHEEL +5 -0
- karma_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- karma_mcp_server-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""入口:stdio(默认)或 Streamable HTTP(默认关闭,需显式开启 + 鉴权令牌)。
|
|
2
|
+
|
|
3
|
+
安全第一:
|
|
4
|
+
* 启动前拒绝「私钥被注入本进程」这种配置(红线 R1)。
|
|
5
|
+
* 远程 HTTP 没有配置鉴权令牌时**拒绝启动**,不会裸奔。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from karma_mcp_server.config import load_config
|
|
14
|
+
from karma_mcp_server.credentials import check_forbidden_env
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv: list[str] | None = None) -> int:
|
|
18
|
+
parser = argparse.ArgumentParser(prog="karma-mcp-server")
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--transport",
|
|
21
|
+
choices=("stdio", "streamable-http"),
|
|
22
|
+
default="stdio",
|
|
23
|
+
help="stdio(本地 Agent,默认)或 streamable-http(远程,需 KARMA_MCP_ENABLE_HTTP=1)",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
26
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
27
|
+
args = parser.parse_args(argv)
|
|
28
|
+
|
|
29
|
+
forbidden = check_forbidden_env()
|
|
30
|
+
if forbidden:
|
|
31
|
+
print(
|
|
32
|
+
"refusing to start: this MCP process must not hold private key material. "
|
|
33
|
+
"remove these env vars: " + ", ".join(forbidden),
|
|
34
|
+
file=sys.stderr,
|
|
35
|
+
)
|
|
36
|
+
return 2
|
|
37
|
+
|
|
38
|
+
config = load_config()
|
|
39
|
+
|
|
40
|
+
token_verifier = None
|
|
41
|
+
auth_settings = None
|
|
42
|
+
if args.transport == "streamable-http":
|
|
43
|
+
if not config.enable_http:
|
|
44
|
+
print(
|
|
45
|
+
"refusing to start: remote HTTP transport is disabled "
|
|
46
|
+
"(set KARMA_MCP_ENABLE_HTTP=1 once remote auth is configured).",
|
|
47
|
+
file=sys.stderr,
|
|
48
|
+
)
|
|
49
|
+
return 3
|
|
50
|
+
from karma_mcp_server.http_auth import (
|
|
51
|
+
StaticTokenVerifier,
|
|
52
|
+
build_auth_settings,
|
|
53
|
+
configured_token,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
token = configured_token()
|
|
57
|
+
if not token:
|
|
58
|
+
print(
|
|
59
|
+
"refusing to start: remote HTTP requires KARMA_MCP_HTTP_TOKEN "
|
|
60
|
+
"(no anonymous remote access).",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
)
|
|
63
|
+
return 4
|
|
64
|
+
token_verifier = StaticTokenVerifier(token)
|
|
65
|
+
auth_settings = build_auth_settings(default_host=args.host, default_port=args.port)
|
|
66
|
+
|
|
67
|
+
from karma_mcp_server.server import build_server
|
|
68
|
+
|
|
69
|
+
mcp = build_server(config, token_verifier=token_verifier, auth_settings=auth_settings)
|
|
70
|
+
|
|
71
|
+
if args.transport == "streamable-http":
|
|
72
|
+
mcp.settings.host = args.host
|
|
73
|
+
mcp.settings.port = args.port
|
|
74
|
+
mcp.run(transport="streamable-http")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
mcp.run(transport="stdio")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Agent 侧 Runtime Key 请求签名。
|
|
2
|
+
|
|
3
|
+
与 ``services/runtime_wallet.build_agent_request_message``(服务端)**逐字对齐**。
|
|
4
|
+
两边由测试钉死,格式不得漂移。
|
|
5
|
+
|
|
6
|
+
私钥只在本进程内存里使用,永不外露、永不进日志。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
_NEWLINE = chr(10)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def runtime_key_id(token: str) -> str:
|
|
21
|
+
"""从 ``KRM_RT_<key_id>_<secret>`` 取出 key_id(签名消息需要它)。"""
|
|
22
|
+
raw = (token or "").strip()
|
|
23
|
+
if not raw.startswith("KRM_RT_"):
|
|
24
|
+
return ""
|
|
25
|
+
head, _, _ = raw[len("KRM_RT_") :].partition("_")
|
|
26
|
+
return head
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def agent_key_from_seed(seed: str) -> Any:
|
|
30
|
+
"""base64(32 raw bytes) 或 64 位 hex → Ed25519PrivateKey。"""
|
|
31
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
32
|
+
|
|
33
|
+
text = (seed or "").strip()
|
|
34
|
+
if not text:
|
|
35
|
+
raise ValueError("agent private key is empty")
|
|
36
|
+
if len(text) == 64 and all(c in "0123456789abcdefABCDEF" for c in text):
|
|
37
|
+
raw = bytes.fromhex(text)
|
|
38
|
+
else:
|
|
39
|
+
raw = base64.b64decode(text, validate=True)
|
|
40
|
+
if len(raw) != 32:
|
|
41
|
+
raise ValueError("agent private key must be 32 raw bytes (base64 or hex seed)")
|
|
42
|
+
return Ed25519PrivateKey.from_private_bytes(raw)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def agent_public_key_b64(key: Any) -> str:
|
|
46
|
+
from cryptography.hazmat.primitives import serialization
|
|
47
|
+
|
|
48
|
+
raw = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
49
|
+
return base64.b64encode(raw).decode()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def canonical_public_key_b64(public_key: str) -> str:
|
|
53
|
+
"""收敛成 canonical base64(raw 32 bytes);不是 32 字节裸公钥就返回空串。
|
|
54
|
+
|
|
55
|
+
fingerprint 必须先把 base64 / hex 两种写法收敛成同一个值,否则同一把钥匙
|
|
56
|
+
在两处会显示成两串不同的 hex,主人核对就失去意义。
|
|
57
|
+
"""
|
|
58
|
+
text = (public_key or "").strip()
|
|
59
|
+
if not text:
|
|
60
|
+
return ""
|
|
61
|
+
compact = "".join(text.split())
|
|
62
|
+
if compact[:2].lower() == "0x":
|
|
63
|
+
compact = compact[2:]
|
|
64
|
+
try:
|
|
65
|
+
if len(compact) == 64 and all(c in "0123456789abcdefABCDEF" for c in compact):
|
|
66
|
+
raw = bytes.fromhex(compact)
|
|
67
|
+
else:
|
|
68
|
+
raw = base64.b64decode(compact, validate=True)
|
|
69
|
+
except Exception: # noqa: BLE001 - 形状不对就当没有指纹
|
|
70
|
+
return ""
|
|
71
|
+
if len(raw) != 32:
|
|
72
|
+
return ""
|
|
73
|
+
return base64.b64encode(raw).decode()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def agent_public_key_fingerprint(public_key_b64: str, *, length: int = 16) -> str:
|
|
77
|
+
"""与后端 ``runtime_key_service.agent_binding_fingerprint`` 同口径(sha256 前 16 位)。
|
|
78
|
+
|
|
79
|
+
口径:先收敛成 canonical base64(raw 32 bytes),再对**那串 base64 文本**取
|
|
80
|
+
sha256。配对卡片、key 列表、agent 自报三处必须是同一串 hex —— 差一个编码
|
|
81
|
+
方式,主人核对的就是两串对不上的数。
|
|
82
|
+
"""
|
|
83
|
+
canonical = canonical_public_key_b64(public_key_b64)
|
|
84
|
+
if not canonical:
|
|
85
|
+
return ""
|
|
86
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:length]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_agent_request_message(
|
|
90
|
+
*, key_id: str, method: str, path: str, timestamp: str, nonce: str, body_sha256: str
|
|
91
|
+
) -> str:
|
|
92
|
+
"""服务端重算的那段文字。**不要**改动行序。"""
|
|
93
|
+
return _NEWLINE.join(
|
|
94
|
+
[
|
|
95
|
+
"Karma Runtime Request",
|
|
96
|
+
"key_id:" + key_id,
|
|
97
|
+
"method:" + method.upper(),
|
|
98
|
+
"path:" + path,
|
|
99
|
+
"timestamp:" + timestamp,
|
|
100
|
+
"nonce:" + nonce,
|
|
101
|
+
"body_sha256:" + body_sha256,
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def sign_runtime_request(
|
|
107
|
+
*, key: Any, key_id: str, method: str, path: str, body: bytes
|
|
108
|
+
) -> dict[str, str]:
|
|
109
|
+
"""已绑公钥的 key 每个请求必须带上的三个头。"""
|
|
110
|
+
timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
111
|
+
nonce = uuid.uuid4().hex
|
|
112
|
+
message = build_agent_request_message(
|
|
113
|
+
key_id=key_id,
|
|
114
|
+
method=method,
|
|
115
|
+
path=path,
|
|
116
|
+
timestamp=timestamp,
|
|
117
|
+
nonce=nonce,
|
|
118
|
+
body_sha256=hashlib.sha256(body or b"").hexdigest(),
|
|
119
|
+
)
|
|
120
|
+
signature = base64.b64encode(key.sign(message.encode("utf-8"))).decode()
|
|
121
|
+
return {
|
|
122
|
+
"X-Karma-Agent-Signature": signature,
|
|
123
|
+
"X-Karma-Runtime-Timestamp": timestamp,
|
|
124
|
+
"X-Karma-Runtime-Nonce": nonce,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_agent_pairing_request_message(
|
|
129
|
+
*, agent_name: str, public_key: str, nonce: str, timestamp: str
|
|
130
|
+
) -> str:
|
|
131
|
+
"""与 ``services/runtime_wallet.build_agent_pairing_request_message`` 逐字对齐。"""
|
|
132
|
+
return _NEWLINE.join(
|
|
133
|
+
[
|
|
134
|
+
"Karma Agent Pairing Request",
|
|
135
|
+
"agent_name:" + agent_name,
|
|
136
|
+
"public_key:" + public_key,
|
|
137
|
+
"nonce:" + nonce,
|
|
138
|
+
"timestamp:" + timestamp,
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def sign_pairing_request(*, key: Any, agent_name: str, public_key: str) -> dict[str, str]:
|
|
144
|
+
"""申请接入要带的三件套:签名 + nonce + 时间戳。
|
|
145
|
+
|
|
146
|
+
公钥从此不再是「申报」:服务端用这段签名证明申请方真的持有那把私钥。
|
|
147
|
+
"""
|
|
148
|
+
timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
149
|
+
nonce = uuid.uuid4().hex
|
|
150
|
+
message = build_agent_pairing_request_message(
|
|
151
|
+
agent_name=agent_name, public_key=public_key, nonce=nonce, timestamp=timestamp
|
|
152
|
+
)
|
|
153
|
+
return {
|
|
154
|
+
"signature": base64.b64encode(key.sign(message.encode("utf-8"))).decode(),
|
|
155
|
+
"nonce": nonce,
|
|
156
|
+
"timestamp": timestamp,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def ensure_local_agent_key() -> Any | None:
|
|
161
|
+
"""本机 agent 私钥:有就用,没有就现生成一把并落盘(0600)。
|
|
162
|
+
|
|
163
|
+
这把私钥是「光有钥匙字符串花不了钱」的全部依据,它必须只存在于 agent 本机 ——
|
|
164
|
+
Karma 服务端从头到尾看不到它,只收到公钥,用来把 Runtime Key 钉在这次的 agent 身上。
|
|
165
|
+
"""
|
|
166
|
+
from karma_mcp_server.credentials import credential
|
|
167
|
+
|
|
168
|
+
seed = credential("KARMA_AGENT_PRIVATE_KEY")
|
|
169
|
+
if seed:
|
|
170
|
+
try:
|
|
171
|
+
return agent_key_from_seed(seed)
|
|
172
|
+
except Exception: # noqa: BLE001, S110 - 种子坏了就当没有,重新生成会把旧的覆盖掉
|
|
173
|
+
pass
|
|
174
|
+
try:
|
|
175
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
176
|
+
|
|
177
|
+
from karma_mcp_server.credential_store import upsert_credential
|
|
178
|
+
|
|
179
|
+
key = Ed25519PrivateKey.generate()
|
|
180
|
+
upsert_credential(
|
|
181
|
+
"KARMA_AGENT_PRIVATE_KEY", base64.b64encode(key.private_bytes_raw()).decode()
|
|
182
|
+
)
|
|
183
|
+
return key
|
|
184
|
+
except Exception: # noqa: BLE001 - 落不了盘就当没有,退回「领取时再绑」那条老路
|
|
185
|
+
return None
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""L4 后端客户端 —— 唯一允许出网的地方。
|
|
2
|
+
|
|
3
|
+
职责:注入 ``X-Karma-Runtime-Key`` + 逐请求 Agent 签名;传 ``client_nonce`` /
|
|
4
|
+
``Idempotency-Key``;把 HTTP 结果归类成 ``KarmaToolError``。
|
|
5
|
+
|
|
6
|
+
**不做**:不代签钱包交易、不持有私钥、不做额度/风控/状态机判定。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from karma_mcp_server.agent_signing import (
|
|
17
|
+
agent_key_from_seed,
|
|
18
|
+
agent_public_key_b64,
|
|
19
|
+
agent_public_key_fingerprint,
|
|
20
|
+
runtime_key_id,
|
|
21
|
+
sign_runtime_request,
|
|
22
|
+
)
|
|
23
|
+
from karma_mcp_server.config import McpConfig
|
|
24
|
+
from karma_mcp_server.credentials import credential
|
|
25
|
+
from karma_mcp_server.errors import ErrorClass, KarmaToolError, classify_http_status
|
|
26
|
+
from karma_mcp_server.redact import fingerprint
|
|
27
|
+
|
|
28
|
+
_SIGNATURE_REQUIRED_MARKER = "X-Karma-Agent-Signature"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _encode(body: Any) -> bytes:
|
|
32
|
+
if body is None:
|
|
33
|
+
return b""
|
|
34
|
+
return json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class KarmaBackend:
|
|
38
|
+
"""对 Karma 后端的薄封装。一个实例 = 一个进程的凭据视图。"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
config: McpConfig,
|
|
43
|
+
*,
|
|
44
|
+
runtime_key: str | None = None,
|
|
45
|
+
api_key: str | None = None,
|
|
46
|
+
agent_private_key_seed: str | None = None,
|
|
47
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
48
|
+
credential_fn=credential,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._config = config
|
|
51
|
+
self._credential = credential_fn
|
|
52
|
+
self._transport = transport
|
|
53
|
+
self._explicit_runtime_key = runtime_key
|
|
54
|
+
self._explicit_api_key = api_key
|
|
55
|
+
self._explicit_seed = agent_private_key_seed
|
|
56
|
+
self._signing_mode: bool | None = None
|
|
57
|
+
|
|
58
|
+
# ---- 凭据(只读) --------------------------------------------------
|
|
59
|
+
def runtime_key(self) -> str:
|
|
60
|
+
if self._explicit_runtime_key is not None:
|
|
61
|
+
return self._explicit_runtime_key.strip()
|
|
62
|
+
return self._credential("KARMA_RUNTIME_KEY")
|
|
63
|
+
|
|
64
|
+
def has_runtime_key(self) -> bool:
|
|
65
|
+
return bool(self.runtime_key())
|
|
66
|
+
|
|
67
|
+
def api_key(self) -> str:
|
|
68
|
+
"""公共 API(``/v1/*``)用的 key。配对前为空是正常的。"""
|
|
69
|
+
if self._explicit_api_key is not None:
|
|
70
|
+
return self._explicit_api_key.strip()
|
|
71
|
+
return self._credential("KARMA_API_KEY")
|
|
72
|
+
|
|
73
|
+
def key_fingerprint(self) -> str:
|
|
74
|
+
return fingerprint(self.runtime_key())
|
|
75
|
+
|
|
76
|
+
def agent_key_fingerprint(self) -> str:
|
|
77
|
+
"""本机 agent 公钥的 16 位 hex 指纹;没有钥匙就返回空串。
|
|
78
|
+
|
|
79
|
+
铸 Runtime Key 时把「这把钱钥匙给哪把 agent 公钥」钉进主人的钱包签名
|
|
80
|
+
消息(``agent_public_key_fingerprint`` 行)。读不到本机钥匙就不填 —— 回到
|
|
81
|
+
「签名不约束绑定公钥」的老行为,绝不因此多放行任何权限。
|
|
82
|
+
"""
|
|
83
|
+
key = self._agent_key()
|
|
84
|
+
if key is None:
|
|
85
|
+
return ""
|
|
86
|
+
return agent_public_key_fingerprint(agent_public_key_b64(key))
|
|
87
|
+
|
|
88
|
+
def _agent_key(self):
|
|
89
|
+
seed = (
|
|
90
|
+
self._explicit_seed
|
|
91
|
+
if self._explicit_seed is not None
|
|
92
|
+
else self._credential("KARMA_AGENT_PRIVATE_KEY")
|
|
93
|
+
)
|
|
94
|
+
if not seed:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
return agent_key_from_seed(seed)
|
|
98
|
+
except Exception: # noqa: BLE001 - 种子坏了就当没有,走「不签名」这条老路
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
# ---- 出网 ----------------------------------------------------------
|
|
102
|
+
def _headers(
|
|
103
|
+
self, *, method: str, path: str, body: bytes, runtime: bool = True
|
|
104
|
+
) -> dict[str, str]:
|
|
105
|
+
h = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
106
|
+
ak = self.api_key()
|
|
107
|
+
if ak and not runtime:
|
|
108
|
+
h["X-Karma-Api-Key"] = ak
|
|
109
|
+
rk = self.runtime_key()
|
|
110
|
+
# 只有要钱、要额度的 /runtime/* 才必须带钥匙;引导期的公开接口
|
|
111
|
+
# (pairing request / claim 等 runtime=False)在还没拿到钥匙时也得能发出去,
|
|
112
|
+
# 否则一台全新的机器永远开不了第一次配对,自助接入的第一步就死在这里。
|
|
113
|
+
# 带了钥匙依旧照发:怎么验是服务端的事,客户端不替它放行。
|
|
114
|
+
if not rk and runtime:
|
|
115
|
+
raise KarmaToolError(
|
|
116
|
+
ErrorClass.UNAUTHORIZED,
|
|
117
|
+
"no Runtime Key configured for this MCP server",
|
|
118
|
+
)
|
|
119
|
+
if rk:
|
|
120
|
+
h["X-Karma-Runtime-Key"] = rk
|
|
121
|
+
key = self._agent_key()
|
|
122
|
+
if key is not None and self._signing_mode is True:
|
|
123
|
+
h.update(
|
|
124
|
+
sign_runtime_request(
|
|
125
|
+
key=key, key_id=runtime_key_id(rk), method=method, path=path, body=body
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return h
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _detail(resp: httpx.Response) -> str:
|
|
132
|
+
try:
|
|
133
|
+
payload = resp.json()
|
|
134
|
+
except Exception: # noqa: BLE001
|
|
135
|
+
return (resp.text or "")[:300]
|
|
136
|
+
if isinstance(payload, dict) and payload.get("detail"):
|
|
137
|
+
return str(payload["detail"])[:300]
|
|
138
|
+
return (resp.text or "")[:300]
|
|
139
|
+
|
|
140
|
+
def _client(self) -> httpx.AsyncClient:
|
|
141
|
+
return httpx.AsyncClient(
|
|
142
|
+
timeout=httpx.Timeout(self._config.write_timeout_seconds),
|
|
143
|
+
transport=self._transport,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
async def _probe_signing_mode(self, client: httpx.AsyncClient) -> bool:
|
|
147
|
+
"""探一次这把钥匙要不要签名(读路径,不落调用记录)。"""
|
|
148
|
+
if not self.has_runtime_key():
|
|
149
|
+
self._signing_mode = False
|
|
150
|
+
return False
|
|
151
|
+
if self._agent_key() is None:
|
|
152
|
+
self._signing_mode = False
|
|
153
|
+
return False
|
|
154
|
+
url = self._config.runtime_base_url + "/runtime/permissions"
|
|
155
|
+
headers = {"Content-Type": "application/json", "X-Karma-Runtime-Key": self.runtime_key()}
|
|
156
|
+
try:
|
|
157
|
+
resp = await client.get(url, headers=headers)
|
|
158
|
+
except httpx.HTTPError:
|
|
159
|
+
self._signing_mode = False
|
|
160
|
+
return False
|
|
161
|
+
self._signing_mode = bool(
|
|
162
|
+
resp.status_code == 401 and _SIGNATURE_REQUIRED_MARKER in self._detail(resp)
|
|
163
|
+
)
|
|
164
|
+
return self._signing_mode
|
|
165
|
+
|
|
166
|
+
async def request(
|
|
167
|
+
self,
|
|
168
|
+
method: str,
|
|
169
|
+
path: str,
|
|
170
|
+
*,
|
|
171
|
+
body: Any = None,
|
|
172
|
+
runtime: bool = True,
|
|
173
|
+
extra_headers: dict[str, str] | None = None,
|
|
174
|
+
) -> Any:
|
|
175
|
+
payload = _encode(body)
|
|
176
|
+
url = self._config.runtime_base_url + path
|
|
177
|
+
async with self._client() as client:
|
|
178
|
+
if runtime and self._signing_mode is None:
|
|
179
|
+
await self._probe_signing_mode(client)
|
|
180
|
+
headers = self._headers(method=method, path=path, body=payload, runtime=runtime)
|
|
181
|
+
if extra_headers:
|
|
182
|
+
headers = {**headers, **extra_headers}
|
|
183
|
+
try:
|
|
184
|
+
resp = await client.request(method, url, headers=headers, content=payload or None)
|
|
185
|
+
except httpx.TimeoutException as exc:
|
|
186
|
+
raise KarmaToolError(ErrorClass.TRANSPORT, f"request to {path} timed out") from exc
|
|
187
|
+
except httpx.HTTPError as exc:
|
|
188
|
+
raise KarmaToolError(
|
|
189
|
+
ErrorClass.TRANSPORT, f"cannot reach Karma backend at {path}"
|
|
190
|
+
) from exc
|
|
191
|
+
|
|
192
|
+
# 主人刚在操作台输完配对码:这一刻服务端开始要求签名,补签重发一次。
|
|
193
|
+
if (
|
|
194
|
+
resp.status_code == 401
|
|
195
|
+
and self._agent_key() is not None
|
|
196
|
+
and self._signing_mode is not True
|
|
197
|
+
and _SIGNATURE_REQUIRED_MARKER in self._detail(resp)
|
|
198
|
+
):
|
|
199
|
+
self._signing_mode = True
|
|
200
|
+
headers = self._headers(method=method, path=path, body=payload, runtime=runtime)
|
|
201
|
+
if extra_headers:
|
|
202
|
+
headers = {**headers, **extra_headers}
|
|
203
|
+
resp = await client.request(method, url, headers=headers, content=payload or None)
|
|
204
|
+
|
|
205
|
+
if resp.is_error:
|
|
206
|
+
detail = self._detail(resp)
|
|
207
|
+
raise KarmaToolError(
|
|
208
|
+
classify_http_status(resp.status_code, detail),
|
|
209
|
+
detail or f"backend returned {resp.status_code}",
|
|
210
|
+
http_status=resp.status_code,
|
|
211
|
+
)
|
|
212
|
+
if not resp.content:
|
|
213
|
+
return {}
|
|
214
|
+
return resp.json()
|
|
215
|
+
|
|
216
|
+
# ---- 便捷方法 ------------------------------------------------------
|
|
217
|
+
async def runtime_get(self, path: str) -> Any:
|
|
218
|
+
return await self.request("GET", path, runtime=True)
|
|
219
|
+
|
|
220
|
+
async def runtime_post(
|
|
221
|
+
self, path: str, body: Any, *, extra_headers: dict[str, str] | None = None
|
|
222
|
+
) -> Any:
|
|
223
|
+
return await self.request(
|
|
224
|
+
"POST", path, body=body, runtime=True, extra_headers=extra_headers
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
async def api_get(self, path: str) -> Any:
|
|
228
|
+
return await self.request("GET", path, runtime=False)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""MCP Server 配置。
|
|
2
|
+
|
|
3
|
+
**只读** ``KARMA_RUNTIME_KEY`` 等凭据;Tier-2/Tier-3(动钱)默认**关闭**(fail-closed)。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from karma_mcp_server.credentials import credential
|
|
12
|
+
|
|
13
|
+
DEFAULT_RUNTIME_URL = "http://localhost:8000"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _truthy(value: str | None) -> bool:
|
|
17
|
+
return (value or "").strip().lower() in ("1", "true", "yes", "on")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class McpConfig:
|
|
22
|
+
runtime_base_url: str = DEFAULT_RUNTIME_URL
|
|
23
|
+
read_timeout_seconds: float = 30.0
|
|
24
|
+
write_timeout_seconds: float = 120.0
|
|
25
|
+
#: Tier-2(交易/交付)与 Tier-3(结算/争议)默认关闭。
|
|
26
|
+
enable_tier2: bool = False
|
|
27
|
+
enable_tier3: bool = False
|
|
28
|
+
#: 远程 Streamable HTTP 默认关闭 —— 远程鉴权方案未定前不得开放。
|
|
29
|
+
enable_http: bool = False
|
|
30
|
+
|
|
31
|
+
def allowed_tiers(self) -> frozenset[int]:
|
|
32
|
+
tiers = {0, 1}
|
|
33
|
+
if self.enable_tier2:
|
|
34
|
+
tiers.add(2)
|
|
35
|
+
if self.enable_tier3:
|
|
36
|
+
tiers.add(3)
|
|
37
|
+
return frozenset(tiers)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_config(
|
|
41
|
+
env: dict[str, str] | None = None, *, file_values: dict[str, str] | None = None
|
|
42
|
+
) -> McpConfig:
|
|
43
|
+
source = os.environ if env is None else env
|
|
44
|
+
base = (
|
|
45
|
+
credential("KARMA_RUNTIME_URL", env=source, file_values=file_values) or DEFAULT_RUNTIME_URL
|
|
46
|
+
)
|
|
47
|
+
return McpConfig(
|
|
48
|
+
runtime_base_url=base.rstrip("/"),
|
|
49
|
+
enable_tier2=_truthy(source.get("KARMA_MCP_ENABLE_TIER2")),
|
|
50
|
+
enable_tier3=_truthy(source.get("KARMA_MCP_ENABLE_TIER3")),
|
|
51
|
+
enable_http=_truthy(source.get("KARMA_MCP_ENABLE_HTTP")),
|
|
52
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""凭据**写入**(配对流程专用)。
|
|
2
|
+
|
|
3
|
+
与 ``credentials.py``(只读)分开:读的路径要尽可能简单、无副作用;写的路径只有
|
|
4
|
+
``karma_connect_claim`` 会走,并且必须 0600 + 原子落盘。
|
|
5
|
+
|
|
6
|
+
凭据文件**永不回显** —— 返回给调用方的只有指纹。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from karma_mcp_server.credentials import agent_env_path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def state_dir() -> Path:
|
|
18
|
+
"""配对状态目录(含 pairing_code,属于秘密)。"""
|
|
19
|
+
override = os.environ.get("KARMA_MCP_STATE_DIR", "").strip()
|
|
20
|
+
if override:
|
|
21
|
+
return Path(override).expanduser()
|
|
22
|
+
return Path.home() / ".karma" / "pairings"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _write_secret(path: Path, text: str) -> None:
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
28
|
+
tmp.write_text(text, encoding="utf-8")
|
|
29
|
+
try:
|
|
30
|
+
os.chmod(tmp, 0o600)
|
|
31
|
+
except OSError:
|
|
32
|
+
pass
|
|
33
|
+
os.replace(tmp, path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def upsert_credential(name: str, value: str) -> Path:
|
|
37
|
+
"""把一对键值并进凭据文件(0600),同名行就地替换。"""
|
|
38
|
+
path = agent_env_path()
|
|
39
|
+
try:
|
|
40
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
41
|
+
except OSError:
|
|
42
|
+
lines = []
|
|
43
|
+
prefix = name + "="
|
|
44
|
+
out: list[str] = []
|
|
45
|
+
replaced = False
|
|
46
|
+
for line in lines:
|
|
47
|
+
if line.strip().startswith(prefix):
|
|
48
|
+
if not replaced:
|
|
49
|
+
out.append(prefix + value)
|
|
50
|
+
replaced = True
|
|
51
|
+
continue
|
|
52
|
+
out.append(line)
|
|
53
|
+
if not replaced:
|
|
54
|
+
out.append(prefix + value)
|
|
55
|
+
_write_secret(path, "\n".join(out).rstrip("\n") + "\n")
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_env_file(text: str) -> Path:
|
|
60
|
+
"""整份重写凭据文件(领取凭据时用)。"""
|
|
61
|
+
path = agent_env_path()
|
|
62
|
+
_write_secret(path, text)
|
|
63
|
+
return path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_pairing_state(user_code: str, payload_json: str) -> Path:
|
|
67
|
+
"""配对状态(含 pairing_code)落到本机 0600 文件,聊天里不出现。"""
|
|
68
|
+
safe = "".join(ch for ch in (user_code or "pairing") if ch.isalnum() or ch in "-_") or "pairing"
|
|
69
|
+
path = state_dir() / (safe.upper() + ".json")
|
|
70
|
+
_write_secret(path, payload_json)
|
|
71
|
+
return path
|