# 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__*` (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:`, 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 `_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/` 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=`, 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//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//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/`: 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: `. - `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("", limit?)` returns ranked `{name, description, backend}` matches and `toolmesh.describe("")` 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-` 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