Rand Stats

MCP::Client

zef:apogee
Revision history for MCP::Client

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/