karma-mcp-server 0.1.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.
Files changed (35) hide show
  1. karma_mcp_server-0.1.0/PKG-INFO +98 -0
  2. karma_mcp_server-0.1.0/README.md +84 -0
  3. karma_mcp_server-0.1.0/karma_mcp_server/__init__.py +7 -0
  4. karma_mcp_server-0.1.0/karma_mcp_server/__main__.py +82 -0
  5. karma_mcp_server-0.1.0/karma_mcp_server/agent_signing.py +185 -0
  6. karma_mcp_server-0.1.0/karma_mcp_server/backend_client.py +228 -0
  7. karma_mcp_server-0.1.0/karma_mcp_server/config.py +52 -0
  8. karma_mcp_server-0.1.0/karma_mcp_server/credential_store.py +71 -0
  9. karma_mcp_server-0.1.0/karma_mcp_server/credentials.py +78 -0
  10. karma_mcp_server-0.1.0/karma_mcp_server/errors.py +121 -0
  11. karma_mcp_server-0.1.0/karma_mcp_server/http_auth.py +64 -0
  12. karma_mcp_server-0.1.0/karma_mcp_server/pairing.py +73 -0
  13. karma_mcp_server-0.1.0/karma_mcp_server/redact.py +36 -0
  14. karma_mcp_server-0.1.0/karma_mcp_server/server.py +1252 -0
  15. karma_mcp_server-0.1.0/karma_mcp_server/wallet_messages.py +61 -0
  16. karma_mcp_server-0.1.0/karma_mcp_server.egg-info/PKG-INFO +98 -0
  17. karma_mcp_server-0.1.0/karma_mcp_server.egg-info/SOURCES.txt +33 -0
  18. karma_mcp_server-0.1.0/karma_mcp_server.egg-info/dependency_links.txt +1 -0
  19. karma_mcp_server-0.1.0/karma_mcp_server.egg-info/entry_points.txt +2 -0
  20. karma_mcp_server-0.1.0/karma_mcp_server.egg-info/requires.txt +7 -0
  21. karma_mcp_server-0.1.0/karma_mcp_server.egg-info/top_level.txt +1 -0
  22. karma_mcp_server-0.1.0/pyproject.toml +31 -0
  23. karma_mcp_server-0.1.0/setup.cfg +4 -0
  24. karma_mcp_server-0.1.0/tests/test_agent_signing.py +101 -0
  25. karma_mcp_server-0.1.0/tests/test_backend_client.py +132 -0
  26. karma_mcp_server-0.1.0/tests/test_credential_store.py +32 -0
  27. karma_mcp_server-0.1.0/tests/test_credentials.py +54 -0
  28. karma_mcp_server-0.1.0/tests/test_errors.py +49 -0
  29. karma_mcp_server-0.1.0/tests/test_http_auth.py +46 -0
  30. karma_mcp_server-0.1.0/tests/test_mcp_server_identity.py +62 -0
  31. karma_mcp_server-0.1.0/tests/test_pairing.py +44 -0
  32. karma_mcp_server-0.1.0/tests/test_server_tools.py +115 -0
  33. karma_mcp_server-0.1.0/tests/test_tier23.py +205 -0
  34. karma_mcp_server-0.1.0/tests/test_wallet_messages.py +56 -0
  35. karma_mcp_server-0.1.0/tests/test_wiring.py +582 -0
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: karma-mcp-server
3
+ Version: 0.1.0
4
+ Summary: Karma universal Agent MCP server — thin access layer over the Karma backend
5
+ License: AGPL-3.0-only
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: cryptography>=42.0.0
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: mcp<2,>=1.8.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.2.0; extra == "dev"
13
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
14
+
15
+ # karma-mcp-server
16
+
17
+ Karma 通用 Agent MCP Server —— **薄接入层**。把已有后端能力接到 MCP,不重写任何业务。
18
+
19
+ - 设计:[`docs/mcp/karma-mcp-architecture.md`](../../docs/mcp/karma-mcp-architecture.md)
20
+ - 工具契约:[`docs/mcp/karma-mcp-tools.md`](../../docs/mcp/karma-mcp-tools.md)
21
+ - 安全:[`docs/mcp/karma-mcp-security.md`](../../docs/mcp/karma-mcp-security.md)
22
+
23
+ ## 安全红线
24
+
25
+ - 本进程**只**注入 `KARMA_RUNTIME_KEY`;**禁止**私钥、助记词。启动时会检查并拒绝启动。
26
+ - 远程 HTTP 传输默认关闭(需 `KARMA_MCP_ENABLE_HTTP=1` + `KARMA_MCP_HTTP_TOKEN`;缺一拒绝启动)。
27
+ - Tier-2/3(动钱)默认不注册(`KARMA_MCP_ENABLE_TIER2/3=1` 才开)。
28
+
29
+ ## 环境变量
30
+
31
+ | 变量 | 必填 | 说明 |
32
+ |---|---|---|
33
+ | `KARMA_RUNTIME_KEY` | 是(配对后) | `KRM_RT_…`;只读 |
34
+ | `KARMA_RUNTIME_URL` | 否 | 默认 `http://localhost:8000` |
35
+ | `KARMA_AGENT_PRIVATE_KEY` | 否 | Agent 签名私钥(base64/hex),留在本机 |
36
+ | `KARMA_AGENT_ID` | 否 | 主人授权的 agent id |
37
+ | `KARMA_AGENT_ENV_PATH` | 否 | 凭据文件路径覆盖(默认 `~/.karma/agent.env`) |
38
+ | `KARMA_MCP_ENABLE_TIER2` | 否 | 开启交易/交付工具(默认关) |
39
+ | `KARMA_MCP_ENABLE_TIER3` | 否 | 开启结算/争议工具(默认关) |
40
+ | `KARMA_MCP_ENABLE_HTTP` | 否 | 开启 Streamable HTTP(默认关) |
41
+ | `KARMA_MCP_HTTP_TOKEN` | 远程必填 | 远程 HTTP 的静态 bearer token;缺了拒绝启动 |
42
+ | `KARMA_MCP_STATE_DIR` | 否 | 配对状态目录(含配对码),默认 `~/.karma/pairings` |
43
+ | `KARMA_MCP_ISSUER_URL` / `KARMA_MCP_RESOURCE_URL` | 否 | 远程鉴权 issuer / resource,默认取 `host:port` |
44
+
45
+ ## 安装
46
+
47
+ ```bash
48
+ # 在仓库根目录:
49
+ pip install ./packages/karma-mcp-server
50
+
51
+ # 没 clone 过仓库(公开仓库,一行装):
52
+ pip install "karma-mcp-server @ git+https://github.com/AtoB101/Karma@main#subdirectory=packages/karma-mcp-server"
53
+ ```
54
+
55
+ PyPI 上**还没有**这个包(`pip install karma-mcp-server` 会 404)。装 / 接线 / 自检 /
56
+ 发包的完整口径:[`docs/mcp/karma-mcp-integration.md`](../../docs/mcp/karma-mcp-integration.md)。
57
+
58
+ ## 运行
59
+
60
+ ```bash
61
+ pip install -e ".[dev]"
62
+ karma-mcp-server # stdio(本地 Agent,默认)
63
+
64
+ KARMA_MCP_ENABLE_HTTP=1 KARMA_MCP_HTTP_TOKEN=... \
65
+ karma-mcp-server --transport streamable-http --host 0.0.0.0 --port 8765
66
+ ```
67
+
68
+ 远程启动有三道红线:进程里出现私钥 / 未设 `KARMA_MCP_ENABLE_HTTP=1` / 没配
69
+ `KARMA_MCP_HTTP_TOKEN` —— 任一条不满足都**拒绝启动**。
70
+
71
+ ## 测试
72
+
73
+ ```bash
74
+ python -m pytest -q -c pyproject.toml
75
+ ```
76
+
77
+ ## 当前状态(第二阶段 S2–S5 已落地)
78
+
79
+ 已实现并有测试(`packages/karma-mcp-server/tests`,56 例全绿):
80
+
81
+ - **S2 薄层**:配置 / 凭据(只读)/ Agent Ed25519 逐请求签名(与服务端逐字对齐)/
82
+ 后端客户端(错误分类 + `client_nonce`)/ 凭据 0600 落盘 / stdio 入口。
83
+ - **S3 配对与身份**:`karma_connect` → `karma_connect_status` → `karma_connect_claim`
84
+ (配对码只落本机文件,凭据只回指纹);身份核验、授权状态、策略、交易 / 结算 / 信誉查询。
85
+ - **S3 授权读写**:`karma_request_authorization`(预览,不写后端)→
86
+ `karma_submit_authorization`(主人钱包签名后铸造)→ `karma_update_authorization`
87
+ (铸新 + 撤旧)→ `karma_revoke_authorization`(一键停用)。
88
+ - **S4 远程入口**:`--transport streamable-http` + 静态 bearer token(常量时间比较)。
89
+ - **S5 交易工具**:Tier-2/3 已实现,**默认不注册**,按 Tier 白名单 fail-closed。
90
+
91
+ | Tier | 默认 | 开启方式 | 内容 |
92
+ |---|---|---|---|
93
+ | T0 只读 | 开 | — | 连接 / 身份 / 授权状态 / 策略 / 交易 / 结算 / 信誉 / 凭证查询 |
94
+ | T1 授权 | 开 | — | 授权预览 / 提交(需主人钱包签名)/ 更新 / 撤销 |
95
+ | T2 交易 | 关 | `KARMA_MCP_ENABLE_TIER2=1` | 下单 / 收款码 / 确认裁决 / 证据 / 回执 / 进度 / 结算请求 |
96
+ | T3 资金 | 关 | `KARMA_MCP_ENABLE_TIER3=1` | 凭证校验 / 争议 / 退款 |
97
+
98
+ 未做(S6 及以后):跨平台适配器(Telegram / 微信等)、完整 OAuth 2.1 授权服务器接入。
@@ -0,0 +1,84 @@
1
+ # karma-mcp-server
2
+
3
+ Karma 通用 Agent MCP Server —— **薄接入层**。把已有后端能力接到 MCP,不重写任何业务。
4
+
5
+ - 设计:[`docs/mcp/karma-mcp-architecture.md`](../../docs/mcp/karma-mcp-architecture.md)
6
+ - 工具契约:[`docs/mcp/karma-mcp-tools.md`](../../docs/mcp/karma-mcp-tools.md)
7
+ - 安全:[`docs/mcp/karma-mcp-security.md`](../../docs/mcp/karma-mcp-security.md)
8
+
9
+ ## 安全红线
10
+
11
+ - 本进程**只**注入 `KARMA_RUNTIME_KEY`;**禁止**私钥、助记词。启动时会检查并拒绝启动。
12
+ - 远程 HTTP 传输默认关闭(需 `KARMA_MCP_ENABLE_HTTP=1` + `KARMA_MCP_HTTP_TOKEN`;缺一拒绝启动)。
13
+ - Tier-2/3(动钱)默认不注册(`KARMA_MCP_ENABLE_TIER2/3=1` 才开)。
14
+
15
+ ## 环境变量
16
+
17
+ | 变量 | 必填 | 说明 |
18
+ |---|---|---|
19
+ | `KARMA_RUNTIME_KEY` | 是(配对后) | `KRM_RT_…`;只读 |
20
+ | `KARMA_RUNTIME_URL` | 否 | 默认 `http://localhost:8000` |
21
+ | `KARMA_AGENT_PRIVATE_KEY` | 否 | Agent 签名私钥(base64/hex),留在本机 |
22
+ | `KARMA_AGENT_ID` | 否 | 主人授权的 agent id |
23
+ | `KARMA_AGENT_ENV_PATH` | 否 | 凭据文件路径覆盖(默认 `~/.karma/agent.env`) |
24
+ | `KARMA_MCP_ENABLE_TIER2` | 否 | 开启交易/交付工具(默认关) |
25
+ | `KARMA_MCP_ENABLE_TIER3` | 否 | 开启结算/争议工具(默认关) |
26
+ | `KARMA_MCP_ENABLE_HTTP` | 否 | 开启 Streamable HTTP(默认关) |
27
+ | `KARMA_MCP_HTTP_TOKEN` | 远程必填 | 远程 HTTP 的静态 bearer token;缺了拒绝启动 |
28
+ | `KARMA_MCP_STATE_DIR` | 否 | 配对状态目录(含配对码),默认 `~/.karma/pairings` |
29
+ | `KARMA_MCP_ISSUER_URL` / `KARMA_MCP_RESOURCE_URL` | 否 | 远程鉴权 issuer / resource,默认取 `host:port` |
30
+
31
+ ## 安装
32
+
33
+ ```bash
34
+ # 在仓库根目录:
35
+ pip install ./packages/karma-mcp-server
36
+
37
+ # 没 clone 过仓库(公开仓库,一行装):
38
+ pip install "karma-mcp-server @ git+https://github.com/AtoB101/Karma@main#subdirectory=packages/karma-mcp-server"
39
+ ```
40
+
41
+ PyPI 上**还没有**这个包(`pip install karma-mcp-server` 会 404)。装 / 接线 / 自检 /
42
+ 发包的完整口径:[`docs/mcp/karma-mcp-integration.md`](../../docs/mcp/karma-mcp-integration.md)。
43
+
44
+ ## 运行
45
+
46
+ ```bash
47
+ pip install -e ".[dev]"
48
+ karma-mcp-server # stdio(本地 Agent,默认)
49
+
50
+ KARMA_MCP_ENABLE_HTTP=1 KARMA_MCP_HTTP_TOKEN=... \
51
+ karma-mcp-server --transport streamable-http --host 0.0.0.0 --port 8765
52
+ ```
53
+
54
+ 远程启动有三道红线:进程里出现私钥 / 未设 `KARMA_MCP_ENABLE_HTTP=1` / 没配
55
+ `KARMA_MCP_HTTP_TOKEN` —— 任一条不满足都**拒绝启动**。
56
+
57
+ ## 测试
58
+
59
+ ```bash
60
+ python -m pytest -q -c pyproject.toml
61
+ ```
62
+
63
+ ## 当前状态(第二阶段 S2–S5 已落地)
64
+
65
+ 已实现并有测试(`packages/karma-mcp-server/tests`,56 例全绿):
66
+
67
+ - **S2 薄层**:配置 / 凭据(只读)/ Agent Ed25519 逐请求签名(与服务端逐字对齐)/
68
+ 后端客户端(错误分类 + `client_nonce`)/ 凭据 0600 落盘 / stdio 入口。
69
+ - **S3 配对与身份**:`karma_connect` → `karma_connect_status` → `karma_connect_claim`
70
+ (配对码只落本机文件,凭据只回指纹);身份核验、授权状态、策略、交易 / 结算 / 信誉查询。
71
+ - **S3 授权读写**:`karma_request_authorization`(预览,不写后端)→
72
+ `karma_submit_authorization`(主人钱包签名后铸造)→ `karma_update_authorization`
73
+ (铸新 + 撤旧)→ `karma_revoke_authorization`(一键停用)。
74
+ - **S4 远程入口**:`--transport streamable-http` + 静态 bearer token(常量时间比较)。
75
+ - **S5 交易工具**:Tier-2/3 已实现,**默认不注册**,按 Tier 白名单 fail-closed。
76
+
77
+ | Tier | 默认 | 开启方式 | 内容 |
78
+ |---|---|---|---|
79
+ | T0 只读 | 开 | — | 连接 / 身份 / 授权状态 / 策略 / 交易 / 结算 / 信誉 / 凭证查询 |
80
+ | T1 授权 | 开 | — | 授权预览 / 提交(需主人钱包签名)/ 更新 / 撤销 |
81
+ | T2 交易 | 关 | `KARMA_MCP_ENABLE_TIER2=1` | 下单 / 收款码 / 确认裁决 / 证据 / 回执 / 进度 / 结算请求 |
82
+ | T3 资金 | 关 | `KARMA_MCP_ENABLE_TIER3=1` | 凭证校验 / 争议 / 退款 |
83
+
84
+ 未做(S6 及以后):跨平台适配器(Telegram / 微信等)、完整 OAuth 2.1 授权服务器接入。
@@ -0,0 +1,7 @@
1
+ """Karma 通用 Agent MCP Server —— 薄接入层。
2
+
3
+ 只做「翻译 + 转发 + 校验」;身份、授权、风控、状态机、结算判定全部在后端。
4
+ 本包永不持有私钥、助记词或钱包凭证。
5
+ """
6
+
7
+ __version__ = "0.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)