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.
Files changed (28) hide show
  1. {axi_cli-0.0.7 → axi_cli-0.0.9}/CHANGELOG.md +14 -0
  2. {axi_cli-0.0.7 → axi_cli-0.0.9}/PKG-INFO +1 -1
  3. {axi_cli-0.0.7 → axi_cli-0.0.9}/pyproject.toml +1 -1
  4. {axi_cli-0.0.7 → axi_cli-0.0.9}/skills/axi-creator/SKILL.md +36 -3
  5. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/__init__.py +2 -1
  6. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/config.py +1 -0
  7. axi_cli-0.0.9/src/axi/context.py +36 -0
  8. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/daemon/client.py +7 -1
  9. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/mcp_serve.py +18 -3
  10. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/providers/mcp.py +34 -20
  11. {axi_cli-0.0.7 → axi_cli-0.0.9}/.gitignore +0 -0
  12. {axi_cli-0.0.7 → axi_cli-0.0.9}/README.md +0 -0
  13. {axi_cli-0.0.7 → axi_cli-0.0.9}/skills/axi-use/SKILL.md +0 -0
  14. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/cli.py +0 -0
  15. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/daemon/protocol.py +0 -0
  16. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/daemon/server.py +0 -0
  17. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/executor.py +0 -0
  18. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/models.py +0 -0
  19. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/providers/__init__.py +0 -0
  20. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/providers/native.py +0 -0
  21. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/registry.py +0 -0
  22. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/__init__.py +0 -0
  23. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/bm25.py +0 -0
  24. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/cache.py +0 -0
  25. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/embedding.py +0 -0
  26. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/hybrid.py +0 -0
  27. {axi_cli-0.0.7 → axi_cli-0.0.9}/src/axi/search/regex.py +0 -0
  28. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axi-cli
3
- Version: 0.0.7
3
+ Version: 0.0.9
4
4
  Summary: Agent eXecution Interface - unified tool layer for AI Agents
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: bm25s>=0.3.3
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "axi-cli"
3
- version = "0.0.7"
3
+ version = "0.0.9"
4
4
  description = "Agent eXecution Interface - unified tool layer for AI Agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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" // 或 HTTP streaming
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` 二选一。保存后直接 `axi list` 就能看到新工具——daemon 会按需自动拉起连接。
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` 函数里 — 走环境变量或 `mcpServers[...].env`
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")
@@ -109,6 +109,7 @@ class MCPServerConfig(BaseModel):
109
109
  args: list[str] = Field(default_factory=list)
110
110
  env: dict[str, str] | None = None
111
111
  url: str | None = None
112
+ headers: dict[str, str] | None = None
112
113
 
113
114
 
114
115
  class NativeToolEntry(BaseModel):
@@ -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(SOCKET_PATH)
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
- registry: Registry, executor: Executor, name: str, params: dict[str, Any]
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
- registry, executor, arguments["name"], arguments.get("params") or {}
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
- self._exit_stack = AsyncExitStack()
54
-
55
- if self.config.url:
56
- read_stream, write_stream, _ = await self._exit_stack.enter_async_context(
57
- streamable_http_client(self.config.url)
58
- )
59
- else:
60
- server_params = StdioServerParameters(
61
- command=self.config.command,
62
- args=self.config.args,
63
- env=self.config.env,
64
- )
65
- devnull = self._exit_stack.enter_context(open(os.devnull, "w"))
66
- read_stream, write_stream = await self._exit_stack.enter_async_context(
67
- stdio_client(server_params, errlog=devnull)
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
- self.session = await self._exit_stack.enter_async_context(
70
- ClientSession(read_stream, write_stream)
71
- )
72
- await self.session.initialize()
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