From f26cbd9a3a4c70bc24113e06c842ea24bbda0fb6 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Tue, 12 May 2026 20:04:57 +0000 Subject: [PATCH] phase2 amend: __ separator (Bedrock-safe) + post_sse error diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - 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 : "). 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) --- config.lua | 17 ++++++++++------- docs/PHASE2.md | 49 +++++++++++++++++++++++++++++++------------------ ffi/curl.lua | 35 +++++++++++++++++++++++++++-------- repl.lua | 27 +++++++++++++++------------ safety.lua | 10 ++++++---- 5 files changed, 89 insertions(+), 49 deletions(-) diff --git a/config.lua b/config.lua index 1a51bf3..a1fb895 100644 --- a/config.lua +++ b/config.lua @@ -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 - -- -- ("." — e.g. "boltzmann.list_dir"). + -- -- ("__" — 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: - -- -- "." — auto-approve one specific tool - -- -- ".*" — auto-approve every tool on that server + -- -- "__" — auto-approve one specific tool + -- -- "__*" — 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 diff --git a/docs/PHASE2.md b/docs/PHASE2.md index d67b265..6f16d77 100644 --- a/docs/PHASE2.md +++ b/docs/PHASE2.md @@ -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 `.`. + connected concurrently; tools are namespaced `__`. 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 : 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 ` 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 | `.` 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 | `__` 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 [alias]`, `:mcp list`, `:mcp tools`, `:mcp disconnect `. | | `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":".", + "function": { "name":"__", "description":"...", "parameters": } }, ... @@ -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 `.`. +as `__`. ### 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=} }, + tool_calls = { {id="call_…", name="alias__tool", arguments=} }, }) 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 []` | Open a session; perform initialize + tools/list; add to active set | | `:mcp disconnect ` | 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 ` | Show one tool's full inputSchema (debug aid) | +| `:mcp tools` | List tools across all sessions (`alias__name` — short description) | +| `:mcp tool ` | 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` prefix on the next user turn (same + `[tool: alias__name]\n` 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 diff --git a/ffi/curl.lua b/ffi/curl.lua index bb2fd0a..44bc331 100644 --- a/ffi/curl.lua +++ b/ffi/curl.lua @@ -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 diff --git a/repl.lua b/repl.lua index eaac1e1..c1c3cd9 100644 --- a/repl.lua +++ b/repl.lua @@ -30,7 +30,7 @@ Meta commands: :resume load .jsonl turns into the in-memory context :mcp list show connected MCP servers :mcp tools list tools across all sessions - :mcp tool show one tool's inputSchema + :mcp tool show one tool's inputSchema :mcp connect [a] open an MCP session at runtime :mcp disconnect 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 ., look up session, call. Returns (content_string, + -- Split __, 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 "); return + renderer.status("usage: :mcp tool "); 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}) diff --git a/safety.lua b/safety.lua index 044937d..34f012e 100644 --- a/safety.lua +++ b/safety.lua @@ -25,14 +25,16 @@ end -- Ask the user whether tool `name` may be called with `args`, consulting -- `cfg.mcp.auto_approve` first. Policy keys: --- "." → exact-match auto-approve --- ".*" → whole-server auto-approve +-- "__" → exact-match auto-approve +-- "__*" → 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 ""