Rand Stats

MCP::Client

zef:apogee
Revision history for MCP::Client

0.5.0  2026-08-21T21:55:14+01:00
    - execute-tool-calls reads the annotations a server publishes in its
      tools/list catalog: a batch whose calls ALL name tools annotated
      readOnlyHint + idempotentHint goes out on up to `tool-concurrency`
      (default 4) in-flight requests instead of one at a time, and identical
      calls in such a batch are ONE request whose answer is copied into each
      slot that asked for it, under that slot's own tool_call_id. Any other
      batch -- including one whose catalogue could not be fetched -- goes down
      the connection one call at a time in model order, exactly as before, and
      results are one per call in the caller's order either way. The scheduler
      is MCP::Server::Batch, shared with MCP::Server's local bridge, so a
      toolkit reached over MCP behaves like the same toolkit inside the
      process. New `tool-annotations($name)` reports what a server said about
      one of its tools; `tool-concurrency => 1` restores strict serial
      execution.
    - Leases now refuse hold-and-wait: an agent that already owns a lease gets
      an immediate actionable refusal on contention and must reacquire the
      complete working set atomically. The Table has an atomic waiter registry,
      arbitrary-length cycle detection, cancellation cleanup, and a combined
      status snapshot.
    - Filesystem mutations pin their covering lease generations across provider
      execution, deferring expiry and release until the call returns. `fs_mkdir`
      joins the default enforced set.
    - Structured tool results survive both local and remote LLM bridges under
      `structured_content`. Leases consume FileSystem `sha256-v1` observations,
      inject hidden conditional revisions into mutations, advance them on
      success, and invalidate them on failure so stale writes fail closed.
    - LOCK_ACQUIRE NOW WAITS, which amends the "acquisition never blocks"
      rule this layer shipped with. A contended acquire polls the table
      about once a second for up to `wait` seconds (a new optional tool
      argument, DEFAULT 90, capped at the table's `default-ttl`) before
      returning the refusal. The economics are the argument: fail-fast
      contention does not remove the waiting, it moves it into the MODEL,
      and every "try again" lap re-sends the entire conversation — two
      agents sharing one file can spend hundreds of thousands of prompt
      tokens taking turns at a lock that was free after four seconds,
      where polling a hash in-process costs nothing. It is safe because
      the wait is bounded twice (by `wait`, and by the TTL every lease
      expires at — the cap ties one to the other, so a holder on the
      default TTL always expires within a single full wait) and because the
      composer refuses to wait while its holder owns any lease. The Table's
      waiter graph rejects a cycle as defence in depth.
      `wait: 0` is the old acquire to the byte, refusal wording included,
      and the refusal at the end of a wait is the same standard
      holder-and-held-for message it always was. Still no queue and no
      fairness: a freed lease goes to whoever's poll lands first.
    - New Leases options `on-wait-begin` / `on-wait-end`: plain closures
      called around a wait that really begins — never for an immediate
      grant, never for `wait: 0` — so a host that caps concurrency by
      counting working agents can stop counting one parked on a lock. The
      end hook fires on EVERY exit (grant, deadline, cancel, exception:
      it is a LEAVE). Neither takes an argument, because a composer
      belongs to one agent and carries its agent-id. Both are shielded: a
      hook that throws must not turn a granted lock into an error the
      model has to interpret.
    - New Leases option `cancelled`: a thunk asked once per poll, which
      ends a wait early with the standard refusal plus a sentence saying
      the wait was cut short. It FAILS OPEN — a thunk that throws is read
      as "not cancelled" — which is the deliberate opposite of
      `concurrent`'s fail-closed rule: over-waiting costs seconds, while
      a falsely abandoned acquire hands back a refusal for a lock that
      was free and the agent then works without the lease it should have
      had.
    - New MCP::Client::Policy::Grants: the session grant book a fleet of agents
      share. A grant belongs to the human, not to the policy that happened to
      ask, so ten children with ten policies would otherwise ask the same
      question ten times. It is a small lock-protected rule list — `add(@rules)`
      (validated, all-or-nothing, answers the whole book), `list`, `elems` —
      handed to any number of policies as the new `grants-store` option. The
      doctrine is one session, one grant set.
    - MCP::Client::Policy takes `grants-store`. With one wired, an `always-`
      answer is written to the book rather than to the policy that asked, and
      every policy reads the book on EVERY decision, so a grant is in force for
      another agent's next call rather than at its next start-up. `.grants`
      renders the EFFECTIVE set (this policy's own grants, then the book), which
      is also what `&on-grant` is handed and what a session should persist —
      seed a resumed session into the store rather than into `grants`, or a rule
      is counted twice. Nothing is widened: grants are still consulted only once
      the static rules have said `ask`, so no shared grant can overrule a deny
      rule or the danger floor, and a deny grant still beats an allow grant.
    - An `always-` answer may carry `rules` (a list) instead of `rule`, for the
      coarse offers a UI makes when the suggestion is too fine-grained to help —
      "allow edits and new directories under the workspace" is four rules and
      one click. Each is validated exactly as a single rule is; the answer is
      all or nothing (one unusable rule refuses that call and remembers none of
      them), an empty list or an answer carrying both `rule` and `rules` is the
      same kind of refusal, and `&on-grant` fires once per answer however many
      rules it carried. Singular `rule` and the bare suggestion default are
      unchanged. t/22 pins the book, the fleet, the plural answer, and the
      sabotage cases where a shared grant must not be a way round a deny.
    - MCP::Client::Policy rules can now narrow a tool match by the program a
      call runs. A rule may carry `command` (a program basename, exact or a
      trailing-'*' glob, matched against the normalised basename so `git`
      matches `/usr/bin/git` and `C:\bin\git.EXE`), `args` (a leading argv
      prefix the call must begin with) and `args-any` (a set, one of which
      must appear anywhere in the argv). `command` and `under` on one rule
      are ANDed. The tri-state fails closed exactly as the path predicate
      does: an argv the engine cannot read to the end keeps a deny/ask firing
      and stops an allow.
    - Rules also carry optional `note` (why the rule exists) and `severity`
      (`danger`), both surfaced on the ask request so a prompt can explain
      and colour itself.
    - New `command-params` policy option, mirroring `path-params`: names the
      program/argv parameters per tool (default `command`/`args`).
    - A deny that narrows by command/args no longer hides its tool from the
      catalogue (only a blanket deny does) — the tool is still usable for the
      calls the rule does not refuse.
    - New MCP::Client::Policy::Commands: given a shell tool's literal argv, it
      returns the commands the call effectively runs. It strips fixed wrappers
      (env/timeout/nice/…), and when the program is a shell run with `-c` it
      lexes the payload — honouring quotes, operators (`&&`/`||`/`;`/`|`/
      newlines), subshells, redirections, command/process substitution and
      parameter expansion — and returns each simple command, recursing through
      nested shells and substitutions. So a command rule sees `rm` inside
      `bash -c "git && rm -rf /"`, not just `bash`. Everything it cannot read
      (a `$VAR`, a `$(…)`, a glob, an unbalanced quote, `env -S`) becomes an
      unreadable command rather than a guess: an allow never fires on it, a
      deny/ask always does. A permanent evasion regression suite (t/18) pins
      the published bypasses.
    - Rules can carry a `check` predicate (a string naming a policy-supplied
      semantic check) and the policy takes a `checks` option (name → callable
      returning yes/no/unknown per effective command). This is the seam the
      danger floor's target analysis rides on; an unknown check name fails
      closed. `~`/`~/…` are now kept as literal tokens by the decomposer so a
      home-target check can see them (and `~/bin/rm` basenames to `rm`).
    - New MCP::Client::Policy::Floor: `danger-floor(:$home, :$sadna-home)` +
      `floor-checks(...)` — a fixed, auditable rule list for the operations
      dangerous no matter who asked. Hard-DENY (no preset waives it): a
      recursive delete of `/` or the home directory, and a write to the
      agent's own config. Red-ask (grantable, `severity => danger`): git
      force-push / reset --hard / clean / stash-clear, sudo/doas/su/pkexec,
      shutdown/reboot, mkfs/dd/wipefs, a bare interpreter (curl|sh), and writes
      to rc files / `.git`. Safe concrete commands fall straight through. t/19
      pins the split. Adversarially reviewed and hardened: path normalisation
      collapses `.`/`..`/`//` (so `rm -rf //`, `/.`, `~/.`, a `..`-obfuscated
      config path are all caught), deny/ask command matching folds case (catches
      `RM`/`BASH`/`SUDO` on a case-insensitive host; allow stays case-exact), and
      the git dangers are a `check` that skips leading global options
      (`git -C /repo push --force`; the valued-global list matches git 2.49 and
      is version-coupled — a future git global that takes a value would need
      adding). Path normalisation expands `~`/`~user` to home's absolute
      segments before collapsing `..`, so `~/../home` (which the shell resolves
      back to home) is caught. Target paths are compared case-folded in the
      floor (the shell targets the local filesystem, case-insensitive on
      macOS/Windows, so `/Users/Bob` IS home) — server-side fs_* `under` rules
      stay case-exact. The write checks read redirect targets, positional args
      AND the value glued after an `=` (so `cp --target-directory=CONFIG` and
      dd `of=CONFIG` are caught). Deliberate residuals covered by the phase-6 OS
      sandbox, not the lexical floor: a write whose target is an unreadable
      `$VAR`, a purely relative path, a partially-globbed target, a glued short
      flag with no `=` (`-t/path`), and `curl | python`-style RCE via a
      non-shell interpreter.
    - New MCP::Client::Leases + MCP::Client::Leases::Table: advisory, in-process
      file leases for a pack of agents sharing one workspace, so the multi-step
      read-modify-write window two agents can otherwise stomp on is protected.
      (fs_edit's exact-match old-string is already single-call optimistic CAS;
      the lease covers the window CAS cannot see.) The Table is the engine-owned
      lease book — acquire is all-or-nothing and NEVER blocks, so deadlock is
      structurally impossible; contention is a value, not an exception, naming
      the holder and the age of their claim. Containment is the same lexical,
      segment-wise path-under the permission engine uses, checked in BOTH
      directions and failing closed on anything it cannot compare. Expiry is
      lazy (no timer thread), a holder re-acquiring refreshes rather than
      stacks, and release/release-holder/release-all cover explicit, drained
      and shutdown paths. The provider-composer publishes `lock_acquire(paths,
      ttl?)` and `lock_release(paths?)` (bare release = everything you hold),
      drops an inner declaration that collides with either name, and hides both
      under `publish-tools => False`. It enforces the mutating tools
      (`fs_write`/`fs_edit`/`fs_move`/`fs_delete` by default, configurable as
      tool patterns) against the table: another holder's lease is always an
      is_error naming them, and under the ruled strict-when-concurrent mode —
      driven by a `concurrent` thunk that FAILS CLOSED, so a broken liveness
      signal over-locks rather than under-protects — a mutation with no lease
      of its own is refused with an error that teaches the fix. A path the
      layer cannot judge is refused when strict and forwarded with a
      `leases-warning` key when not. Stacks UNDER the policy, so permission
      resolves before a lease is consumed. Reads are unrestricted in v1.
    - New MCP::Client::Reasons: a provider composer that adds one optional
      `reason` string parameter to every tool declaration on the way out and
      deletes it from every call on the way in, so a UI can show the human WHY
      a call is being made without any server ever seeing the field. Never
      added to `required` (an omitted why must not cost a turn on a schema
      error). A tool that already declares a `reason` of its own — or names one
      in `required` — is left completely alone in BOTH directions: its reason
      is a real server argument, and the general rule is that stripping a
      genuine parameter is a silent corruption while an unexpected one is the
      server's own validation problem. Only calls to tools augmented by the
      MOST RECENT tools-for-llm are stripped (the set is instance state,
      replaced wholesale under a lock at each listing), and only those are
      rebuilt: everything else — an unlisted tool, a call with no reason,
      arguments that are not a JSON object, the empty string models send for a
      no-argument tool — is forwarded as the very object the caller passed.
      Canonical stack position is `Policy(Reasons(Subagents(…(Registry))))`:
      BELOW the policy, so the permission prompt's copy of the call still
      carries the reason; ABOVE everything else, so the `task` tool is
      augmented too and no downstream layer sees the parameter. Because the
      strip happens at forward time only, the reason stays in the recorded
      assistant tool_calls, so transcripts and replay keep it with no schema
      change. Two safety positions are doctrine, stated in the Pod: the reason
      is a model-authored CLAIM (a UI keeps the arguments primary), and machine
      policy — rules, danger floor, classifier — must never consume it, or the
      permission layer becomes something a model can write its way through.
      t/21 pins the lot, including the stacked Policy(Reasons(Registry)) case
      where the ask sees the reason and the fixture server receives none.
    - MCP::Client::Policy.default-rules now also allows `lock_acquire` and
      `lock_release`: lease bookkeeping changes nothing a human owns, and
      prompting for an advisory lock is friction in front of an answer that is
      always yes. With no lease layer stacked, the names never appear.
    - MCP::Client::Policy::Rules exports four more of its location helpers —
      `located-args`, `root-for`, `path-params-for` and `absolutize` — so a
      layer beside the policy locates a call's paths with the same code rather
      than a second implementation of the convention. No behaviour change.
    - MCP::Client::Policy.replace-rules: swap the whole rule set atomically
      (validated first, installed under the lock), the seam a preset switch
      rides on. Shipped with the command-rules work above and undocumented
      here until now.
    - MCP::Client::Policy re-evaluates the effective grants immediately after
      acquiring the ask-lock. Calls queued behind an `always-allow` or
      `always-deny` answer are decided by the grant it left instead of re-asking
      the human the question it just settled — silently: `&on-ask` is not called
      again, `&on-grant` does not fire, and nothing further is remembered,
      because deciding by a grant never was an event. A granted deny refuses
      with the same rule-shaped message a static deny uses, since no human said
      anything about that particular call. Nothing is widened (the re-check is
      the grant path of the decision, moved inside the lock) and the new
      ask-lock → policy-lock nesting is one-way: nothing anywhere takes them in
      the other order.
    - An `always-` answer is now WRITTEN to the grants (or to the shared book)
      before the ask-lock is released, where it used to be written just after.
      That ordering is what makes the re-check above sound for the FIRST waiter
      in the queue and not only for the rest of it: grant visibility
      happens-before the release, so a waiter cannot wake into the window
      between the human's answer and the grant that answer made, and re-ask.
      `&on-grant` still fires OUTSIDE the ask-lock, after it, and still before
      the call that provoked the grant is decided — a slow listener must not
      hold up the very agents the grant has just released.
    - New `MCP::Client::Policy.grant-decision($tool, $arguments)`, for a host
      that asks the human ITSELF — its own dialog, its own bracket of locks —
      rather than through `&on-ask`, and needs the same check before it opens
      that dialog. It consults ONLY the effective grants (never the static
      rules: a rule that allowed or denied decided the call long before anything
      asked), with the policy's roots/path-params/command-params/checks, and
      answers in `&on-ask`-answer shape so a callback can return it verbatim:
      `{ action => 'allow-once', rule, reason }`, `{ action => 'deny-once',
      rule, reason, message }`, or an undefined Hash when no grant decides.
      Never throws and takes no lock of its own.
    - Per-agent elicitation routing. MCP::Client takes one `on-elicit` hook, so
      a fleet sharing a client had every server question answered by whichever
      policy was wired at construction. A policy built with `:claim-elicits`
      now answers the elicitations its OWN forwarded calls provoke, through its
      own `&on-ask` and its own ask-lock, whichever policy's `elicit-hook` the
      host wired. The claim rides a dynamic variable down the forwarding stack,
      which works because elicitation is fulfilled on the thread that made the
      call. Three things fall back to the wired policy, exactly as before: a
      call from a policy that did not claim, a claimant with no `&on-ask` (a
      headless child does not get to decline on behalf of somebody else's
      human), and a provider that fulfils elicitations on another thread. When
      policies are stacked the outermost claim wins, and the request hash is
      unannotated — the callback answering it is already the claimant's own.
    - New rule narrower `host`, for web tools: a rule may now be scoped to a
      website, as an exact host (`docs.raku.org`) or a subdomain glob of the one
      shape `*.` plus a host (`*.raku.org`, which covers `docs.raku.org` and
      `a.b.raku.org` but neither `raku.org` itself nor `evilraku.org` — the
      match is on the `.` boundary, case-blind). Nothing else glob-ish is
      accepted, because a pattern that can never fire is the one mistake a
      permission file must not make quietly. Without this, one "always allow"
      on a documentation page was consent to the whole web for the session:
      `suggest()` now offers `{ tool, host }` for a call that named a readable
      site, so that is what "always" remembers.
      The host comes from the call's `url` argument (deliberately NOT one of the
      location arguments: a URL is never read as a filesystem path) through a
      small strict parse — absolute http/https only, authority up to the first
      `/`, `?` or `#`, IPv6 brackets and ports dropped, a trailing root dot
      dropped so `evil.com.` cannot walk past a rule about `evil.com`. Anything
      else, userinfo included (`https://user@evil.com#@good.com` is a shape two
      readers disagree about), is unreadable rather than guessed at.
      Fail-closed exactly as `under` is: an ALLOW narrowed by host fires only on
      a URL it read and recognised, while a DENY or ASK fires on that or on
      anything it could not read. `host` ANDs with the other narrowers, sharpens
      which rule a prompt is told about, and — like every other narrower — keeps
      its tool in the catalogue that a blanket deny would hide.
    - New MCP::Client::UnknownKeys: a non-mutating auditor that compares each
      call's argument keys against the properties the tool's own declaration
      published, and calls `on-warn` once per (tool, undeclared key). It exists
      because a call can arrive carrying keys the tool never declared — a
      truncated generation that a constrained decoder closed into VALID JSON, a
      provider that re-serialised the arguments through a lossy shape, a model
      inventing a plausible parameter — and a server that ignores the extras
      does four fifths of the job with nobody the wiser. It is a smoke alarm,
      not a valve: it never refuses, strips, rewrites, delays or fails a call,
      forwards the caller's own call objects untouched, returns the inner
      provider's results verbatim (an inner exception throws through with no
      shape added), and publishes the catalogue exactly as it came. Rejection
      belongs where the argument list is known exactly and the cost of acting
      on a mangled call is unbounded — a client-side refusal of somebody else's
      schema would break working setups, MCP servers being free to accept
      arguments they do not advertise.
      It audits a call only when the tool was in the most recent
      `tools-for-llm`, that declaration carried a non-empty `properties`
      object, and the arguments read as a JSON object (an object, or the JSON
      string a function call travels as); everything else — unparseable, not an
      object, the empty string, a tool never listed — is silent, because it is
      the provider's to report. `reason` is always tolerated, declared or not,
      so a stack with the reason layer switched on produces no false positives
      (the name is a literal rather than an import: the two layers are
      independent, and a test pins them together).
      Each (tool, key) pair is said once per instance — a model with a habit
      would otherwise bury the first warning under one per call — while a NEW
      key on the same tool is a new fact and is reported. The `on-warn` hash
      carries `tool`, the `unknown-keys` being reported now, every
      `declared-keys`, and the call `id`; it defaults to a one-line `note`,
      is shielded (a callback that throws cannot change what a tool call does),
      and is validated at construction for arity, since a callback that cannot
      be called would otherwise be a layer that silently never warns.
      Its seat is `Policy( UnknownKeys( Reasons( … ) ) )`: under the policy so
      the arguments are the model's own, over the reason layer so the cached
      declarations are the augmented ones the model was actually shown.

