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.
- karma_mcp_server-0.1.0/PKG-INFO +98 -0
- karma_mcp_server-0.1.0/README.md +84 -0
- karma_mcp_server-0.1.0/karma_mcp_server/__init__.py +7 -0
- karma_mcp_server-0.1.0/karma_mcp_server/__main__.py +82 -0
- karma_mcp_server-0.1.0/karma_mcp_server/agent_signing.py +185 -0
- karma_mcp_server-0.1.0/karma_mcp_server/backend_client.py +228 -0
- karma_mcp_server-0.1.0/karma_mcp_server/config.py +52 -0
- karma_mcp_server-0.1.0/karma_mcp_server/credential_store.py +71 -0
- karma_mcp_server-0.1.0/karma_mcp_server/credentials.py +78 -0
- karma_mcp_server-0.1.0/karma_mcp_server/errors.py +121 -0
- karma_mcp_server-0.1.0/karma_mcp_server/http_auth.py +64 -0
- karma_mcp_server-0.1.0/karma_mcp_server/pairing.py +73 -0
- karma_mcp_server-0.1.0/karma_mcp_server/redact.py +36 -0
- karma_mcp_server-0.1.0/karma_mcp_server/server.py +1252 -0
- karma_mcp_server-0.1.0/karma_mcp_server/wallet_messages.py +61 -0
- karma_mcp_server-0.1.0/karma_mcp_server.egg-info/PKG-INFO +98 -0
- karma_mcp_server-0.1.0/karma_mcp_server.egg-info/SOURCES.txt +33 -0
- karma_mcp_server-0.1.0/karma_mcp_server.egg-info/dependency_links.txt +1 -0
- karma_mcp_server-0.1.0/karma_mcp_server.egg-info/entry_points.txt +2 -0
- karma_mcp_server-0.1.0/karma_mcp_server.egg-info/requires.txt +7 -0
- karma_mcp_server-0.1.0/karma_mcp_server.egg-info/top_level.txt +1 -0
- karma_mcp_server-0.1.0/pyproject.toml +31 -0
- karma_mcp_server-0.1.0/setup.cfg +4 -0
- karma_mcp_server-0.1.0/tests/test_agent_signing.py +101 -0
- karma_mcp_server-0.1.0/tests/test_backend_client.py +132 -0
- karma_mcp_server-0.1.0/tests/test_credential_store.py +32 -0
- karma_mcp_server-0.1.0/tests/test_credentials.py +54 -0
- karma_mcp_server-0.1.0/tests/test_errors.py +49 -0
- karma_mcp_server-0.1.0/tests/test_http_auth.py +46 -0
- karma_mcp_server-0.1.0/tests/test_mcp_server_identity.py +62 -0
- karma_mcp_server-0.1.0/tests/test_pairing.py +44 -0
- karma_mcp_server-0.1.0/tests/test_server_tools.py +115 -0
- karma_mcp_server-0.1.0/tests/test_tier23.py +205 -0
- karma_mcp_server-0.1.0/tests/test_wallet_messages.py +56 -0
- 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,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)
|