The secure, durable execution layer between AI agents and enterprise infrastructure.
In practice, MCP servers only expose a fraction of the REST API they wrap — and you'll hit the gaps fast. ToolMesh lets you replace the wrapper layer with .dadl files — a declarative YAML format that describes any REST API as MCP tools. No wrapper server to build, deploy, or maintain.
Current: Claude → ToolMesh → MCP Server → REST API
With DADL: Claude → ToolMesh → REST API (via .dadl file)
You don't write the YAML by hand. You ask an LLM. Claude, GPT, Gemini — any model that knows the DADL spec generates a working .dadl file in seconds. Describe what you need, drop the file into config/dadl/, done.
"Create a DADL for the GitHub API — list repos, open issues, and create pull requests."
10 seconds. Works with any LLM that knows the format.
And unlike MCP gateways that just pass tool calls through, ToolMesh adds what production deployments actually need:
- Authorization — fine-grained user → plan → tool control (OpenFGA)
- Credential Security — secrets injected at execution time, never in prompts
- Audit Trail — every tool call recorded with structured logging or queryable SQLite
- Input & Output Gating — JS policies validate parameters and filter responses
| Pillar | What it does | Backed by |
|---|---|---|
| Any Backend | Connect MCP servers or describe REST APIs declaratively via DADL | Go MCP SDK + DADL (.dadl files) |
| Code Mode | LLMs write typed JS instead of error-prone JSON | AST-parsed tool calls |
| Audit | Execution trail — every tool call recorded and queryable | slog / SQLite |
| OpenFGA | Fine-grained authorization (user → plan → tool) | OpenFGA |
| Credential Store | Inject secrets at execution time, never in prompts | Per-request injection via Executor pipeline |
| Gate | JavaScript policies validate inputs (pre) and filter outputs (post) | goja |
# Clone
git clone https://github.com/DunkelCloud/ToolMesh.git
cd ToolMesh
# Configure
cp .env.example .env
# IMPORTANT: Set a password — without it, all requests are rejected:
# TOOLMESH_AUTH_PASSWORD=my-secret-password
# Or set an API key for programmatic access:
# TOOLMESH_API_KEY=my-api-key
# Optional: local overrides (build locally, enable OpenFGA, HTTPS proxy, ...)
# cp docker-compose.override.yml.example docker-compose.override.yml
# # then edit docker-compose.override.yml — picked up automatically by Docker Compose
# Start (runs in bypass mode by default — no authz required)
docker compose up -d
# Verify it's running (default port: 8123)
curl http://localhost:8123/health
# MCP endpoint: http://localhost:8123/mcp
# Note: Most MCP clients require HTTPS — see TLS section belowToolMesh itself serves plain HTTP. Most MCP clients — including Claude Desktop — require HTTPS and will reject http:// URLs. You need a TLS-terminating reverse proxy in front of ToolMesh:
| Option | When to use |
|---|---|
| Caddy | Self-hosted with a public domain — automatic Let's Encrypt certs |
| Cloudflare Tunnel | No open ports needed, zero-config TLS |
| nginx / Traefik | Already in your stack |
For local development only, you can bypass TLS by editing claude_desktop_config.json by hand (the GUI enforces https://).
Add to your Claude Desktop MCP config:
{
"mcpServers": {
"toolmesh": {
"url": "https://toolmesh.example.com/mcp"
}
}
}For local development without TLS proxy:
{
"mcpServers": {
"toolmesh": {
"url": "http://localhost:8123/mcp"
}
}
}ToolMesh supports OAuth 2.1 with PKCE S256 for remote access. Configure users in config/users.yaml and use the public HTTPS URL as the MCP endpoint.
ToolMesh supports two authentication methods that can be used independently or together. All OAuth state (tokens, auth codes, clients) is persisted in Redis and survives server restarts.
Define users in config/users.yaml with bcrypt-hashed passwords:
users:
- username: admin
password_hash: "$2a$10$..."
company: dunkelcloud
plan: pro
roles: [admin]Generate password hashes with the bootstrap tool or any bcrypt-capable utility:
# Using tm-bootstrap (inside the container)
docker compose exec toolmesh /tm-bootstrap hash-password "my-password"
# Or using htpasswd (on the host)
htpasswd -nbBC 10 "" "my-password" | cut -d: -f2For single-user setups, TOOLMESH_AUTH_PASSWORD still works as a fallback. Configure the identity with TOOLMESH_AUTH_USER, TOOLMESH_AUTH_PLAN, and TOOLMESH_AUTH_ROLES (defaults: owner, pro, admin).
Define API keys in config/apikeys.yaml with bcrypt-hashed keys:
keys:
- key_hash: "$2a$10$..."
user_id: claude-code-user
company_id: dunkelcloud
plan: pro
roles: [tool-executor]Each key maps to a distinct user identity with its own plan and roles, which flow through to OpenFGA authorization.
For single-key setups, TOOLMESH_API_KEY still works as a fallback. The same TOOLMESH_AUTH_USER, TOOLMESH_AUTH_PLAN, and TOOLMESH_AUTH_ROLES variables control the identity.
Dynamic Client Registration is rate-limited to 5 registrations per hour per IP to prevent abuse.
ToolMesh tracks which AI client triggers each tool call. This lets operators restrict high-risk tools for low-trust clients, apply different PII filtering per caller, and audit who did what — all without maintaining separate MCP deployments.
CallerID is derived automatically from the authentication source:
- OAuth clients: The
client_namefrom Dynamic Client Registration (e.g."claude-code") - API keys: The
caller_idfield inconfig/apikeys.yaml - Anonymous: Falls back to
"anonymous"
CallerClass maps CallerIDs to trust levels via config/caller-classes.yaml:
classes:
trusted:
- claude-code
- claude-desktop
- local-llm
standard:
- partner-*
# Everything else defaults to "untrusted"Trust levels affect the execution pipeline:
| CallerClass | PII Filtering | Tool Access | Audit |
|---|---|---|---|
trusted |
Credentials only (AWS keys, API tokens) | Full | Audit entry with caller context |
standard |
High-risk PII + credentials | Full | Audit entry with caller context |
untrusted |
All PII patterns | Sensitive tools blocked | Audit entry with caller context |
Audit entries include caller_id, caller_class, user_id, company_id, and tool fields. With the sqlite audit store, these are queryable:
SELECT * FROM audit_events WHERE caller_class = 'untrusted' AND tool = 'memorizer_retrieve_knowledge';OPENFGA_MODE controls whether OpenFGA authorization is enforced:
| Mode | Behavior |
|---|---|
bypass (default) |
All tool calls are allowed without authz checks |
restrict |
OpenFGA enforces user → plan → tool authorization (requires OPENFGA_STORE_ID) |
Start with bypass to get running quickly, then switch to restrict after bootstrapping OpenFGA.
See docs/configuration.md for all environment variables.
| Variable | Default | Description |
|---|---|---|
TOOLMESH_MCP_TIMEOUT |
120 |
HTTP client timeout (seconds) for calls to downstream MCP servers |
TOOLMESH_EXEC_TIMEOUT |
120 |
Tool execution timeout (seconds) — context deadline for backend calls |
Increase these for backends that need more time (e.g. browser-based web fetchers):
TOOLMESH_MCP_TIMEOUT=180
TOOLMESH_EXEC_TIMEOUT=180| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
debug |
Log verbosity: debug, info, warn, error |
LOG_FORMAT |
json |
Output format: json or text |
DEBUG_BACKENDS |
(empty) | Comma-separated backend names for per-backend debug logging |
DEBUG_FILE |
(empty) | Path to a separate debug log file (e.g. debug.log) |
Development default. The default level is debug so that MCP communication issues are fully traceable out of the box. At this level, ToolMesh logs complete request/response payloads which may include sensitive data. For production, set LOG_LEVEL=info or higher.
At debug level, ToolMesh logs the complete request/response flow between clients and backends:
- Incoming JSON-RPC method, params, and request ID
- Outgoing JSON-RPC results and errors
- Backend connection lifecycle (connect, discover, disconnect)
- Tool call parameters sent to MCP backends and their responses
- Executor pipeline steps (authz, credential injection, gate pre, execution, gate post)
When troubleshooting a specific backend, set DEBUG_BACKENDS and DEBUG_FILE to write debug-level output for only the named backends to a separate file. The file also includes the ToolMesh startup banner (version, commit, build date) so recipients have full context. Normal stdout logging continues at the global LOG_LEVEL unchanged.
DEBUG_BACKENDS=github
DEBUG_FILE=debug.log
LOG_LEVEL=error # keep stdout quiet, debug goes to the fileThe ./data directory is typically volume-mounted to the host, so the debug file is directly accessible without docker cp.
See docs/architecture.md for the full architecture documentation.
┌─────────────────────────────────┐
│ ToolMesh │
│ │
│ Redis · OpenFGA · Audit │
│ Credential Store · JS Gate │
│ │
AI Agent ──MCP──────────▶ │ AuthZ ▸ Creds ▸ Gate ▸ Exec │
│ │
└──┬──────┬───────┬───────┬───────┘
│ │ │ │
MCP Client .dadl .dadl .dadl
│ │ │ │
▼ ▼ ▼ ▼
MCP Stripe GitHub Vikunja
Server API API API
Create or edit config/backends.yaml:
backends:
- name: memorizer
transport: http
url: "https://memorizer.example.com/mcp"
api_key_env: "MEMORIZER_API_KEY"Set the credential as an environment variable:
CREDENTIAL_MEMORIZER_API_KEY=sk-mem-xxxxxTools from each backend are exposed with a prefix (e.g. memorizer_retrieve_knowledge). Credentials are injected by the Executor at runtime via the CredentialStore — the LLM never sees API keys.
When an MCP server doesn't expose an endpoint you need, describe it in a .dadl file and ToolMesh calls the REST API directly — no wrapper server needed.
Current: Claude → ToolMesh → MCP Server → REST API
New: Claude → ToolMesh → REST API (via .dadl file)
Both modes run in parallel. Add a REST backend to config/backends.yaml:
backends:
- name: vikunja
transport: rest
dadl: /app/dadl/vikunja.dadl
url: "https://vikunja.example.com/api/v1" # overrides base_url in .dadlThe url field is optional — it overrides the base_url in the .dadl file. This is useful for APIs like Vikunja where each deployment has a different URL, while APIs like Stripe can hardcode their URL in the .dadl file.
Want Claude to list GitHub issues? Here's all it takes:
tools:
list_issues:
method: GET
path: /repos/{owner}/{repo}/issues
description: "List issues for a repository"
params:
owner: { type: string, in: path, required: true }
repo: { type: string, in: path, required: true }
state: { type: string, in: query }That's it — ToolMesh handles auth, pagination, retries, and error mapping. The full .dadl format below adds these as declarative defaults.
A .dadl file describes a REST API declaratively:
spec: "https://dadl.ai/spec/dadl-spec-v0.1.md"
backend:
name: myapi
type: rest
base_url: https://api.example.com/v1 # optional if url is set in backends.yaml
description: "My API service"
auth:
type: bearer # bearer, oauth2, session, apikey
credential: my-api-token # logical name for CredentialStore
inject_into: header
header_name: Authorization
prefix: "Bearer "
defaults:
headers:
Content-Type: application/json
pagination:
strategy: page # cursor, offset, page, link_header
request:
page_param: page
limit_param: per_page
limit_default: 50
response:
total_pages_header: x-total-pages
behavior: auto
max_pages: 20
errors:
format: json
message_path: "$.message"
retry_on: [429, 502, 503]
terminal: [400, 404]
retry_strategy:
max_retries: 3
backoff: exponential
initial_delay: 1s
tools:
list_items:
method: GET
path: /items
description: "List all items"
params:
page: { type: integer, in: query }
search: { type: string, in: query }
get_item:
method: GET
path: /items/{id}
description: "Get a single item"
params:
id: { type: integer, in: path, required: true }
pagination: none
create_item:
method: POST
path: /items
description: "Create an item"
params:
name: { type: string, in: body, required: true }
tags: { type: array, in: body }
pagination: noneREST Proxy tools integrate seamlessly into Code Mode:
const tasks = await toolmesh.vikunja_list_project_tasks({ project_id: 1 });
await toolmesh.vikunja_set_task_position({ id: 42, position: 1.5, project_view_id: 1 });- Auth: Bearer token, OAuth2 client_credentials, session-based login, API key (header or query)
- Pagination: Automatic multi-page fetching (cursor, offset, page number, Link header)
- Error Handling: Configurable retry on transient errors (429, 5xx) with exponential backoff
- Response Transformation: JSONPath extraction (
result_path) and jq filters (transform) - Scoping: Type definitions ready for large APIs (>100 tools) — implementation progressive
As shown above, the fastest path is asking an LLM. If you use Claude Code with ToolMesh connected, it can create the .dadl file, add the backend entry to config/backends.yaml, and set the credential — all in one session.
To share a DADL with the community, either email it to dadl@dunkel.cloud or open a PR on the dadl-registry.
Instead of raw JSON tool calls, LLMs can use typed JavaScript:
// List available tools with TypeScript definitions
const tools = await toolmesh.list_tools();
// Execute tools with typed parameters
const result = await toolmesh.memorizer_retrieve_knowledge({
query: "project architecture",
top_k: 5
});ToolMesh parses the code, extracts tool calls, and routes them through the full execution pipeline (AuthZ → Credentials → Gate pre → Backend → Gate post).
ToolMesh uses a registry-based extension model inspired by Go's database/sql driver pattern. Three component types are extensible via init() registration:
| Component | Built-in | Config |
|---|---|---|
| Credential Store | embedded |
CREDENTIAL_STORE=<name> |
| Tool Backend | mcp, echo |
config/backends.yaml |
| Gate Evaluator | goja |
GATE_EVALUATORS=<list> |
Enterprise extensions (InfisicalStore, VaultStore, Compliance-LLM, etc.) are available separately and included via Go build tags: go build -tags enterprise ./cmd/toolmesh.
See docs/architecture.md for details.
See CONTRIBUTING.md.
Apache 2.0 — Copyright 2025–2026 Dunkel Cloud GmbH