0.3.0  2026-08-10T20:02:51+01:00
    - execute-tool-calls is now eager: it dispatches the calls itself rather
      than deferring them to the caller's reification of the returned Seq,
      which could otherwise run a tool on the consumer's own thread instead
      of MCP::Client's.
    - Progress support: call-tool takes :progress-token, and when
      &.on-progress is wired an automatic per-call token is generated when
      the caller does not supply one. Inbound notifications/progress
      notifications are routed straight to the hook.
    - MCP::Client::Policy gains &.on-grant, fired the moment a grant is
      remembered rather than left for a caller to poll for.
    - Dependency pins corrected: MCP::Server ver<0.5.0+> (was 0.4.0+) and
      JSON::Fast ver<0.19+> (was exactly 0.19).

0.2.0  2026-08-09T19:47:09+01:00
    - Initial release: a client for talking to third-party MCP servers over
      stdio or Streamable HTTP, speaking both the 2025-11-25 ("legacy") and
      2026-07-28 ("modern") protocol eras.
    - MCP::Client::Exceptions: the typed failure vocabulary (timeouts, a dead
      server process, protocol errors, version mismatches, cancellation, a
      spent multi-round-trip budget, a closed transport, spawn failures).
    - MCP::Client::Protocol: the client half of the wire format that
      MCP::Server::Protocol does not cover - build-request (which stamps the
      2026-07-28 _meta block for modern-era requests and leaves legacy ones
      alone), parse-inbound (which, unlike the server's parse-message, accepts
      id-only responses), normalize-result, decline-input-response and
      is-modern-error.
    - MCP::Client::Correlator: the pending-request table for a shared pipe,
      with injectable clock and timer for deterministic timeout tests.
    - MCP::Client::SSE: a standalone incremental Server-Sent Events parser that
      survives chunk boundaries falling anywhere, including mid-UTF-8.
    - MCP::Client::Cache: a ttlMs/cacheScope cache for the four cacheable
      modern-era results; legacy results and ttlMs=0 results are never stored.
    - MCP::Client::Transport: the role every transport does - request, notify,
      close, alive - plus the contract around it (the client owns the JSON-RPC
      ids, the promise carries the normalised result, JSON-RPC errors break it
      with a typed exception, notifications never touch it).
    - MCP::Client: the client itself. Probes the server's protocol era once and
      caches the verdict, falls back to the 2025-11-25 initialize handshake for
      servers that are not modern, runs the 2026-07-28 multi round-trip loop on
      tools/call, resources/read and prompts/get (declaring exactly the client
      capabilities its hooks can serve), caches what the server says may be
      cached, and offers every call blocking or as a Promise.
    - MCP::Client::Transport::Stdio: spawns the server as a child process (no
      shell, ever) and speaks newline-framed JSON-RPC over its pipes. Failure
      is a first-class citizen: a dying child fails every pending request with
      its exit code and a stderr tail, stray stdout lines are logged and
      dropped, and close escalates from EOF to SIGKILL through a grace period.
    - MCP::Client::Transport::HTTP: the 2026-07-28 Streamable HTTP transport.
      Stamps the required MCP-Protocol-Version / Mcp-Method / Mcp-Name headers,
      implements x-mcp-header parameter mirroring (invalid annotations exclude
      the tool, as the spec requires), reads both JSON and per-request SSE
      responses, and decodes bodies as UTF-8 itself rather than trusting
      charset guessing.
    - MCP::Client::Registry: aggregates any number of tool providers - local
      MCP::Server instances, MCP::Client connections, or other registries -
      behind prefixed tool names, routing calls back to their owners and
      answering unroutable ones as in-band tool errors.
    - The LLM bridge: tools-for-llm and execute-tool-calls on MCP::Client,
      shape-identical to MCP::Server's, so LLM::Chat::ToolLoop treats a remote
      server and an in-process toolkit interchangeably. Declarations come out
      sorted by tool name on both sides, and an empty or whitespace-only
      arguments string - what models send for a tool that takes none - is read
      as "no arguments" rather than answered with a parse error. Both match
      MCP::Server's own bridge, which changed to agree.
    - connect-stdio / connect-http accept the transport's own options
      (on-stderr, kill-grace, stderr-lines, inherit-env; http-version,
      persistent, connect-timeout) alongside client options, and a shared
      on-warn carries both client and transport diagnostics.
    - docs/Readme.rakudoc: the full guide - the era machine and its probe,
      connecting over either transport, the result shapes and their caching,
      the multi round-trip hooks and what declining means, the LLM bridge and
      the registry, an exception table, and the limitations.
    - t/15-mrtr-live.rakutest: the multi round-trip loop against a real
      MCP::Server that really does elicit (MCP::Server 0.4.0 implements the
      server half), through the in-process transport. Test-only addition - no
      library code changed - and the cross-distribution counterpart to
      t/06-mrtr.rakutest, which pins the same contract against a scripted wire.
      Run it with: prove6 -Ilib -I../MCP-Server/lib t/
    - examples/toolloop.raku: a runnable end-to-end tool loop over a registry
      holding both an in-process MCP::Server toolkit and a spawned MCP server,
      driven by a scripted LLM::Chat backend (LLM::Chat is not a dependency;
      run the example with -I../LLM-Chat/lib).
    - MCP::Client::Policy (0.2.0): Claude Code-style per-directory permissions
      at the provider seam. A policy wraps any provider - a client, a registry,
      another policy - and satisfies the same duck type, so it stacks anywhere
      one goes. Reads pass, anything that changes the world stops at an on-ask
      callback, and "always allow" leaves a session grant behind that is plain
      data the caller can persist and reload. Denials are is_error results a
      model can read, never exceptions; execute-tool-calls keeps the
      never-throws promise exactly as MCP::Client and the registry do, batches
      the allowed calls into one forward, and hands the provider the caller's
      own untouched call hashes. tools-for-llm leaves out the tools a bare deny
      rule refuses. Headless (no callback) every ask is a refusal that says so.
    - MCP::Client::Policy::Rules: the engine, pure data in and out. Rules are
      { tool (exact or trailing-'*' glob), decision (allow/deny/ask), under? };
      every matching rule is evaluated and the strongest decision wins
      (deny > ask > allow, so a serialised rule set merges and re-orders
      safely), and a call no rule matched is asked about rather than allowed.
      Containment is purely lexical and segment-wise - never IO, never a string
      prefix (/tmp/root2 is not inside /tmp/root) - because the paths belong to
      the server's filesystem and symlink truth is the server pack's sandbox to
      enforce. It is tri-state: '..', a null byte, a backslash, an absolute path
      with no root to measure from, a missing or non-string argument and
      arguments that will not parse are all 'unknown', which fails closed both
      ways - an allow rule needs every location 'yes', a deny or ask rule fires
      on any 'yes' or 'unknown'. roots (tool-name prefix to directory, longest
      prefix wins) absolutise relative arguments; path-params overrides the
      path/from/to convention per tool.
    - Policy.elicit-hook: one callback seam for two kinds of question. A
      permission question carries the tool, the parsed arguments, a copy of the
      call, the rule, the locations and a suggested "always" rule; a
      server-elicit carries the server's own ElicitRequest. Both are asked under
      one lock, because there is one human. With nobody to ask - or a callback
      that throws, or answers with nonsense - the server is declined and the
      call refused, never hung. Wire MCP::Client's on-elicit to it only when
      .interactive: wiring it is what declares the elicitation capability.
    - Policy.default-rules: allow the FileSystem pack's read-only tools
      (fs_read/fs_list/fs_glob/fs_stat/fs_grep) and user_ask - asking the human
      is inherently consented to, since the elicitation UI is where they answer
      or decline. Deliberately no '* ask' catch-all: the engine already asks
      about anything unmatched, and a catch-all would suppress session grants.
    - t/14-policy.rakutest: twelve groups, including the stacked
      policy-over-registry-over-policy case (and the footgun it comes with: a
      rule written in the wrong layer's vocabulary matches nothing), the
      elicit-hook end to end against a real eliciting MCP::Server behind the
      in-process transport, and a parity guard that runs t/13's argument shapes
      through an allow-everything policy to pin the policy's argument parser to
      the bridge's. Run it with: prove6 -Ilib -I../MCP-Server/lib t/