phase2 amend: __ separator (Bedrock-safe) + post_sse error diagnostics

Phase 7 verify finding from TC #26 against :model cloud:
  HTTP 400 from openrouter→Amazon Bedrock:
  "tools.0.custom.name: String should match pattern
   '^[a-zA-Z0-9_-]{1,128}$'"

Anthropic via Bedrock validates tool names against that regex and
rejects dots. PHASE2 originally chose "." as the namespace separator
("boltzmann.list_dir"); OpenAI tolerated it, Bedrock does not.

Separator switched to "__" (two underscores) everywhere — internal
API matches on-wire shape, no transformation layer:

  - repl.lua:
    - tools_schema builds "alias__name"
    - dispatch_tool_call splits via "^(.-)__(.+)$" (non-greedy → leftmost __)
    - :mcp tool parser uses same split
    - :mcp tools formatter prints "alias__name"
    - HELP block shows <alias__name>
  - safety.lua confirm_tool_call: alias.* glob → alias__* glob
  - config.lua example block: keys rewritten
  - docs/PHASE2.md: amendment header added; §1, §2 row, §3 config.lua
    row, §5 wire-shape JSON examples, §6 auto_approve schema, §7
    meta-cmd table, §12 plan all updated. Original "." references
    preserved in commit history.

Constraint: aliases must not themselves contain "__" so the parse
stays unambiguous. Tool names from MCP servers may have underscores
freely.

Second fix bundled — uninformative broker error:
  Previously "broker error: transport: HTTP response code said error"
  Now      "broker error: transport: HTTP 400: {full body snippet}"

