CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# 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