axi-cli 0.0.7__tar.gz → 0.0.9__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.
- {axi_cli-0.0.7 → axi_cli-0.0.9}/CHANGELOG.md +14 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/PKG-INFO +1 -1
- {axi_cli-0.0.7 → axi_cli-0.0.9}/pyproject.toml +1 -1
- {axi_cli-0.0.7 → axi_cli-0.0.9}/skills/axi-creator/SKILL.md +36 -3
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/__init__.py +2 -1
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/config.py +1 -0
- axi_cli-0.0.9/src/axi/context.py +36 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/daemon/client.py +7 -1
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/mcp_serve.py +18 -3
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/providers/mcp.py +34 -20
- {axi_cli-0.0.7 → axi_cli-0.0.9}/.gitignore +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/README.md +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/skills/axi-use/SKILL.md +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/cli.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/daemon/protocol.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/daemon/server.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/executor.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/models.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/providers/__init__.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/providers/native.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/registry.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/__init__.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/bm25.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/cache.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/embedding.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/hybrid.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/regex.py +0 -0
- {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/tokenize.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.0.9] - 2026-07-07
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- daemon Unix socket 的 StreamReader 读缓冲从默认 64KB 调大至 8MB:工具数量多时 `axi list` / `axi search` 响应超过单行缓冲上限触发 `LimitOverrunError`,现可正常返回
|
|
7
|
+
|
|
8
|
+
## [0.0.8] - 2026-07-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `axi.env(name, default=None)`:native 工具的统一变量读取入口。CLI / stdio 形态下读进程环境变量;经 `axi mcp --transport http` 导出为共享 HTTP server 时,每个客户端通过 `Axi-*` 请求头传入的变量按请求隔离、优先于进程环境变量(`Axi-Foo-Bar` → `FOO_BAR`:去前缀、`-`→`_`、转大写)
|
|
12
|
+
- `mcpServers` 新增 `headers` 字段:连接需认证的 HTTP MCP server 时随每个请求附带(如 `Authorization: Bearer xxx`);stdio 型凭据继续走 `env`,两种 transport 各用各的通道
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- MCP 连接建立中途失败(如 `initialize` 报错、远端拒绝认证)不再泄漏已打开的资源(httpx 客户端 / 流 / session):改用 `AsyncExitStack.pop_all()` 惯用法,全部成功才移交所有权,失败由 with 自动清理
|
|
16
|
+
|
|
3
17
|
## [0.0.7] - 2026-07-04
|
|
4
18
|
|
|
5
19
|
### Added
|
|
@@ -64,6 +64,21 @@ def get_can_data(
|
|
|
64
64
|
- 复杂输入用 `BaseModel` 嵌套;axi 会把它展开成 object schema
|
|
65
65
|
- **填 `output_example`**:MCP 协议没有 output schema,原生工具是 axi 独有的优势——Agent 能在 `axi describe` 里直接看到返回形状,不用试跑一遍
|
|
66
66
|
|
|
67
|
+
### 凭据与配置变量:用 `axi.env`,别用 `os.environ`
|
|
68
|
+
|
|
69
|
+
工具里需要 API Key 等变量时,一律通过 `axi.env(name, default=None)` 读取:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import axi
|
|
73
|
+
|
|
74
|
+
@tool(description="调用上游 API")
|
|
75
|
+
def call_api(query: str) -> dict:
|
|
76
|
+
key = axi.env("API_KEY") # CLI/stdio 下读环境变量;HTTP 导出时读客户端 Axi-* header
|
|
77
|
+
...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
CLI 和 stdio 形态下它就是环境变量;但当工具经 `axi mcp --transport http` 导出为共享 HTTP server 时,每个客户端可通过 `Axi-*` 请求头传自己的变量(见下文"导出为 MCP server"),`axi.env` 会优先取当前请求的值。直接 `os.environ` 的工具在 HTTP 形态下拿不到客户端变量。
|
|
81
|
+
|
|
67
82
|
### 注册:两种方式,二选一
|
|
68
83
|
|
|
69
84
|
**方式 1:本项目内用 → `axi.json` 的 `nativeTools`**
|
|
@@ -104,13 +119,14 @@ smartlink = "smartlink_axi.tools"
|
|
|
104
119
|
"env": {"API_KEY": "xxx"}
|
|
105
120
|
},
|
|
106
121
|
"remote": {
|
|
107
|
-
"url": "http://localhost:8000/mcp"
|
|
122
|
+
"url": "http://localhost:8000/mcp", // 或 HTTP streaming
|
|
123
|
+
"headers": {"Authorization": "Bearer xxx"} // 可选:远端要求认证时随每个请求附带
|
|
108
124
|
}
|
|
109
125
|
}
|
|
110
126
|
}
|
|
111
127
|
```
|
|
112
128
|
|
|
113
|
-
`command` / `url`
|
|
129
|
+
`command` / `url` 二选一;凭据传递按 transport 选字段——stdio 用 `env`(注入子进程环境),HTTP 用 `headers`(附到每个请求)。保存后直接 `axi list` 就能看到新工具——daemon 会按需自动拉起连接。
|
|
114
130
|
|
|
115
131
|
---
|
|
116
132
|
|
|
@@ -155,6 +171,23 @@ axi mcp --server a,b --transport http --port 8321 # HTTP 长驻服务
|
|
|
155
171
|
}
|
|
156
172
|
```
|
|
157
173
|
|
|
174
|
+
### HTTP 形态下给每个客户端传变量:`Axi-*` header
|
|
175
|
+
|
|
176
|
+
stdio 是一客户端一进程,变量走 `env` 即可;HTTP 是共享进程,进程环境变量在启动时定死,**每个客户端的变量改走请求头**。约定:`Axi-<名字>` 映射为变量名(去前缀、`-`→`_`、转大写),工具内用 `axi.env` 读取,请求级值优先于进程环境变量:
|
|
177
|
+
|
|
178
|
+
```json
|
|
179
|
+
{
|
|
180
|
+
"mcpServers": {
|
|
181
|
+
"my-tools": {
|
|
182
|
+
"url": "http://your-host:8321/mcp",
|
|
183
|
+
"headers": { "Axi-API-KEY": "sk_xxx", "Axi-REGION": "cn" }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
工具里 `axi.env("API_KEY")` 即得 `sk_xxx`,不同客户端互不可见。注意:这解决的是"客户端带变量进来",不是服务端准入控制——公网暴露时另需网关层鉴权。
|
|
190
|
+
|
|
158
191
|
---
|
|
159
192
|
|
|
160
193
|
## 反模式
|
|
@@ -163,7 +196,7 @@ axi mcp --server a,b --transport http --port 8321 # HTTP 长驻服务
|
|
|
163
196
|
- 所有参数都写 `str` — 失去类型约束和枚举提示,Agent 要靠 describe 里的自然语言猜
|
|
164
197
|
- `description` 只写一个词("搜索"、"查询")— BM25 召回差、Embedding 语义稀薄
|
|
165
198
|
- 返回不稳定结构(同一工具有时返 list 有时返 dict)— 下游 Agent 没法写聚合逻辑
|
|
166
|
-
- 把敏感配置(API Key)硬编码在 `@tool` 函数里 —
|
|
199
|
+
- 把敏感配置(API Key)硬编码在 `@tool` 函数里 — 用 `axi.env` 读取(MCP server 的凭据走 `mcpServers[...].env` / `headers`)
|
|
167
200
|
- entry_points 的 value 写成文件路径 `"pkg/tools.py"` — 必须是可 import 的模块路径 `"pkg.tools"`
|
|
168
201
|
|
|
169
202
|
---
|
|
@@ -4,9 +4,10 @@ import logging
|
|
|
4
4
|
from typing import Any, Callable
|
|
5
5
|
|
|
6
6
|
from axi.cli import get_executor, get_registry
|
|
7
|
+
from axi.context import env
|
|
7
8
|
from axi.providers.native import register_tool
|
|
8
9
|
|
|
9
|
-
__all__ = ["tool"]
|
|
10
|
+
__all__ = ["tool", "env"]
|
|
10
11
|
|
|
11
12
|
# 包级 logger:各子模块通过 logging.getLogger(__name__) 自动继承
|
|
12
13
|
_logger = logging.getLogger("axi")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""请求级变量:native 工具的统一变量读取入口。
|
|
2
|
+
|
|
3
|
+
stdio / CLI 形态下变量来自进程环境变量;HTTP MCP 形态下 server 是共享进程,
|
|
4
|
+
每个客户端的变量通过 ``Axi-*`` header 随请求传入,按请求隔离。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
|
|
11
|
+
_request_env: ContextVar[dict[str, str]] = ContextVar("axi_request_env", default={})
|
|
12
|
+
|
|
13
|
+
_ENV_HEADER_PREFIX = "axi-"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_request_env(headers: Mapping[str, str] | None) -> None:
|
|
17
|
+
"""从 ``Axi-*`` header 提取当前请求的变量:``Axi-Foo-Bar`` → ``FOO_BAR``。
|
|
18
|
+
|
|
19
|
+
由 mcp_serve 在每次 native 工具调用前调用;``None``(stdio 无请求)置空。
|
|
20
|
+
ASGI 规范保证 header 名已小写。
|
|
21
|
+
"""
|
|
22
|
+
_request_env.set(
|
|
23
|
+
{
|
|
24
|
+
name.removeprefix(_ENV_HEADER_PREFIX).replace("-", "_").upper(): value
|
|
25
|
+
for name, value in headers.items()
|
|
26
|
+
if name.startswith(_ENV_HEADER_PREFIX)
|
|
27
|
+
}
|
|
28
|
+
if headers
|
|
29
|
+
else {}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def env(name: str, default: str | None = None) -> str | None:
|
|
34
|
+
"""读取变量:请求级(HTTP header)优先,其次进程环境变量。"""
|
|
35
|
+
value = _request_env.get().get(name)
|
|
36
|
+
return value if value is not None else os.environ.get(name, default)
|
|
@@ -24,6 +24,10 @@ logger = logging.getLogger(__name__)
|
|
|
24
24
|
_DAEMON_START_POLL_RETRIES = 30
|
|
25
25
|
_DAEMON_START_POLL_INTERVAL = 0.1 # seconds
|
|
26
26
|
|
|
27
|
+
# StreamReader 缓冲区上限。默认 64KB 放不下完整工具列表(list/search 的响应是单行
|
|
28
|
+
# JSON,工具多时轻松超过 64KB),会抛 asyncio.LimitOverrunError。调大到 8MB。
|
|
29
|
+
_STREAM_LIMIT = 8 * 1024 * 1024
|
|
30
|
+
|
|
27
31
|
|
|
28
32
|
def is_daemon_running() -> bool:
|
|
29
33
|
"""检查 daemon 是否在运行。"""
|
|
@@ -92,7 +96,9 @@ def daemon_request(req: DaemonRequest) -> DaemonResponse:
|
|
|
92
96
|
|
|
93
97
|
async def _send(req: DaemonRequest) -> DaemonResponse:
|
|
94
98
|
try:
|
|
95
|
-
reader, writer = await asyncio.open_unix_connection(
|
|
99
|
+
reader, writer = await asyncio.open_unix_connection(
|
|
100
|
+
SOCKET_PATH, limit=_STREAM_LIMIT
|
|
101
|
+
)
|
|
96
102
|
except OSError as e:
|
|
97
103
|
return DaemonResponse.fail(
|
|
98
104
|
f"Cannot connect to daemon: {e}. Try: axi daemon stop && axi daemon start"
|
|
@@ -18,6 +18,7 @@ import mcp.types as types
|
|
|
18
18
|
from mcp.server.lowlevel import Server
|
|
19
19
|
|
|
20
20
|
from axi.config import app_config
|
|
21
|
+
from axi.context import set_request_env
|
|
21
22
|
from axi.daemon.client import daemon_request
|
|
22
23
|
from axi.daemon.protocol import LOG_PATH, DaemonRequest
|
|
23
24
|
from axi.executor import Executor
|
|
@@ -87,10 +88,18 @@ def build_registry(
|
|
|
87
88
|
|
|
88
89
|
|
|
89
90
|
async def _execute(
|
|
90
|
-
|
|
91
|
+
server: Server,
|
|
92
|
+
registry: Registry,
|
|
93
|
+
executor: Executor,
|
|
94
|
+
name: str,
|
|
95
|
+
params: dict[str, Any],
|
|
91
96
|
) -> RunResult:
|
|
92
97
|
"""按来源分发执行:native 进程内,MCP 转发 daemon。
|
|
93
98
|
|
|
99
|
+
native 执行前把当前 HTTP 请求的 Axi-* header 写入请求级变量
|
|
100
|
+
(stdio 无请求对象,置空);MCP 工具在 daemon 进程执行,读不到
|
|
101
|
+
contextvar,不捕获。
|
|
102
|
+
|
|
94
103
|
Executor.run 与 send_request 内部都会 asyncio.run,
|
|
95
104
|
必须放到 worker 线程执行以避免嵌套事件循环。
|
|
96
105
|
"""
|
|
@@ -100,6 +109,8 @@ async def _execute(
|
|
|
100
109
|
return RunResult.fail(str(e))
|
|
101
110
|
|
|
102
111
|
if meta.source == ToolSource.NATIVE:
|
|
112
|
+
request = server.request_context.request
|
|
113
|
+
set_request_env(request.headers if request else None)
|
|
103
114
|
return await asyncio.to_thread(executor.run, meta.full_name, params)
|
|
104
115
|
|
|
105
116
|
# daemon_request 内含 ensure_daemon:daemon idle 自杀后长驻 serve 进程可自动重启它
|
|
@@ -174,7 +185,7 @@ def _build_flat_server(registry: Registry, executor: Executor) -> Server:
|
|
|
174
185
|
raise ValueError(
|
|
175
186
|
f"Unknown tool: {name}. Call tools/list to see the available tools."
|
|
176
187
|
)
|
|
177
|
-
result = await _execute(registry, executor, full_name, arguments)
|
|
188
|
+
result = await _execute(server, registry, executor, full_name, arguments)
|
|
178
189
|
if result.status == "error":
|
|
179
190
|
raise RuntimeError(result.error)
|
|
180
191
|
return _text(result.data)
|
|
@@ -269,7 +280,11 @@ def _build_meta_server(registry: Registry, executor: Executor) -> Server:
|
|
|
269
280
|
return _text(out[0] if len(out) == 1 else out)
|
|
270
281
|
if name == "run":
|
|
271
282
|
result = await _execute(
|
|
272
|
-
|
|
283
|
+
server,
|
|
284
|
+
registry,
|
|
285
|
+
executor,
|
|
286
|
+
arguments["name"],
|
|
287
|
+
arguments.get("params") or {},
|
|
273
288
|
)
|
|
274
289
|
return _text(result.model_dump(exclude_none=True))
|
|
275
290
|
raise ValueError(
|
|
@@ -8,6 +8,7 @@ import threading
|
|
|
8
8
|
from contextlib import AsyncExitStack
|
|
9
9
|
from typing import Any, Self
|
|
10
10
|
|
|
11
|
+
import httpx
|
|
11
12
|
from mcp import ClientSession, StdioServerParameters
|
|
12
13
|
from mcp.client.stdio import stdio_client
|
|
13
14
|
from mcp.client.streamable_http import streamable_http_client
|
|
@@ -49,27 +50,40 @@ class MCPConnection:
|
|
|
49
50
|
self._exit_stack: AsyncExitStack | None = None
|
|
50
51
|
|
|
51
52
|
async def connect(self) -> None:
|
|
52
|
-
"""建立连接并初始化 session
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
53
|
+
"""建立连接并初始化 session。中途失败时 with 退出自动清理已打开的资源。"""
|
|
54
|
+
async with AsyncExitStack() as stack:
|
|
55
|
+
if self.config.url:
|
|
56
|
+
http_client = None
|
|
57
|
+
if self.config.headers:
|
|
58
|
+
# 超时与 SDK 默认客户端(create_mcp_http_client)一致:
|
|
59
|
+
# 读超时 300s 是 SSE 长调用的存活窗口,不能用整体 30s
|
|
60
|
+
http_client = await stack.enter_async_context(
|
|
61
|
+
httpx.AsyncClient(
|
|
62
|
+
headers=self.config.headers,
|
|
63
|
+
timeout=httpx.Timeout(30, read=300),
|
|
64
|
+
follow_redirects=True,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
read_stream, write_stream, _ = await stack.enter_async_context(
|
|
68
|
+
streamable_http_client(self.config.url, http_client=http_client)
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
server_params = StdioServerParameters(
|
|
72
|
+
command=self.config.command,
|
|
73
|
+
args=self.config.args,
|
|
74
|
+
env=self.config.env,
|
|
75
|
+
)
|
|
76
|
+
devnull = stack.enter_context(open(os.devnull, "w"))
|
|
77
|
+
read_stream, write_stream = await stack.enter_async_context(
|
|
78
|
+
stdio_client(server_params, errlog=devnull)
|
|
79
|
+
)
|
|
80
|
+
session = await stack.enter_async_context(
|
|
81
|
+
ClientSession(read_stream, write_stream)
|
|
68
82
|
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
83
|
+
await session.initialize()
|
|
84
|
+
# 全部成功才接管资源所有权;此前任何一步抛出都由 with 清理
|
|
85
|
+
self._exit_stack = stack.pop_all()
|
|
86
|
+
self.session = session
|
|
73
87
|
|
|
74
88
|
async def list_tools(self) -> list[ToolMeta]:
|
|
75
89
|
"""获取该 server 的所有工具。"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|