Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Entries here are kept short; see the corresponding
[GitHub Release](https://github.com/DunkelCloud/ToolMesh/releases)
for the full narrative and details.

## [Unreleased]

## [0.6.0] - 2026-11-10

**Behavior change:** this release refuses several things 0.5.0 accepted.
REST backends whose DADL reads credentials or reaches hosts outside their
`backends.yaml` entry are refused at load until the entry allows them (see
the first Security entry), and DADLs with credential names other than
letters, digits and `_` or with `credential:` parameter defaults are refused
at parse. Among the other changes: the OAuth login page requires a
confirmation, `X-Forwarded-For` counts only from `TOOLMESH_TRUSTED_PROXIES`,
failed API keys are throttled per client address, DADL arguments of the
wrong type and MCP arguments outside the tool's schema are refused with
`invalid_input`, statuses outside a DADL's `retry_on` are no longer
retried, and backends with an underscore in their name keep it in the
`backend` label of audit, telemetry and metrics.

### Security

- **Upgrade note:** REST backends are bound to their `backends.yaml` entry.
  A DADL reads only the credentials of its entry's namespace
  `CREDENTIAL_<ENTRY>_*` (a name belongs to the configured entry with the
  longest matching namespace, so `github` does not read the credentials of
  `github-enterprise`), plus the entry's `env:` and new `credentials:` list.
  It sends credentials, token requests and session logins only to its
  `base_url` host and port, the other hosts of that host's registrable
  domain on the default port, and the entry's new `allowed_hosts:` list
  (`host`, `*.domain`, optionally `:port`); with an https `base_url`, over
  https only. A backend whose DADL reaches further is refused at load, with
  the exact lines to add in the log; the other backends keep running. REST
  entries need a `name`. DADLs with credential names other than letters,
  digits and `_`, or with a parameter default `credential:<name>`, are
  refused at parse. After upgrading, check the log for `backend refused`.
- Session cookies of a backend follow its host binding: a redirect to
  another port or scheme of the base host no longer carries them. A
  warning at load names entries whose DADL sends credentials over plain
  http.
- `env:` keys of a backend are read in upper case by both the credential
  binding and the remapping; before, a key in another case widened what the
  backend could read without remapping the read.
- With `allow_private_url`, private addresses stay reachable only for the
  destinations the configuration names (`base_url`, `token_url`, absolute
  tool paths), not for redirect targets.
- A redirect chain that left the origin keeps credentials stripped also on
  a hop back; tool paths with a placeholder in the host are refused.
- The login confirmation is never shown pre-ticked; revocation also ends the
  pair of an expired access token; a password change ends the user's OAuth
  sessions; redirect URIs with a fragment, without authority or with a
  non-ASCII host are refused.
- DADL arguments reach the gate as the API receives them: defaults filled
  in, numeric strings as numbers, only plain decimal strings accepted.

- Composite `api.*` child calls keep their authorization and pre-execution
  gate after a reload of `backends.yaml`; a REST adapter without that guard
  refuses child calls.
- Backend redirects to another origin drop injected credentials, custom
  headers and the `Referer`, refuse to re-send a request body and are never
  followed from `https` to `http`.
- OAuth2 token and session login requests follow no redirects and use the
  backend's SSRF-safe transport; the session login URL must stay on the
  `base_url` host.
- Transport errors name only scheme and host, so a query-injected API key no
  longer reaches the audit log or tool results.
- Path parameter values `.` and `..` are refused and joined URLs must keep
  the scheme, host and path prefix of `base_url`; a colon in the first path
  segment is no longer read as a URL scheme.
- The OAuth login page names the redirect host and the self-declared client
  name and requires a confirmation; `TOOLMESH_OAUTH_REDIRECT_HOSTS`
  optionally restricts redirect hosts.
- Token refresh and access tokens are resolved against the current
  `users.yaml` or single-password owner as loaded at startup; after a
  restart, removed or disabled users, and sessions of a changed password, lose
  access. `POST /revoke` (RFC 7009) revokes tokens. **Upgrade note:** tokens
  issued before this release carry no credential fingerprint; they are
  accepted and bound at their next refresh, so they survive a password
  change made after the upgrade. Revoke suspicious tokens, or clear the
  stored tokens while ToolMesh is stopped (see the configuration reference,
  "Tokens from before the upgrade", for the file store and Redis).
- `X-Forwarded-For` is honored only from the proxy addresses in
  `TOOLMESH_TRUSTED_PROXIES` (default loopback only); a header from any other
  address is ignored with a warning at most once an hour.
- SSRF checks treat carrier-grade NAT, benchmarking, documentation,
  multicast, reserved, NAT64 and 6to4 addresses as private.
- DADL parameters are checked against their declared types before the
  pre-execution gate.
- Only the owner can delete a blob.
- `/register` is rate-limited without Redis, and failed API keys on `/mcp`
  are throttled per client address (`TOOLMESH_APIKEY_MAX_FAILURES_PER_IP`,
  default 50); a slot is reserved before the key is checked.
- `TOOLMESH_CODE_MAX_CONCURRENT` (default 16) bounds parallel `execute_code`
  runs; one user may hold at most half of them.
- The `TOOLMESH_ISSUER` placeholder is `https://toolmesh.invalid/`.
- Behind a trusted proxy, `X-Forwarded-For` entries with a port
  (`203.0.113.7:51234`, `[2001:db8::7]:51234`) or in brackets are read as
  the client address instead of being skipped; an entry that is no address
  (`unknown`, empty) ends the walk at the last trusted proxy, so a client
  cannot choose the address its limits count it under.
- A number or boolean given for a DADL parameter declared `string` reaches
  the pre-execution gate as the exact text the API receives, also in a JSON
  body (`"42"`, not `42`); arrays, objects and other values without a text
  form are refused for such parameters with `invalid_input`.
- `file_url_allowed_hosts` applies to every redirect of a `file_url` fetch,
  not only to the URL the caller passes. If an allowed source redirects to
  a CDN or storage host, add that host to the list.
- A refresh token is refused once its seven days are over; the Redis key
  and the restart warm-up no longer extend its lifetime.
- SSRF checks refuse IPv6 addresses with a zone (`[::ffff:127.0.0.1%25x]`),
  which slipped past the private-address check and reached loopback, private
  or metadata addresses, and fail closed on DNS answers that do not parse.
- A backend `base_url` on the unspecified address (`http://0.0.0.0/`,
  `http://[::]/`) is now refused with `allow_private_url: false`, like
  loopback: a connection to it reaches a service on the ToolMesh host.
- The bearer token of MCP backends over HTTP (`api_key_env`) is sent only to
  the origin of the configured `url`, no longer to the target of a redirect.
  MCP-over-HTTP requests stay on that origin: a redirect to another origin
  (or from https to http) is refused, and so is a message endpoint on
  another origin announced by a legacy SSE server.

### Added

- Credential store `command` (`CREDENTIAL_STORE=command`). ToolMesh runs
  `TOOLMESH_CREDENTIAL_COMMAND`, for example `sops -d --output-type dotenv`
  on an encrypted file, and keeps the values it prints in memory only: never
  in the process environment, which Docker writes into the container
  configuration on disk, and never in the log. The output is dotenv, or JSON
  with `TOOLMESH_CREDENTIAL_FORMAT=json`: `TOOLMESH_CREDENTIAL_JSON_PATH`
  selects the object that holds the values (`data.data` for
  `vault kv get -format=json` or `bao kv get -format=json`), and nested
  objects below it are flattened. The command runs without a shell; a failed
  first run stops the start. `TOOLMESH_CREDENTIAL_RELOAD_IN` refreshes the
  values on an interval, and a `RELOAD_IN` key in the output sets the next
  run itself, which suits short-lived tokens. A failed
  refresh keeps the previous values and retries with backoff. With this store
  `TOOLMESH_AUTH_PASSWORD`, `TOOLMESH_API_KEY`, `REDIS_URL` and `env:`
  remappings come from the output too, and stdio backends receive only the
  variables their new `credential_env` entry in `backends.yaml` names. Two
  metrics follow the runs: `toolmesh_credential_store_runs_total{result}` and
  `toolmesh_credential_store_last_success_timestamp_seconds`. The embedded
  store behaves as before. See `docs/configuration.md`.

- DADL health checks (spec §4.6) are implemented. A DADL can declare under
  `backend.health` the cheapest call that proves the API is reachable and the
  credential works, by referencing a tool or naming an endpoint. ToolMesh runs
  the check at startup, after a reload and every 15 minutes, so a revoked or
  expired credential is reported before an agent needs the backend. It
  reports failing backends and credentials close to expiry on `/health` as
  `degraded` or `warning`, still with HTTP 200, and offers the check as
  `<backend>_health` with a standardized result (`{ok, http_status?,
  error_code?, latency_ms, checked_at, auth_expires_at?, error?}`, never the
  API response). `health_check` in `backends.yaml` sets the polling interval
  (`interval`, `0` turns polling off), a failure threshold
  (`degrade_after`), the expiry warning window (`warn_before`) and a
  credential expiry the API does not report (`auth_expires_at`). Three gauges
  follow the same state: `toolmesh_backend_health_up`,
  `toolmesh_backend_health_degraded` and
  `toolmesh_backend_auth_expiry_timestamp_seconds`. The feature identifier
  `health` is accepted in `requires.features`. See `docs/configuration.md`.

- Two Prometheus counters show how often `execute_code` scripts finish
  although a tool call in them failed. Such a call does not throw inside the
  script; the script gets the failure as a value and may carry on without
  the data. `toolmesh_codemode_swallowed_errors_total{backend, reason}`
  counts these scripts once per backend and reason
  (`dispatch|coercion|tool_error`), and
  `toolmesh_codemode_scripts_total{outcome}` counts all scripts by how they
  ended (`ok|error|timeout`). Scripts behave as before. See
  `docs/metrics.md`.

- The file broker can be switched off and bounded.
  `TOOLMESH_FILE_BROKER_ENABLED=false` removes `upload_file`, `/files/upload`
  and `/blobs/` (both answer `404`); tools with a binary response then return
  an error instead of a download URL. `TOOLMESH_FILE_BROKER_MAX_TOTAL_BYTES`
  and `TOOLMESH_FILE_BROKER_MAX_BYTES_PER_USER` bound the bytes it holds, in
  total and per user; uploads and binary responses count against the user who
  made the call, and a file that does not fit is refused with a clear error
  (`507` on `/files/upload`). Without these variables nothing changes: the
  broker is on and unbounded, which the startup security-posture summary now
  lists as a relaxed control. Blob files left by a previous run are removed at
  startup; they could no longer be served. See `docs/configuration.md`.

- The `execute_code` sandbox provides `atob`, `btoa`, `TextEncoder` and
  `TextDecoder` (UTF-8). A base64 payload, such as the content of a GitHub
  file read, decodes with the usual idiom instead of a hand-written alphabet
  table. Composites and unit backends get the same four globals.

- A failed call's entry in the `execute_code` result listing carries the
  arguments the script sent as `params`, with the values of secret-looking
  names (`password`, `token`, `api_key`, …) redacted, so a script with
  several calls shows which one failed with what. Successful entries are
  unchanged.

### Changed

- A call to an MCP backend is checked against the tool's `inputSchema`
  before it leaves ToolMesh. An undeclared argument or a missing required
  one is rejected with the same `invalid_input` error a REST tool raises:
  it names the argument, the closest declared name where there is one
  (`topK` for `top_k`) and the declared parameter set. The backend's own
  error used to come back raw and usually without the accepted names.
  **Behavior change:** an argument an MCP backend used to drop silently is
  now an error. A schema that opens its property set on purpose
  (`additionalProperties` true or a schema, `patternProperties`) keeps
  accepting undeclared names, and a schema that declares no properties is
  not checked.

- OAuth clients registered at `/register` expire. A client that never obtains
  a token is forgotten after a day (`TOOLMESH_DCR_CLIENT_TTL`, default
  `86400`), and every token issued to a client keeps it for 90 days from then
  on (`TOOLMESH_DCR_CLIENT_IDLE_TTL`, default `7776000`); `0` keeps clients
  forever as before. Clients registered with earlier versions get an expiry
  with their next token. When the registration rate limit cannot be checked
  because Redis is unreachable, the registration still goes through and is
  now logged at `WARN` as a fail-open; a start without Redis reports that
  registrations are not rate-limited. See `docs/configuration.md`.

- The approval page `/approve/<id>` leads with what an approver decides on:
  whom the grant is for, the risk of what the kit allows, how many actions
  would be new to the caller, the duration and the fingerprint; the
  technical detail is one click away. It comes in English, German, French,
  Spanish and Japanese (`?lang=<code>`, or the browser's language); the
  statement that is signed stays English. The texts are in
  `internal/grants/web/i18n/`.
- An approval may grant a kit for longer than requested, up to the kit's
  `ttl_max` (24 hours without one), on the approval page and on the approver
  API (`POST /grants/<id>/approve` with `{"ttl": ...}`). Until now it could
  only shorten the duration. The page offers durations from 5 minutes to 24
  hours up to that maximum and one typed in whole minutes or hours; the
  request view (`GET /approve/<id>/request`) carries `ttl_max`, and
  `ttl_choices` runs from the shortest to the longest.
- A REST call answered with a status that the DADL lists in neither
  `errors.retry_on` nor `errors.terminal` is no longer retried, as the DADL
  spec (§8) requires. Until now every such `5xx`, and a `3xx` that came
  through, was retried anyway, so leaving a `5xx` out of `retry_on` had no
  effect. A DADL that wants a `5xx` such as `500` retried lists it in
  `retry_on`; a tool with its own `errors` block lists its own `retry_on`,
  because that block replaces the backend defaults.

### Fixed

- The MCP registry entry (`server.json`) declares the `Authorization` header
  of the remote endpoint, optional and secret, so that registry listings no
  longer present ToolMesh as needing no authentication. ToolMesh rejects every
  request without an API key or an OAuth access token.
- A `REDIS_URL` that does not parse no longer puts the URL, and with it a
  password, into the log; the warning names only what is wrong with it.
- A new installation sends its first telemetry report one to five minutes
  after it starts instead of after 24 hours, so installations that restart
  more often than once a day, for example after updates or on laptops,
  report at all. A start sends a report only when none has succeeded yet or
  the last one, kept in `toolmesh.json`, is older than 24 hours; restarts
  add no reports.
- Tool calls to a backend whose name contains an underscore, such as
  `web_search`, are attributed to that backend in the audit trail, in
  telemetry and in the metrics `toolmesh_tool_calls_total` and
  `toolmesh_tool_call_duration_seconds`. The backend was taken from the
  tool name up to the first underscore, so `web_search_query` was recorded
  as backend `web`, tool `search_query`, and telemetry counted the call for
  a backend `web` or not at all. Dashboards and alerts that select such a
  backend by the shortened `backend` label need its full name now. Tools no
  backend claims are counted under backend `unknown` with their full name.
- The preflight of `tell_me_my_rights` (`tools: [...]`) reports the built-in
  tools ToolMesh lists, such as `execute_code`, `discover_tools` and
  `upload_file`, as `allowed`. It used to call them `denied` because no
  backend has them, so an agent that checked its rights first, as the tool
  recommends, gave up on tools it could call. A name that is not a tool the
  caller can see or get a kit for now has the reason `unknown tool: ...`
  instead of the wording of a denial; a tool the caller may not see still
  reads the same as a name no tool has.
- The warnings `response transformation error`, `pagination error, returning
  partial results` and `file broker upload failed, falling back to disk`
  name the backend and the tool they concern. Before, a failing `transform`
  of a DADL could only be traced back to its tool through the surrounding
  log lines.

## [0.5.0] - 2026-10-23

**Behavior change:** authorization is on by default. ToolMesh runs OpenFGA
inside its own process (`TOOLMESH_AUTHZ_MODE=embedded`, the new default),
keeps its data in `authz.db` in the data directory and checks every tool call;
no OpenFGA service is needed. On a fresh installation the owner
(`TOOLMESH_AUTH_USER`) and the role `admin` may call every tool (preset
`single-user`); when `users.yaml` or `apikeys.yaml` exists, plan `pro` may
call every tool and plan `free` every tool classified `read` as well (preset
`team`). Any other caller gets nothing until a grant names it, and tool
listings show each caller only what it may call. With the defaults of the
single password or API key (user `owner`, plan `pro`, role `admin`) nothing
changes. An installation that ran without authorization is recognized on the
first start of this version while `TOOLMESH_AUTHZ_MODE` is not set, by
`OPENFGA_MODE=bypass` or by files an earlier run left in the data directory,
and keeps every tool open to every authenticated caller (preset `open`),
reported at every start until a preset or grants are chosen. To begin with
the defaults above instead, set `TOOLMESH_AUTHZ_MODE=embedded` or
`TOOLMESH_AUTHZ_PRESET` before that first start. `OPENFGA_MODE=restrict`
keeps working as `TOOLMESH_AUTHZ_MODE=external`. The data directory must be
writable; it is in the shipped `docker-compose.yml`, which no longer sets
`OPENFGA_MODE`.

**Behavior change:** a server without any credential configured (no
`TOOLMESH_AUTH_PASSWORD`, `TOOLMESH_API_KEY`, `users.yaml` or `apikeys.yaml`)
rejects every request to `/mcp`, `/files/upload` and `DELETE /blobs/…` with
`401`, as the documentation said. Up to 0.4.1 it served them
anonymously, `tools/list` and `execute_code` included. An installation that
relied on the open endpoint has to set `TOOLMESH_API_KEY` or
`TOOLMESH_AUTH_PASSWORD`.

**Behavior change:** the password login at `/authorize` answers differently in
four cases. A wrong password re-renders the login form with `401` instead of
`200`. A login refused by the new failed-login limits gets `429` with
`Retry-After`. A request naming an unregistered client or redirect URI is
rejected with `400` before the password is looked at; it used to get the login
form back when the password was wrong. And where no password is configured
(neither `TOOLMESH_AUTH_PASSWORD` nor `users.yaml`), `/authorize` answers `403`
(`access_denied`) instead of showing a login form. A reverse proxy in front of
ToolMesh should pass `401`, `403` and `429` bodies on `/authorize` through
unchanged. In
`toolmesh_logins_total`, the `failure` series for `oauth_bearer` and `api_key`
never moved before and now count rejected bearer credentials — expect a small
baseline from clients presenting an access token that has just expired — and
there is a new `method="password"`.

**Behavior change:** `/mcp` answers a request without a valid credential with
`401` and a `WWW-Authenticate: Bearer` challenge instead of `200`; the
JSON-RPC error in the body is unchanged. MCP clients (Claude Code, Claude
Desktop and claude.ai connectors, `mcp-remote`, the MCP TypeScript SDK) act
on that status to start the OAuth flow or to refresh an expired token; none
of them relies on the `200`. A reverse proxy in front of ToolMesh must pass
the `401` and the `WWW-Authenticate` header on `/mcp` through unchanged, and
monitoring that treats every `4xx` on `/mcp` as an error will now see the
requests that used to hide behind a `200`. A request whose credential could
not be checked at that moment is answered `503` with `Retry-After` on `/mcp`,
`/authorize`, `/files/upload` and `DELETE /blobs/…`. `toolmesh_logins_total`
has a new `method="anonymous"`; a failure ratio computed over all methods
now includes requests that carry no credential at all (see `docs/metrics.md`
for a query that leaves them out).

**Behavior change:** telemetry reports carry a site ID, a persistent
identifier of the installation. It is a random UUIDv7, which includes the time
it was created; ToolMesh creates it on the first start with telemetry enabled
and keeps it as `site_id` in `toolmesh.json` in the data directory. Nothing in
it is derived from the host, the configuration or the operator, but it links
the reports of one installation over time: the statistics now count
installations rather than source IP addresses, and they are no longer
anonymous in the strict sense. With `DO_NOT_SEND_ANONYMOUS_STATISTICS=yes` no
site ID is created and nothing is sent; an ID created earlier stays in the
file until it is removed. To discard it, stop ToolMesh and remove `site_id`
from `toolmesh.json`; the next start with telemetry enabled creates a new one.
Without a persistent data directory every start reports a new site ID. See
the [configuration reference](https://www.toolmesh.io/en/configuration/).

### Added

- Embedded authorization: OpenFGA as a library on a SQLite datastore, with
  store and model created on first start and the model written again when a
  new version changes it. Grants come from a preset (`TOOLMESH_AUTHZ_PRESET`:
  `single-user`, `team`, `open`) or from `config/authz.yaml`
  (`TOOLMESH_AUTHZ_CONFIG`, example in `config/authz.yaml.example`), which is
  applied again within seconds of every change. A grant names a user, role,
  plan, company or everyone and allows all tools, the tools of a backend, the
  tools of a DADL access class, or single tools. Denies in the same file
  block any of these for a subject and win over every grant. Roles, plans
  and companies stay where they are configured today (`users.yaml`,
  `apikeys.yaml`, `TOOLMESH_AUTH_*`). See `docs/configuration.md`,
  "Authorization".
- `TOOLMESH_AUTHZ_MODE=disabled` as the only way to switch authorization off.
  It is reported in the security-posture summary and recorded in the audit
  trail (status `authz_disabled`).
- Kits in `config/authz.yaml` (`kits:`): named bundles of tools a caller is
  granted as a whole and for a limited time, with a description, an optional
  warning, scopes such as `graylog:read`, single tools and a default and
  maximum duration. Discovery lists the tools a kit covers, marked
  `grant_required`, and a denied call names the kits that would allow it
  and with which it also passes the pre-execution gate (audit metadata
  `remedy_kits`). See `docs/configuration.md`, "Kits".
- Granting kits: a caller asks with the meta-tool `request_grant` (kit,
  reason, optional duration) and gets a short request id. Approvers listed in
  `config/authz.yaml` (`approvers:`, name and SHA-256 of a key of their own)
  approve, shorten, deny or revoke on the approver API `/grants`; MCP
  credentials do not work there. A grant holds for the requesting user as the
  caller it connected as, until it expires; every check passes the granted
  kits to the authorization engine. Requests and grants live in Redis when it
  is connected, otherwise in process memory. Every step is audited, and
  `tell_me_my_rights` lists the caller's grants and pending requests. An
  approver whose key is also an MCP credential (`TOOLMESH_API_KEY`,
  `TOOLMESH_AUTH_PASSWORD`, a `key_sha256` in `apikeys.yaml`) is refused, and
  a kit without `ttl_max` may not have a `ttl_default` above 24 hours. See
  `docs/configuration.md`, "Granting kits".
- A kit a caller holds replaces the pre-execution gate for the tools it
  covers (a kit granted in part only through its granted entries): ToolMesh
  skips every pre-execution policy except those that declare
  `// toolmesh: enforce-with-kit` in their header comment, so the approver's
  decision stands. Denies in `config/authz.yaml` still win over a kit, and
  the post-execution gate runs in full. The start log shows
  `pre_gate_with_kit=enforced` or `skipped` for each loaded policy; a call in
  which a kit replaced the gate is logged (`kit replaces the pre-execution
  gate`) and its audit entry carries `kit_applied` and `gate_skipped`. A
  call the gate refuses is answered, as an authorization denial is, with the
  kits that would lift the refusal (`remedy_kits`); where kits exist and none
  lifts it, the answer says so. Policies see `ctx.kits` and
  `ctx.kitCovered`. Mark parameter filters and other conditions that must
  hold for kit holders with the directive. See `docs/configuration.md`,
  "Gate Policies".
- Approvers can be limited to kits: `kits: [...]` on an approver in
  `config/authz.yaml` names the kits it may approve (every kit without it;
  the names must be kits the file defines). An approval of another kit is
  refused, with `403` on the approver API, and audited; the approval page
  offers only approvers who may approve the request's kit. Denying and
  revoking stay open to every approver.
- Approval page `/approve/<id>`: an approver with a signing address
  (`approvers:` → `address`, a P2WPKH `bc1q…` address) approves a request by
  signing a statement ToolMesh writes for it (BIP-322), in the page with the
  private key, which never leaves the browser, or in a wallet. The page shows
  the kit's entries grouped by what they allow, with how many tools each
  covers and how many are new to the caller (a tool a policy refuses counts
  as new for the kit that lifts the refusal), and the approver can untick
  entries and shorten the duration; the grant then covers exactly the signed
  entries. `request_grant` answers with the link (when `TOOLMESH_ISSUER` is
  set) and a fingerprint of the request; messengers get a link preview without
  the requester's text. The page is part of the binary, with a strict Content
  Security Policy, and offers making a signing key at `/approve/`. Approvals
  record the address, signature and statement in the audit trail; refused
  signatures are limited per request and source address (`429` with
  `Retry-After`), in counters apart from the login limits. The
  approver API grants part of a kit too (`grants`, `tools` in the approve
  body). See `docs/configuration.md`, "Approval page".
- `execute_code` scripts can call `toolmesh.request_grant({...})` and
  `toolmesh.tell_me_my_rights({...})`. A client that keeps the tool list it
  got before kits were defined reaches `request_grant` there.
- Meta-tool `tell_me_my_rights`: what the caller may do, per backend, for one
  backend, or as a preflight over the tools a task needs, with the smallest
  set of kits that covers what is missing. The verdicts take the
  pre-execution gate policies into account: a tool a policy refuses the
  caller is `escalatable` through the kits that would lift the refusal, or
  `denied`, with a reason that names the policy. Policies see
  `ctx.preflight` in such a run, which has no parameters and is not recorded
  for rate limiting; discovery does not ask the policies. Information only;
  every call is audited. See `docs/configuration.md`, "Asking what a caller
  may do".
- Example policy `config/policies/examples/prod-access-by-caller-class.js`:
  a pre-execution gate that lets a call through only when the caller's class
  (`caller-classes.yaml`) may use the tool's DADL `access` class, and denies
  unknown classes and unclassified tools with a reason naming tool and class.

### Changed

- The `log` audit store (`AUDIT_STORE=log`, the default) writes an entry's
  metadata as a `metadata` object in the audit line, as the `sqlite` store
  already stored it. For kit grants it holds the approver, the grant id and
  the kit, and for a request the reason and the duration asked for; calls
  record the kits they held (`held_kits`), denials the kits that would have
  allowed them (`remedy_kits`). A log pipeline that maps the audit fields
  sees this one new field.
- `discover_tools` and `toolmesh.describe()` show the DADL `access` field for
  tools of named backends. The composite backend dropped it on the listing
  path, so only the gate and the audit trail saw it.
- Shipped example policies: `default.js`, `github-branch-protection.js` and
  `shelly_write_protection.js` declare `// toolmesh: enforce-with-kit`, and
  `caller_block.js` runs in the pre-execution phase only, so that a kit
  holder's call is not refused after the backend ran.
- Building ToolMesh requires Go 1.26 (was 1.25, which no longer receives
  security fixes); the Docker image is built with `golang:1.26-alpine`. The
  embedded OpenFGA is v1.22.0.

### Deprecated

- `OPENFGA_MODE`. Read only while `TOOLMESH_AUTHZ_MODE` is not set: `restrict`
  selects `external`, `bypass` selects `embedded`. A warning is logged while
  it is set.

### Fixed

- Telemetry reports of installations with more than 50 distinct DADL files
  are accepted now. The collector takes at most 50 entries per request and
  rejected such a report as a whole, so these installations sent no
  telemetry. ToolMesh now splits the report into requests of at most 50
  entries. A rejected report kept its counters, so the calls counted in the
  meantime go out with the next report.

### Security

- Failed password logins are now limited: per account from one client address
  (default 5), per account across all addresses (20), and per client address
  across all accounts (50), each within 15 minutes
  (`TOOLMESH_LOGIN_MAX_FAILURES_PER_USER_IP`, `…_PER_USER`, `…_PER_IP`,
  `TOOLMESH_LOGIN_FAILURE_WINDOW`). Until now the login could be tried without
  bound. Counters are kept in Redis when it is connected and in process memory
  otherwise; a Redis error falls back to process memory with a warning instead
  of lifting the limits. A limit set to `0` is reported in the startup
  security-posture summary. See `docs/configuration.md`, "Login Throttling".
- The OAuth client and its redirect URI are validated before the password, so
  the password check is no longer reachable without a registered client.
- An unknown username now costs the same bcrypt comparison as a wrong password
  and is locked out the same way, so neither response time nor lockout shows
  which usernames exist.
- Every failed login is logged at `WARN` (`login failed`, with the submitted
  username, the client address and a reason; never the password) and counted
  in `toolmesh_logins_total{method="password",result="failure"}`. A rejected
  bearer credential is logged and counted once, as an `oauth_bearer` or an
  `api_key` failure.
- Password login is refused when no password is configured (neither
  `TOOLMESH_AUTH_PASSWORD` nor `users.yaml`); until now such a deployment
  accepted an empty password. On such a deployment
  authorization codes are not exchanged and access and refresh tokens are not
  honored either, including ones still in the token store from an earlier
  configuration. The single-password comparison no longer depends on the
  length of the configured password.
- A bearer credential is no longer compared with bcrypt against every entry
  of `apikeys.yaml` before anything else is tried. Until now any request with
  an `Authorization: Bearer` header, valid or not, cost one bcrypt comparison
  per configured key, so a few dozen requests per second from anyone were
  enough to keep every core busy, and every request with an access token paid
  the same. API keys are now found through an index over SHA-256 digests, and
  access tokens are looked up before any bcrypt comparison. An entry can name
  its key as `key_sha256`, which is indexed when the file is loaded. Existing
  files with only a bcrypt `key_hash` keep working unchanged: such an entry
  joins the index the first time its key is used after startup, and until
  then it is the only kind a comparison is still spent on. Requests that
  need such a comparison are admitted one at a time, so they use at most one
  core and cannot keep password logins waiting. ToolMesh logs a warning at
  startup while a file has such entries. See `docs/configuration.md`,
  "API Keys".
- The number of bcrypt comparisons running at the same time is bounded for
  the whole process (`TOOLMESH_BCRYPT_MAX_CONCURRENT`, default: half of the
  available CPUs, at least one). The bound covers password logins, including
  the comparison an unknown username costs, and the remaining comparisons
  for API keys. A request that cannot get a comparison in time is answered
  `503` with `Retry-After`; it is not a failed login and does not count
  against the failed-login limits.
- `/mcp` answers `401` with `WWW-Authenticate: Bearer` and the
  `resource_metadata` of the endpoint, as the MCP authorization specification
  requires, and serves that metadata at
  `/.well-known/oauth-protected-resource/mcp`. A rejected bearer credential
  gets `error="invalid_token"`. Origins in `TOOLMESH_CORS_ORIGINS` are
  allowed to read the header. The challenge is built from `TOOLMESH_ISSUER`;
  ToolMesh warns at startup while that is still the placeholder from
  `.env.example`.
- The single `TOOLMESH_API_KEY` is compared in constant time independent of
  its length; the comparison used to return early when the lengths differed.
- Requests to `/mcp` that are turned away are logged at `INFO` (`mcp request
  rejected: unauthorized`, with the client address and whether a credential
  was presented) instead of `DEBUG`, and requests without any credential are
  counted in `toolmesh_logins_total{method="anonymous"}`.
- A failure of the token store is answered `503` instead of being reported
  to the client as an invalid credential, and an API key is accepted without
  the token store being asked.
- A server without any credential configured (no `TOOLMESH_AUTH_PASSWORD`,
  `TOOLMESH_API_KEY`, `users.yaml` or `apikeys.yaml`) rejects every request
  to `/mcp`, `/files/upload` and `DELETE /blobs/…` with `401`, as documented.
  Until now it served them anonymously, `tools/list` and `execute_code`
  included.

## [0.4.1] - 2026-09-24

**Behavior change:** two checks that 0.4.0 did not perform now reject calls it
let through. A tool call whose arguments disagree with the tool's DADL
declaration — an argument the tool does not declare, or a missing required
parameter — is refused with `[invalid_input] HTTP 400` before any request is
built; 0.4.0 dropped the undeclared argument and sent the call anyway. A
request body larger than the tool's declared `max_body_size` is refused; 0.4.0
ignored the key and applied only the global 100 MiB ceiling. A DADL whose
`max_body_size` cannot be parsed now fails the backend at load instead of being
ignored. No configuration change is needed: a call that was doing what its
caller asked is unaffected, and the shipped DADL corpus was checked before
either change landed. Operators running their own DADLs or composites should
expect a call that used to succeed with a silently dropped argument to fail
now; the error names the argument and the declared parameter set.

### Added

- `max_body_size` (DADL spec §6) is now enforced. The key has been in the spec
  and in the canonical JSON Schema since v0.2 and 42 tools across the shipped
  DADL corpus declare one, but the runtime never read it: every load warned
  `unknown key "max_body_size" … ignored` and every upload ran against the
  global ceiling instead. A tool declaring `5MB` was not capped at 5MB, and one
  declaring `100MB` was not granted 100MB either.
  The declared size now bounds the request body on every path that carries
  one — a fetched `file_url`, a `tm-blob://` handle, a local `file` parameter,
  a multipart body (each part as it is read *and* the assembled whole, since
  parts individually under the limit can exceed it together), and plain JSON or
  form-encoded bodies. A source that declares an oversized `Content-Length` is
  refused before the fetch runs; one that under-reports it is cut off mid-stream
  rather than truncated silently.
  Sizes are written as `50MB`, `128 KiB`, `1.5GB`, or a bare byte count, with
  `KB`/`MB`/`GB` read as 1024-based so a declared `100MB` lands exactly on the
  runtime's 100 MiB ceiling instead of 4.8% under it. A value that cannot be
  parsed fails the backend at load rather than being ignored — every available
  fallback is a *wider* cap than the author wrote, so a typo would quietly lift
  the limit it was meant to impose. A value above the runtime ceiling is
  clamped to it and the clamp is logged: a DADL, which may come from a public
  registry, narrows what this process will buffer but never widens it.
  This also closes a gap in the legacy `file` parameter, which buffered a local
  file into the multipart writer with no size check of its own.
- `lint-dadl` is now a gate rather than a composite scanner. It read every
  file's §15.3 findings and threw them away, and it skipped any file without
  composite code entirely — so an invented or misspelled key reached production
  as a startup log line and nothing ever failed over it. Five such keys were
  sitting in a live DADL directory when this landed
  (`next_link_header`, `total`, `current_page`, `total_pages`).
  It now reports parse failures, unimplemented keys, and composite violations
  for every file, and splits the §15.3 class in two using the canonical JSON
  Schema: a key the spec does **not** define is an error — invented, and
  nothing will ever honor it — while a key the spec **does** define is a
  warning, because the file is right and this build is behind. The two look
  identical in a runtime warning and call for opposite fixes; `max_body_size`
  spent months in the second bucket. `-warn` downgrades the whole class for a
  file targeting a newer spec version, and `-schema` overrides the schema path
  (without one, every unimplemented key stays an error — the fail-closed
  reading). Paths may now be directories, so it can be pointed at the DADL
  directory a deployment actually loads:
  `make lint-dadl DADL_DIR=/path/to/deployed/dadl`.
  CI runs it over this repository's own DADL files, which nothing checked
  before — registry CI covers only what is published to the registry, so a
  file living anywhere else had no gate at all.
- A browser that opens the MCP endpoint now gets a page explaining what the
  endpoint is, with the URL to copy into a connector and links to the setup
  docs, instead of the bare `Method not allowed` that reads like a broken
  service. The branch is taken only for `Accept: text/html`: an MCP client
  attempting a server-initiated SSE stream sends `text/event-stream` and still
  receives the 405 it always did, as do `curl` and anything else that sends
  `*/*` or no `Accept` header at all. The site root serves the same page rather
  than `404 page not found`; every other unmatched path still 404s. The page is
  unauthenticated by design — it carries no credentials, only the endpoint URL
  and whether authentication is required.
- `TOOLMESH_ROOT_REDIRECT` points `GET /` at an absolute http(s) URL instead of
  the built-in page, so a deployment whose documentation lives elsewhere — the
  public demo, an internal wiki — can send visitors straight there without that
  URL being compiled into the binary. The redirect is a 302 so it can be
  retargeted or cleared later without waiting out a browser cache, and it is
  bound to `/` alone: `/mcp` keeps serving the page, since a visitor who landed
  there needs the URL in front of them to copy. An invalid value fails startup
  rather than being ignored, which on a site root would look exactly like the
  page working as intended.

### Fixed

- A tool call is now checked against the tool's declared parameters (DADL spec
  §6.1) before the request is built, and rejected when they disagree. Both
  faults it catches used to pass unannounced: request building only ever reads
  the *declared* parameters, so an argument the tool does not declare was
  dropped on the floor, and a missing required one was simply absent from the
  request. The call still went out — meaning something other than what the
  caller wrote — and the reply looked like a genuine answer. A caller that
  guessed `range` for a parameter actually named `time_range` could not
  distinguish the result from an API that held no data for the window, which is
  exactly how one such call cost an afternoon of misdirected incident analysis.
  The rejection names the offending argument, the declared parameter it most
  resembles, and the full declared set with types and locations, so a caller
  that guessed recovers in one round trip instead of guessing again. It carries
  the §8.2 metadata of a 400 (`invalid_input`), the same as a 400 the API
  itself returned, and no backend request is made. Composites are checked on
  the same terms — both the call into the composite and every `api.*` child
  call it makes, the path where the missing-required gap was first noticed.
  A declared `default:` still satisfies `required` on its own, and an explicit
  `null` on an `in: body` parameter remains a value ("clear this field") rather
  than an omission. Tools sourced from upstream MCP servers are unaffected —
  their arguments are forwarded verbatim and validated at the far end.

  *Corrected after release (2026-10-02):* this entry originally said that
  composite JavaScript and Code Mode both branch on `e.code` / `e.http_status`.
  Only composites do. The two sandboxes report a failed call differently, for
  this rejection and for an error the API returned alike. Neither behavior
  changed in 0.4.1.

  In a composite, a failed `api.*` call throws, and the error carries `code`,
  `http_status`, `api_message`, and `provider_code` when the API supplied one:

  ```javascript
  // composite code
  try {
    return await api.search_messages({ range: "1h" }); // declared: time_range
  } catch (e) {
    if (e.code === "invalid_input") return { rejected: e.http_status }; // 400
    throw e;
  }
  ```

  In `execute_code`, a failed `toolmesh.*` call does not throw. The failure is
  the value the call resolves to, so a `catch` never sees it and a script that
  does not inspect that value carries on with it. A failed DADL tool resolves
  to the string `"Error: …"`, a call that could not be dispatched to
  `{ error, tool }`, and one the TypeScript-definition coercer rejected to
  `undefined`. None of them carries the §8.2 fields; those travel in the
  `execute_code` response, where the failed call's entry keeps its full
  `result` with `isError: true` and `metadata.error_code` /
  `metadata.statusCode`:

  ```javascript
  // execute_code
  const r = await toolmesh.logs_search_messages({ range: "1h" });
  if (typeof r === "string" && r.startsWith("Error: ")) {
    return { failed: r }; // 'Error: tool "search_messages": unknown parameter "range" …'
  }
  if (r && r.error) return { failed: r.error }; // dispatch failure
  return r.messages.length;
  ```

  The `Error: ` prefix is what the REST adapter writes, not a contract: an
  authorization denial or a gate rejection resolves to plain text without it.
  What covers every case is the failed call's entry in the response —
  `result.isError`, or `error` for a call that produced no result.
- The input schema advertised for a DADL tool or composite now carries
  `additionalProperties: false`. Advertising an open object while the runtime
  rejects undeclared arguments is the discrepancy that let a caller believe an
  extra argument had been accepted; a client that checks the schema now catches
  the mistake before the call is sent, and one that ignores it behaves as
  before.
- The TypeScript-definition coercer no longer strips an undeclared argument
  with only a server-side log line. It rebuilds the parameter map from the
  declared set, so a stripped argument could never have reached the tool; the
  call now fails with the declared parameter list instead of succeeding
  without the thing that was asked for.
- `docker-compose.yml` now sets `restart: unless-stopped` on `keydb`. It was
  the only service in the stack still on Docker's default policy (`no`), and
  `toolmesh` depends on a healthy KeyDB via `depends_on`, so after a KeyDB
  crash or a Docker daemon restart the whole stack stayed down.

## [0.4.0] - 2026-08-05

DADL v0.2 Core Runtime: the seven runtime features of the finalized
DADL spec v0.2, plus the transport, discovery, and file-handling work
accumulated since 0.3.0.

### Fixed

- A backend's `hint:` (backends.yaml) is now honored for `transport: rest`.
  It was only ever read for MCP backends, so a hint configured on a REST
  backend was silently dropped — it reached neither the catalog blurb nor
  anything else. As a consequence the first-use notice was delivering the
  DADL's `backend.description` instead of the operator's hint, announcing what
  an API is to a caller who had just chosen a tool from it. `BackendInfo` now
  keeps the two apart: `Description` (what the backend is, from the API
  definition) drives the `execute_code` catalog line as before, while `Hint`
  (what the operator wants known here) drives the first-use notice and nothing
  else. Backends without a configured hint now stay silent.
- `discover_tools` now matches its `pattern` against the tool name it actually
  prints, not just the canonical one. Results render names in JavaScript form
  (`tabula-wiki_read_page` prints as `tabula_wiki_read_page`), so copying a name
  from a result into the next pattern returned nothing — for every backend whose
  name contains a character that is not a JavaScript identifier. Both spellings
  now match.
- Long-running tool calls no longer abort with "The connector's server isn't
  responding." A `tools/call` is now delivered over an MCP Streamable HTTP SSE
  stream when the client accepts one, with a keepalive comment emitted every
  10s while the backend works, so the client's idle timer cannot fire mid-call
  (slow reasoning models, committees, batch evals inside `execute_code`). The
  per-request write deadline is also lifted for tool calls, so the executor and
  `execute_code` timeouts — not the HTTP server's 60s `WriteTimeout` — are the
  real bound. Because the result is streamed the moment the call returns,
  partial `execute_code` results that the buffered path lost when the client
  gave up early are now delivered. Tool-call timeouts surface a specific
  message ("Tool call timed out before the backend responded") instead of a
  generic "Internal error".

### Added

- DADL v0.2 `requires` gate: a file's `requires` block (`toolmesh` semver
  range, `features` list) is enforced fail-closed at load time; unknown keys
  are collected into per-file warnings (warn-and-ignore, deduplicated) instead
  of vanishing silently. Implemented feature identifiers: `refresh_token`,
  `composites`, `file_url`, `redact`, `semantic_errors`, `idempotency`,
  `jwt_bearer`, `authorization_code`, `returns`, `deprecation`.
- `response.redact` (spec §9.3): declared JSONPaths are masked with
  `[REDACTED]` after the transform pipeline and before any caller-visible
  output, merging additively across defaults and tool level. The JSONPath
  engine now implements the full §9.4 dialect including the wildcard selector;
  descendant segments (`$..`) are rejected instead of silently misread.
- Semantic error codes (spec §8.2): failed calls carry a stable code from
  `errors.map` or the well-known default table, the raw HTTP status, the
  extracted message, and the provider's own code via the previously inert
  `errors.code_path`. Composite `api.*` failures throw errors composite code
  can branch on (`e.code`, `e.http_status`, `e.provider_code`).
- Idempotency keys (spec §6.6): tools declaring `idempotency` get a `uuid_v4`
  key generated before the first attempt and replayed on every retry of the
  same logical call; distinct calls and pagination pages get distinct keys.
- OAuth2 `jwt_bearer` (RFC 7523 service accounts, RS256-signed with the
  standard library) and `authorization_code` (runtime renewal identical to
  `refresh_token`; consent configuration declared for the setup tooling).
- `returns`, `deprecated`, and `replaced_by` (spec §6.5/§6.7): typed results
  and deprecation markers flow into generated TypeScript and into the live
  tool descriptors — descriptions lead with `DEPRECATED … use X instead` and
  trail with `Returns: <type>`.
- `auth.type: api_key` is accepted as an alias for `apikey` (spec §15.4).
- A backend's `hint:` (backends.yaml) is now delivered with the first tool call
  each caller makes into that backend, in addition to the `execute_code` tool
  description. That description carries every backend's hint at once, so on a
  large mesh it grows long enough for clients to truncate it and the hints past
  the cut are never seen. Attaching the hint to the call delivers it in full, at
  the moment it is relevant, to whoever is actually using the backend. On a
  direct call the note arrives as its own content block ahead of the payload;
  inside `execute_code` it rides beside the result as `notice` (never merged
  into it — the script consumes the result as data). Delivery is per caller and
  best-effort: a missed or repeated note costs a few tokens and nothing else.
- `execute_code`'s wall-clock budget is configurable via `TOOLMESH_CODE_TIMEOUT`
  (seconds, default 120), so an orchestration of several slow backend calls is
  not capped below the backends' own timeouts.
- `include_tools` backends.yaml option restricts a backend's exposed surface to
  exactly the named tools/composites — everything else disappears from
  `discover_tools`, `execute_code`, and direct calls. Lets a broad shared DADL
  (e.g. `openai.dadl`) be pointed at a chat-only endpoint (Ollama, vLLM) while
  advertising only chat/embeddings, keeping the catalog small and preventing the
  LLM from picking an unimplemented endpoint. Honored for every transport: MCP
  backends (`http`, `stdio`) apply the allow-list to the upstream `tools/list`
  result and enforce it again on execution, so an MCP server's housekeeping
  tools can be kept off the agent's surface. Entries that match no upstream
  tool, and `expose_tools` entries the allow-list excludes, are reported in the
  log on connect.
- Progressive discovery for large tool catalogs. `discover_tools` now
  auto-scales its output with the number of matches (≤25 full TypeScript
  signatures, ≤250 one-line summaries, ≤2000 names only, above that a
  per-backend overview), supports BM25-ranked free-text search via the new
  `query` parameter (top 25 by default), accepts explicit `detail` and
  `limit` overrides, always appends a matched/shown footer with refine
  hints, and hard-caps every response at 50 KB. Previously a broad pattern
  like `netbox` returned ~320 KB of full declarations on a large instance.
- In-sandbox discovery for Code Mode: `toolmesh.discover("<free text>",
  limit?)` returns ranked `{name, description, backend}` matches and
  `toolmesh.describe("<tool_name>")` returns the full parameter schema —
  both run locally against the descriptor index, do not count toward the
  per-execution tool-call budget, and respect per-user authorization.
- New dependency-free `internal/toolindex` package: in-memory BM25 index
  over tool names, descriptions, and parameter names.
- DADL spec §6.2 file handling in the REST transport. Parameters declared
  `type: file_url, in: body` are fetched from the caller-provided URL
  (`http`/`https`, or `file` inside the allowed upload directory) and sent to
  the backend as the raw streamed request body — or as a multipart/form-data
  file part when the tool declares `content_type: multipart/form-data`
  (e.g. DeepL document upload). Tools declaring `response: {type: file_url,
  ttl: ...}` store binary responses in the file broker / blob store and
  return a download URL honoring the per-tool TTL, instead of inlining raw
  bytes as text. File fetches use a dedicated HTTP client that never carries
  the backend's cookies, credentials, or relaxed TLS settings, and are
  capped at 100 MB.
- File broker ingest and `tm-blob://` handles (spec §6.2.3/§6.2.4), so binary
  content never has to travel through the model context. `POST /files/upload`
  now serves the broker endpoint the built-in `FileBrokerClient` always
  expected, guarded by MCP authentication, and the new `upload_file` built-in
  fetches a public URL server-side (SSRF-safe) and returns a handle. A handle
  resolves straight from the store in `file_url` parameters — no HTTP hop —
  and any other string parameter whose whole value is a handle carrying an
  explicit `#base64`, `#dataurl`, or `#url` fragment is materialized
  server-side before egress, at any nesting depth; malformed or unknown
  handles fail the call. Inline forms are capped at 10 MB, `#url` streams.
  Substitution sits in the REST adapter, so direct calls and `execute_code`
  behave identically. `GET`/`HEAD` on `/blobs/{id}` stay capability-based —
  the unguessable ID plus the TTL — so a download URL can be handed to a
  backend without sharing credentials, while `DELETE` is destructive and
  requires MCP authentication. The blob metadata index is still in memory: a
  restart drops the handles.

### Changed (BREAKING)

- Retry safety (spec §8, normative): automatic retries now require an
  idempotent method (`GET`/`HEAD`/`PUT`/`DELETE`), a declared `idempotency`
  block, or `retry_unsafe: true`. A `POST`/`PATCH` with none of these fails on
  the first retryable error instead of being retried — re-execution could
  duplicate the write. The error text names both remedies. Write tools
  inheriting a default `retry_on` list stop auto-retrying until their DADL
  opts in deliberately.
- Behavior-determining enum values are now rejected fail-closed at load time
  (spec §15.3): unknown values of `auth.flow`, `pagination.strategy`,
  `pagination.behavior` (only `auto`/`expose`), `response.stream_handling`
  (only `collect`/`skip`), and `idempotency.generate` refuse the file instead
  of being silently ignored. DADLs carrying informal values (e.g.
  `behavior: manual`) must be corrected before upgrading.
- A DADL whose `requires` block names a capability this build does not
  implement (e.g. `refresh_token_rotation`) is refused at load time with a
  message naming the missing capability — by design, fail-closed.
- `backends.yaml` is now parsed strictly: a key that no backend field claims
  aborts startup with the offending line instead of being silently dropped.
  Lenient parsing let a misspelled or invented option (e.g. `tools_filter`)
  vanish without a trace, leaving an operator convinced a restriction was in
  force while the backend exposed its full tool surface. Operators must remove
  or correct unknown keys before upgrading; a hot-reload that hits one keeps
  the previous backends running and logs the error rather than taking the
  server down. Empty or fully commented-out files still mean "no backends".
- The `list_tools` MCP meta-tool was renamed to `discover_tools`. There is no
  backwards-compatible alias — clients that hard-code the old name must be
  updated. The rename is harness-stable: clients that sort tool listings
  alphabetically now show `discover_tools` before `execute_code`, matching the
  intended discover-then-execute workflow. Affected: tool name, the `tool`
  label on `toolmesh_tool_calls_total` / `toolmesh_tool_call_duration_seconds`,
  and the structured-log message key when discovery is invoked.
- The `discover_tools` description shrank from a few thousand tokens (it
  duplicated the per-backend hint block) to under 100 tokens. The hint block
  remains on `execute_code`, which is the tool LLMs reach for when they want
  the discovery surface in front of them.
- Backend instances that share a DADL spec are now collapsed into a single
  hint line in the `execute_code` description (e.g.
  `dokuwiki-prod, dokuwiki-staging: DokuWiki JSON-RPC API` instead of two
  identical lines). Native backends without a DADL spec continue to render
  individually.
- `execute_code` no longer echoes every tool call's full result beside the
  script's return value. When the script returns a value, successful call
  entries are compacted to `{tool, status, resultBytes}`: the return value is
  the script's own projection of the data it fetched, so the full echo
  transported the same payload twice and defeated Code Mode's token economy —
  a script returning a two-field summary of a transaction list still produced
  a 144 KB response. Entries carrying an error keep their full content (both
  dispatch failures and tool-level `isError` results), because re-running a
  side-effectful call to recover the diagnostic is exactly what this avoids,
  and scripts without a return value are unchanged, since there the trace is
  the result. A caller that parses the per-call `result` must read the return
  value instead, or set the new `include_results: true` to restore the full
  echo.

### Security

- Sandbox lockdown: the goja `LockdownRuntime` now clears the `constructor`
  reference on the intrinsic prototypes before the `eval`/`Function` stubs are
  installed. The previous order left `Function.prototype` shadowed, so the
  prototype-chain route to the Function constructor
  (`(function(){}).constructor('code')()`) stayed reachable in the composite,
  code-mode, unit, and gate runtimes.
- Static scanner: `ScanCode` now flags blocklisted property names in member
  access — both dot (`obj.constructor`) and string-literal bracket
  (`obj["constructor"]`) forms — not only bare identifiers.
- Composite child calls (`api.*`) are now authorized and pre-gated with the
  same OpenFGA check and pre-execution gate as a direct call to the same tool,
  fail-closed. Previously a composite could invoke a sibling tool the caller
  was not authorized for or that a gate policy would have blocked.
- `LOG_LEVEL` now defaults to `info` instead of `debug`. Debug-level logging
  intentionally includes full request URLs (which contain query-string API
  keys for `auth.inject_into: query` backends), so it is no longer the default.
- Caller-supplied `file_url` fetches are now governed by a dedicated
  `allow_private_file_url` option (default `false`) instead of inheriting the
  admin `allow_private_url` flag, plus an optional `file_url_allowed_hosts`
  allowlist. This closes an SSRF path to internal/metadata endpoints from the
  default configuration. `IsPrivateIP` also now rejects the unspecified address
  (`0.0.0.0`/`::`), and failed fetches no longer echo the upstream response
  body.
- goja runtimes get a soft heap limit via `TOOLMESH_MEM_LIMIT_BYTES` /
  `GOMEMLIMIT` plus a per-runtime call-stack cap, and `docker-compose.yml` sets
  `mem_limit` + `restart`, so a runaway sandbox allocation is contained to a
  restartable container instead of OOM-killing the host.
- `.mcpregistry_*` publisher credential files are now git-ignored and a
  `gitleaks` job runs in CI.

## [0.1.3] - 2026-04-06

### Added

- Anonymous, opt-out telemetry: aggregated DADL usage statistics
  (content hash, call/error counts, MCP server count, version) sent
  to `tmc.dunkel.cloud` every 24 hours. Opt out via
  `DO_NOT_SEND_ANONYMOUS_STATISTICS=yes`. No individual data collected.
- SHA-256 `ContentHash` computed on DADL parse for telemetry
  identification.
- Telemetry state persisted to `toolmesh.json` across restarts;
  overdue sends triggered immediately on startup.

## [0.1.2] - 2026-04-05

### Security

- SSRF hardening: REST `base_url` and redirects validated against
  private/loopback/link-local/metadata ranges at both config load and
  connection time; DNS resolution fails closed.
- Sandbox scanner now walks class bodies, static blocks, and tagged
  template literals.
- OAuth 2.1: `client_id` verified in authorization_code and
  refresh_token grants.
- New `SecurityHeaders` and `PanicRecovery` HTTP middleware; `/mcp`
  enforces JSON content-type and a 10 MB body cap; `clientIP` uses
  rightmost-non-private X-Forwarded-For.
- CORS wildcard matching requires a proper subdomain boundary; blob
  store files written with 0600 and path traversal blocked.
- DADL jq transforms capped at 100k results / 10 MB output; error
  messages truncated at 1 KB.
- Sensitive parameters redacted in debug logs, not only audit records.

### Tests

- `go test -cover ./...` restored from 51.2% to 80.0% with 48 new
  unit-test files. See the v0.1.2 release notes for per-package
  numbers.

## [0.1.1] - 2026-04-04

### Security

- Composite/code-mode sandbox: `Function.prototype.constructor` frozen
  to block prototype-chain bypasses; scanner blocklist extended
  (`constructor`, `__proto__`, `Reflect`, …); `execute_code` runs
  through the same static analysis.
- OAuth 2.1: PKCE (S256) mandatory on all `/authorize` requests;
  `redirect_uri` validated on GET flow; DCR rejects non-HTTPS redirect
  URIs.
- Gate policy VM runs with a 5s timeout under `LockdownRuntime`;
  `RateLimiter.Check` separated from `Record` so policies cannot
  inflate counters.
- `list_tools` results filtered through the OpenFGA authorizer.
- Sensitive params redacted before audit persistence; credential env
  var names no longer leaked in error messages.
- Initial SSRF validation for DADL `base_url`.

### Changed

- REST HTTP client split into default (30s) and streaming (10min)
  variants; per-backend `timeout` / `streaming_timeout` options in
  `backends.yaml`.
- Missing `CREDENTIAL_*` env vars now silently skip the auth header
  instead of aborting, enabling optional-auth APIs (e.g. Semantic
  Scholar).

### Fixed

- PR container images tagged `pr-<N>` are correctly deleted on PR
  close.

## [0.1.0] - 2026-04-02

Initial release.

### Added

#### MCP Server

- Streamable HTTP transports
- Multi-backend aggregation with automatic tool prefixing (`backend_toolname`)
- Fail-closed execution pipeline: AuthZ → Credentials → Gate (pre) → Backend → Gate (post) → Audit
- Configurable timeouts for MCP communication and tool execution
- CORS origin control

#### Code Mode

- `list_tools` and `execute_code` meta-tools for LLM-driven orchestration
- LLMs write JavaScript instead of constructing JSON tool calls
- AST-parsed tool call extraction from code blocks

#### Backend Adapters

- **MCP adapter** — connect to any MCP server via HTTP or STDIO
- **REST adapter (DADL)** — declarative API definitions in YAML
- **Echo adapter** — built-in test backend
- **Composite tool engine** — server-side multi-endpoint orchestration with TypeScript, max 50 API calls per execution

#### DADL (Dunkel API Definition Language)

- YAML-based API descriptions with per-tool definitions
- Authentication strategies: Bearer, OAuth2, Session, API key
- Automatic pagination: cursor, offset, page, and link_header strategies
- Response transformation with JSONPath extraction and jq filters
- Error handling with configurable retry (exponential backoff)
- Per-tool access classification (`read`, `write`, `admin`, `dangerous`, custom)
- File handling with built-in file broker (upload/download)
- Form-encoded body serialization
- `lint-dadl` CLI for security linting of DADL files

#### Pre-built DADL Integrations

- GitHub API
- GitLab API
- Vikunja (task management)
- Shelly Cloud (IoT device control)
- DokuWiki

#### Authentication

- OAuth 2.1 with PKCE S256
- Dynamic Client Registration (DCR) with rate limiting (5/hour per IP)
- API key authentication with bcrypt hashing
- Single-user mode with password/API key fallback
- Redis-persisted OAuth state (survives container restarts)
- Multi-user support via `users.yaml` and `apikeys.yaml`

#### Authorization

- OpenFGA integration with User → Plan → Tool relationship model
- Bypass and restrict modes for development vs. production
- `tm-bootstrap` CLI for OpenFGA store setup and password hashing
- Caller-origin integration (CallerClass-aware policies)

#### Credential Store

- Runtime injection via `CREDENTIAL_*` environment variables
- Secrets never exposed in prompts or tool definitions
- Registry-based extension model (inspired by Go `database/sql` drivers)

#### Output Gate

- Pre- and post-execution JavaScript policies via goja engine
- Seven example policies included: default passthrough, PII protection, role-based field filtering, caller blocking, caller-class enforcement, GitHub branch protection, Shelly write protection

#### Audit

- Pluggable audit stores: slog (write-only) and SQLite (queryable)
- Configurable retention (default: 90 days for SQLite)
- Every tool call logged structurally with trace ID

#### Security

- CallerID spoofing prevention via DCR `client_name`
- Caller-origin tracking (CallerID, CallerName, CallerClass) with `caller-classes.yaml`
- PII filtering with role-based field filtering
- Input validation and sanitization for all tool parameters
- Credential isolation — secrets never exposed in tool responses or logs
- Binary response handling

#### Extension Model

- Registry pattern for Credential Stores, Tool Backends, and Gate Evaluators
- Enterprise extensions via Go build tags (`-tags enterprise`)
- Enterprise credential stores: Infisical, HashiCorp Vault / OpenBao
- Enterprise gate evaluator: Compliance-LLM (LLM-based content classification)

#### Deployment

- Alpine-based multi-stage Docker build with scratch final image
- Multi-platform builds (amd64 + arm64) via buildx
- Docker Compose orchestration with optional services (OpenFGA/MySQL, KeyDB, Caddy)
- Health checks for all services
- Example configuration files (`.env`, `backends.yaml`, `users.yaml`, `apikeys.yaml`, `caller-classes.yaml`, `docker-compose.override.yml`)

#### Logging & Debugging

- Structured logging via slog (JSON and text formats)
- Per-backend debug file logging (`DEBUG_BACKENDS`, `DEBUG_FILE`)
- Configurable log levels
- HTTP request tracing with trace ID propagation

#### Documentation

- Architecture overview with six-pillar model
- Configuration reference for all environment variables
- DADL specification v0.1
- Contributing guidelines, security policy, and code of conduct