ffi/curl.lua M.post_sse changes:
  - FAILONERROR no longer set (was hiding the response body).
  - raw_body accumulator added alongside the SSE buffer; captures
    every byte regardless of SSE shape.
  - After perform, check status_code via curl_easy_getinfo. On >=400,
    return (nil, "HTTP <code>: <body[:400]>"). 2xx unchanged.
  - End-of-stream SSE flush only runs on 2xx (no false event on
    error bodies that aren't SSE-shaped).
  - Phase 1 callers reading just first return slot stay correct.

End-to-end verified:
  - :model cloud + tools=[boltzmann__read_file ...] +
    "Use boltzmann__read_file with path=/etc/hostname" →
    Claude emits tool_call with name="boltzmann__read_file",
    args='{"path": "/etc/hostname"}'. ok=true, transport clean.
  - Force-bad tool name "bad.name.with.dots" → err string carries
    the full bedrock 400 with the regex-pattern message visible.

TC #26 (sub-loop end-to-end) is now testable against cloud — the
error that blocked it is resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:04:57 +00:00
parent 3fa6279f5b
commit f26cbd9a3a
5 changed files with 89 additions and 49 deletions
+10 -7
View File
@@ -59,7 +59,10 @@ return {
-- -- Each entry: alias = { url = "...", auth_token = "..." | auth_env = "..." }
-- -- auth_token literal > auth_env env-var indirection > nil (no auth).
-- -- Aliases become the namespace prefix on tool names sent to the model
-- -- ("<alias>.<tool>" — e.g. "boltzmann.list_dir").
-- -- ("<alias>__<tool>" — e.g. "boltzmann__list_dir"). The separator is
-- -- "__" (two underscores) because Anthropic via Bedrock validates tool
-- -- names against ^[a-zA-Z0-9_-]{1,128}$ — dots are rejected.
-- -- Aliases themselves must not contain "__".
-- boltzmann = {
-- url = "http://boltzmann.fritz.box:8080/mcp",
-- auth_env = "BOLTZMANN_MCP_TOKEN",
@@ -75,14 +78,14 @@ return {
--
-- -- Per-call confirm gate auto-approve policy.
-- -- Key forms:
-- -- "<alias>.<tool>" — auto-approve one specific tool
-- -- "<alias>.*" — auto-approve every tool on that server
-- -- "<alias>__<tool>" — auto-approve one specific tool
-- -- "<alias>__*" — auto-approve every tool on that server
-- -- Anything not matched falls back to the [y/N] prompt.
-- auto_approve = {
-- ["boltzmann.read_file"] = true,
-- ["boltzmann.list_dir"] = true,
-- ["boltzmann.search_files"] = true,
-- ["hertz.*"] = true, -- trust the hub fully
-- ["boltzmann__read_file"] = true,
-- ["boltzmann__list_dir"] = true,
-- ["boltzmann__search_files"] = true,
-- ["hertz__*"] = true, -- trust the hub fully
-- },
--
-- -- Tool-call sub-loop budget per ask_ai turn. Hitting the cap surfaces
+31 -18
View File
@@ -2,9 +2,22 @@
**Project:** aish — AI-augmented conversational shell
**Document:** Phase 2 Requirements, Architecture & Design Decisions
**Status:** Plan (review pass folded in 2026-05-12)
**Status:** Verify (Phase 7) — implementation complete; live testing in progress
**Date:** 2026-05-12
**Amendments since formulate:**
- 2026-05-12 (review fold-in): see §12 "Review fold-in" subsection.
- 2026-05-12 (Phase 7 verify, separator switch): tool-name namespace
delimiter changed from `.` to `__` because Anthropic via Bedrock
validates tool names against `^[a-zA-Z0-9_-]{1,128}$` — dots are
rejected with `HTTP 400 tools.0.custom.name: String should match
pattern '...'`. Discovered when `:model cloud` exercised TC #26
against the real cloud path. Internal API matches on-wire shape so
there's no transformation layer. Constraint: aliases must not
themselves contain `__` so the parse stays unambiguous (leftmost
`__` is the split point). Tool names from MCP servers may contain
underscores freely. All §3/§5/§6/§7/§12 references updated below.
PHASE0.md is the locked substrate; PHASE1.md is layered on top. This
manifest specifies what Phase 2 adds. Section numbers reference back to
PHASE0.md / PHASE1.md where relevant.
@@ -18,7 +31,7 @@ Three pillars per PHASE0.md §11 row 2:
1. **MCP client** (`mcp.lua`) — JSON-RPC 2.0 over HTTP+SSE transport.
Target reference implementation: `lmcp`. Operations needed for v1:
`initialize`, `tools/list`, `tools/call`. Multiple servers may be
connected concurrently; tools are namespaced `<server>.<tool>`.
connected concurrently; tools are namespaced `<server>__<tool>`.
2. **Tool-calling protocol bridge** — the broker sends OpenAI-compatible
`tools` in the request body; the model emits `tool_calls` in the
response; `mcp.lua` dispatches each call to the right server; the
@@ -53,7 +66,7 @@ Three pillars per PHASE0.md §11 row 2:
| MCP protocol version | `2025-03-26` (confirmed by live probe of boltzmann:8080/mcp) | lmcp pins this in `MCP_VERSION` and **does not negotiate** — it returns its compiled-in version regardless of what the client sends (lmcp.lua:80-91). aish sends `2025-03-26` in `initialize` and accepts whatever the server returns; on mismatch it logs `[aish] mcp <alias>: protocol version mismatch (sent X, got Y); proceeding` and continues. v1 has no version-gated behavior to abort on. |
| MCP auth | Bearer token via `Authorization: Bearer <token>` header, per-server | Analyze finding: every lmcp deployment in mfritsche's fleet (boltzmann/hertz/pve*/nc/etc.) requires Bearer auth. Phase 2 config supports `auth_token` literal and `auth_env` env-var indirection per server (mirrors `key_env` in the models registry). lmcp servers without auth (broglie/higgs LAN-only) just leave the field nil. |
| Tool-call wire format | OpenAI `tools` field on `/v1/chat/completions` body; `tool_calls` on assistant deltas; `role:"tool"` turn with `tool_call_id` for results | Standard, supported by llama.cpp and OpenRouter. Aligns with the existing `/v1/chat/completions` substrate invariant. |
| Tool namespacing | `<server-alias>.<tool-name>` for both the wire-level tool name and `:mcp tools` listing | Avoids name collisions across servers. The alias comes from the config key or the connect URL hash. |
| Tool namespacing | `<server-alias>__<tool-name>` for both the wire-level tool name and `:mcp tools` listing (was `.` at formulate; switched 2026-05-12 — see Amendments above) | Avoids name collisions across servers. The alias comes from the config key or the connect URL hash. `__` (two underscores) is within Bedrock's tool-name regex `^[a-zA-Z0-9_-]{1,128}$` whereas `.` is not. Aliases must not themselves contain `__`. |
| `CMD:` coexistence with tool-calls | Both stay live, no policy preference. Substrate invariant §3 unchanged. | Resolves Q6 (see §10). `CMD:` is the local-shell route; MCP tools are structured-API routes; they serve different purposes. Future phases (Norris, Phase 3) may prefer tools when both are available, but Phase 2 doesn't enforce. |
| Authorization default | Per-call confirm (mirrors PHASE0.md §10 `confirm_cmd` for shell) | Conservative default; user can opt into auto-approval per tool or per server via config. Resolves Q8. |
| System prompt augmentation | Hybrid: static frame in `broker.lua` system prompt + dynamic `tools` array in the request body | Tool list goes in the API field where it belongs; the system prompt only mentions that tools exist and how to use them. Per-request body cost is bounded (tools change rarely; small schemas). Resolves Q9. |
@@ -73,7 +86,7 @@ Three pillars per PHASE0.md §11 row 2:
| `context.lua` | turns = {{role, content}, ...} + `pending_exec_output`; `Context:append` asserts `turn.content` and rebuilds the entry as `{role, content}` only — extra fields are dropped | Three concrete edits: (a) **loosen `:append`** so `role == "assistant"` can carry `tool_calls = [{id, name, arguments}]` with `content` allowed empty, and `role == "tool"` requires `tool_call_id` + `content` (the assert moves from "content required" to "shape per role"); (b) **preserve `tool_calls` and `tool_call_id`** in the stored turn (not just role+content); (c) `to_messages()` emits `tool_calls` on assistant turns and `tool_call_id` on tool turns. Add a debug assertion that `role == "tool"` follows an assistant turn with non-empty `tool_calls` (catches design bugs early; N4 in review). **`pending_exec_output` interaction**: the buffer **persists across the tool-call sub-loop** (the loop is internal — no user input happens — so there's no append_user to flush against). It flushes on the next genuine user turn, regardless of how many tool-call iterations preceded. |
| `repl.lua` | meta cmds + ask_ai stream loop | After ask_ai sees `tool_calls`, enter a tool-execution sub-loop: confirm-gate each call via `safety.confirm_tool_call`, dispatch via `mcp.session:call_tool`, append tool turn to context, re-issue the broker request. Loop until assistant emits text without tool_calls. New meta: `:mcp connect <url> [alias]`, `:mcp list`, `:mcp tools`, `:mcp disconnect <alias>`. |
| `renderer.lua` | streaming text + exec frame | Add `tool_call_begin(name, args)`, `tool_call_end(result, ok)`. Visual style: indented, dim, parallel to the exec frame. |
| `config.lua` | example with models/shell/context/history | Schema additions: `mcp = { servers = { alias = { url = "..." } }, auto_approve = { ["alias.tool"] = true } }`. Documented in §10 below. |
| `config.lua` | example with models/shell/context/history | Schema additions: `mcp = { servers = { alias = { url = "..." } }, auto_approve = { ["alias__tool"] = true } }`. Documented in §6 below. |
| `ffi/curl.lua` | post + post_sse; `M.post` does not set `FAILONERROR`, so non-2xx responses return the body as a normal string. `ffi.cdef` exposes only `curl_easy_setopt` — no `curl_easy_getinfo` (cdef block at curl.lua:11-28). | **One small extension**: `M.post` returns **`(body, status_code)` on transport success** (status_code may be non-2xx — caller decides what to do; mcp.lua treats `>= 400` as transport failure). `(nil, errmsg)` on libcurl-level failure is **unchanged** — Phase 1 callers that read only the first slot stay correct. Requires adding `curl_easy_getinfo` + `CURLINFO_RESPONSE_CODE` (decimal 2097154, `CURLINFOTYPE_LONG | 2`) to the `ffi.cdef` block, plus a `long[1]` out-param shim. MCP auth failures from lmcp arrive as HTTP `401` with a non-JSON-RPC body (`{"error":"unauthorized"}`); `mcp.lua` must distinguish HTTP-level failure from JSON-RPC envelope errors. No SSE GET channel is added (analyze finding ruled it out for lmcp). |
| `history.lua` | JSONL session log | Tool turns are logged like any other turn — `{role:"tool", tool_call_id:"...", content:"..."}`. Resume reconstructs them via `ctx:append` like user/assistant turns. |
@@ -185,7 +198,7 @@ This split resolves Q21 (with the C5/C7 review fix folded in).
"temperature": 0.2,
"tools": [
{ "type":"function",
"function": { "name":"<alias>.<tool>",
"function": { "name":"<alias>__<tool>",
"description":"...",
"parameters": <inputSchema> } },
...
@@ -195,7 +208,7 @@ This split resolves Q21 (with the C5/C7 review fix folded in).
The `tools` array is assembled by `mcp.tools_schema()` — flattens
`tools/list` results from every connected session, namespacing each tool
as `<alias>.<name>`.
as `<alias>__<name>`.
### Response handling (streaming)
@@ -203,7 +216,7 @@ llama.cpp / OpenAI deltas may include:
```json
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_…",
"function":{"name":"alias.tool","arguments":"{\"a\":"}}]}}]}
"function":{"name":"alias__tool","arguments":"{\"a\":"}}]}}]}
data: {"choices":[{"delta":{"tool_calls":[{"index":0,
"function":{"arguments":"1}"}}]}}]}
data: {"choices":[{"finish_reason":"tool_calls",...}]}
@@ -233,7 +246,7 @@ that text first, then renders the tool-call frame around dispatch.
ctx:append({
role = "assistant",
content = accumulated_text, -- may be "" if model emitted no prose
tool_calls = { {id="call_…", name="alias.tool", arguments=<json-string>} },
tool_calls = { {id="call_…", name="alias__tool", arguments=<json-string>} },
})
ctx:append({
role = "tool",
@@ -269,7 +282,7 @@ Hit-cap surfaces as a status: `[aish] tool-call depth limit reached`.
function M.confirm_tool_call(name, args, cfg)
local policy = cfg.mcp and cfg.mcp.auto_approve or {}
if policy[name] then return true end
-- Per-server prefix check: "alias.*" entries
-- Per-server prefix check: "alias__*" entries
local alias = name:match("^([^.]+)%.")
if alias and policy[alias .. ".*"] then return true end
-- Otherwise prompt
@@ -298,8 +311,8 @@ mcp = {
},
},
auto_approve = {
["boltzmann.read_file"] = true, -- specific tool
["broglie.*"] = true, -- whole server
["boltzmann__read_file"] = true, -- specific tool
["broglie__*"] = true, -- whole server
},
max_tool_depth = 8,
}
@@ -321,8 +334,8 @@ heuristic decides; for non-destructive tools, auto_approve. Outside scope here.
| `:mcp connect <url> [<alias>]` | Open a session; perform initialize + tools/list; add to active set |
| `:mcp disconnect <alias>` | Close one session |
| `:mcp list` | Show connected sessions (alias, url, tool count, status) |
| `:mcp tools` | List tools across all sessions (`alias.name` — short description) |
| `:mcp tool <alias.name>` | Show one tool's full inputSchema (debug aid) |
| `:mcp tools` | List tools across all sessions (`alias__name` — short description) |
| `:mcp tool <alias__name>` | Show one tool's full inputSchema (debug aid) |
Existing `:help` updated to list these.
@@ -445,7 +458,7 @@ and Phase 1 implementation cadence.
2. **`safety.lua` — confirm-gate surface.** Implement just
`M.confirm_tool_call(name, args, cfg)` per §6. Reads
`cfg.mcp.auto_approve` for exact-match and `alias.*` glob. Falls back
`cfg.mcp.auto_approve` for exact-match and `alias__*` glob. Falls back
to `rl.readline` prompt. Norris-mode hooks stay out (Phase 3). **Test
in isolation** with mocked rl + various policy shapes.
@@ -460,7 +473,7 @@ and Phase 1 implementation cadence.
user turn (§3 row). **Tests in isolation**: (i) build a context with
assistant+tool_calls + tool turns, round-trip through `to_messages()`,
eyeball JSON shape; (ii) day-one fallback test (N8) — same context
with `use_tool_role = false` must emit the `[tool: alias.name]\n…`
with `use_tool_role = false` must emit the `[tool: alias__name]\n…`
prefix shape instead of a `role:"tool"` message.
4. **`renderer.lua` extensions.** Add `M.tool_call_begin(name, args)`
@@ -483,8 +496,8 @@ and Phase 1 implementation cadence.
6. **`repl.lua` wiring.** New module-local `mcp_sessions = {alias=session,...}`,
populated from `config.mcp.servers` at startup. Helpers:
- `tools_schema()` → flatten `tool` lists across sessions, namespace `alias.name`
- `dispatch_tool_call(call)` → split `alias.tool`, look up session, call, return content
- `tools_schema()` → flatten `tool` lists across sessions, namespace `alias__name`
- `dispatch_tool_call(call)` → split `alias__tool`, look up session, call, return content
- `ask_ai` loop now: stream response → if any tool_calls completed,
for each call: `safety.confirm_tool_call` → `dispatch_tool_call` →
append assistant-with-tool_calls + tool turn → re-call `broker.chat_stream`
@@ -543,7 +556,7 @@ and Phase 1 implementation cadence.
- **Q18 fallback path** (strict templates rejecting `role:"tool"`).
Plumb a `context.use_tool_role` flag (default true). If a real-world
rejection appears at Phase 7, flip the flag and convert tool turns to
`[tool: alias.name]\n<content>` prefix on the next user turn (same
`[tool: alias__name]\n<content>` prefix on the next user turn (same
pattern as `pending_exec_output`). **Day-one verification** (N8 in
review): commit #3 includes a small in-isolation test that builds a
context with `use_tool_role = false`, appends an assistant+tool_calls
+27 -8
View File
@@ -157,7 +157,11 @@ function M.post_sse(url, body, headers, on_event, timeout_ms)
if handle == nil then return nil, "curl_easy_init returned NULL" end
-- SSE parse state: buffer holds incomplete tail between callback deliveries.
local buffer = ""
-- raw_body captures every byte we receive (regardless of SSE shape) so we
-- can surface upstream error bodies (e.g. openrouter→bedrock 400 with a
-- non-SSE JSON envelope). Truncated only at error-message time.
local buffer = ""
local raw_body = ""
local cb_error = nil
local write_cb = ffi.cast(
@@ -169,7 +173,9 @@ function M.post_sse(url, body, headers, on_event, timeout_ms)
-- documents that as process-fatal. Surface via cb_error and let
-- curl keep draining (return n) so we can report after perform.
local ok, err = pcall(function()
buffer = buffer .. ffi.string(ptr, n)
local chunk = ffi.string(ptr, n)
raw_body = raw_body .. chunk
buffer = buffer .. chunk
while true do
local b = buffer:find("\n\n", 1, true)
if not b then break end
@@ -206,21 +212,30 @@ function M.post_sse(url, body, headers, on_event, timeout_ms)
setopt_ptr (handle, OPT.HTTPHEADER, slist)
setopt_ptr (handle, OPT.WRITEFUNCTION, write_cb)
setopt_long(handle, OPT.NOSIGNAL, 1)
setopt_long(handle, OPT.FAILONERROR, 1)
-- FAILONERROR intentionally NOT set: we want to read the response body
-- on >=400 so the caller can surface upstream API errors (bedrock
-- rejecting tool-name format, openrouter quota, etc.) instead of just
-- "HTTP response code said error". Status code is checked after perform.
setopt_str (handle, OPT.USERAGENT, "aish/0.0 (luajit-ffi)")
if timeout_ms then
setopt_long(handle, OPT.TIMEOUT_MS, timeout_ms)
end
local rc = C.curl_easy_perform(handle)
local err
if rc ~= 0 then err = ffi.string(C.curl_easy_strerror(rc)) end
local err, status
if rc == 0 then
status = get_response_code(handle)
else
err = ffi.string(C.curl_easy_strerror(rc))
end
-- End-of-stream flush: the final event may lack a trailing \n\n if the
-- server closed the connection right after writing the last data: line
-- (some llama.cpp builds, and any plain HTTP/1.0 close-on-EOF feed).
-- Parse any remaining buffer content as one last event. Same pcall shield.
if rc == 0 and #buffer > 0 then
-- Only flush on 2xx — on error responses the buffer is the error body,
-- not an SSE event.
if rc == 0 and status < 400 and #buffer > 0 then
local ok, perr = pcall(function()
local data_parts = {}
for line in (buffer .. "\n"):gmatch("([^\n]*)\n") do
@@ -240,8 +255,12 @@ function M.post_sse(url, body, headers, on_event, timeout_ms)
write_cb:free()
if cb_error then return nil, "callback: " .. tostring(cb_error) end
if rc == 0 then return true end
return nil, err
if rc ~= 0 then return nil, err end
if status >= 400 then
local snippet = raw_body ~= "" and raw_body:sub(1, 400) or "(no body)"
return nil, ("HTTP %d: %s"):format(status, snippet)
end
return true
end
return M
+15 -12
View File
@@ -30,7 +30,7 @@ Meta commands:
:resume <name> load <name>.jsonl turns into the in-memory context
:mcp list show connected MCP servers
:mcp tools list tools across all sessions
:mcp tool <alias.name> show one tool's inputSchema
:mcp tool <alias__name> show one tool's inputSchema
:mcp connect <url> [a] open an MCP session at runtime
:mcp disconnect <alias> drop an MCP session
:help this message
@@ -82,8 +82,11 @@ function M.run(config)
end
-- Assemble OpenAI-shape `tools` array across all live sessions, with
-- alias.name namespacing per PHASE2.md §5. Empty array → broker omits
-- the field entirely (§12 risk row 1).
-- "alias__name" namespacing. Originally PHASE2 used "." as the separator,
-- but Anthropic via Bedrock validates tool names against
-- ^[a-zA-Z0-9_-]{1,128}$ and rejects dots — amended to "__" 2026-05-12.
-- Empty array → broker omits the field entirely (§12 risk row 1).
-- Aliases must not themselves contain "__" so the parse stays unambiguous.
local function tools_schema()
local out = {}
for alias, sess in pairs(mcp_sessions) do
@@ -91,7 +94,7 @@ function M.run(config)
out[#out + 1] = {
type = "function",
["function"] = {
name = alias .. "." .. t.name,
name = alias .. "__" .. t.name,
description = t.description or "",
parameters = t.inputSchema
or { type = "object", properties = {} },
@@ -123,14 +126,14 @@ function M.run(config)
return table.concat(parts, "\n")
end
-- Split <alias>.<tool>, look up session, call. Returns (content_string,
-- Split <alias>__<tool>, look up session, call. Returns (content_string,
-- is_error). Errors of all flavors (rpc, transport, missing alias)
-- yield a synthesized "[aish] tool ... failed: ..." string so the
-- caller always has a body for the role:"tool" turn — the strict-
-- template alternation rationale per PHASE0.md §6 and the C5/C7 fold
-- in PHASE2.md §4.
-- in PHASE2.md §4. Non-greedy "(.-)__(.+)" splits at the leftmost "__".
local function dispatch_tool_call(name, args)
local alias, tool_name = name:match("^([^.]+)%.(.+)$")
local alias, tool_name = name:match("^(.-)__(.+)$")
if not alias then
return ("[aish] tool name has no alias prefix: %s"):format(name), true
end
@@ -472,7 +475,7 @@ function M.run(config)
for _, t in ipairs(sess:list_tools()) do
any = true
local desc = (t.description or ""):gsub("\n", " ")
io.write((" %s.%-18s %s\n"):format(
io.write((" %s__%-16s %s\n"):format(
alias, t.name, desc:sub(1, 60)))
end
end
@@ -480,10 +483,10 @@ function M.run(config)
elseif sub == "tool" then
local name = sub_args:match("^%s*(%S+)")
if not name then
renderer.status("usage: :mcp tool <alias.name>"); return
renderer.status("usage: :mcp tool <alias__name>"); return
end
local alias, tname = name:match("^([^.]+)%.(.+)$")
if not alias then
local alias, tname = name:match("^(.-)__(.+)$")
if not alias or alias == "" then
renderer.status("tool name missing alias prefix: " .. name)
return
end
@@ -499,7 +502,7 @@ function M.run(config)
if not found then
renderer.status("unknown tool: " .. name); return
end
io.write((" %s.%s\n"):format(alias, found.name))
io.write((" %s__%s\n"):format(alias, found.name))
io.write((" description: %s\n"):format(found.description or "(none)"))
io.write(" inputSchema:\n ")
io.write((json.encode(found.inputSchema or {}, {indent = true})
+6 -4
View File
@@ -25,14 +25,16 @@ end
-- Ask the user whether tool `name` may be called with `args`, consulting
-- `cfg.mcp.auto_approve` first. Policy keys:
-- "<alias>.<tool>" → exact-match auto-approve
-- "<alias>.*" → whole-server auto-approve
-- "<alias>__<tool>" → exact-match auto-approve
-- "<alias>__*" → whole-server auto-approve
-- Anything else falls back to a [y/N] prompt; empty / non-"y" answer rejects.
-- The separator switched from "." to "__" 2026-05-12 because Anthropic via
-- Bedrock rejects dots in tool names (regex ^[a-zA-Z0-9_-]{1,128}$).
function M.confirm_tool_call(name, args, cfg)
local policy = (cfg and cfg.mcp and cfg.mcp.auto_approve) or {}
if policy[name] then return true end
local alias = name:match("^([^.]+)%.")
if alias and policy[alias .. ".*"] then return true end
local alias = name:match("^(.-)__")
if alias and alias ~= "" and policy[alias .. "__*"] then return true end
local prompt = ("call '%s'? [y/N] "):format(pretty_call(name, args))
local ans = rl.readline(prompt) or ""