ToolMesh — Let AI agents touch real systems. Safely.
One Go binary between your agents and your infrastructure — with authorization, credential security, audit logging, and output policies on every tool call.
30 lines of YAML. No server to build.
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
The Six Pillars
| 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 |
Quickstart
# 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 below
TLS (important)
ToolMesh 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://).
Connect to Claude Desktop
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"
}
}
}
Connect to Claude.ai (Custom Connector)
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.
Authentication
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.
OAuth 2.1 (Interactive Login)
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 any bcrypt-capable utility:
htpasswd -nbBC 10 "" "my-password" | cut -d: -f2
For 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).
API Keys (Programmatic Access)
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.
DCR Rate Limiting
Dynamic Client Registration is rate-limited to 5 registrations per hour per IP to prevent abuse.
Caller-Origin
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 a verified identifier derived from the authentication source:
- API keys: The
caller_idfield inconfig/apikeys.yaml(admin-configured, trusted) - OAuth clients: The opaque
client_idUUID from Dynamic Client Registration - Anonymous: Falls back to
"anonymous"
CallerName is the self-reported display name (like a browser User-Agent). For OAuth clients this is the client_name from DCR (e.g. "claude-desktop"). It appears in audit logs for debugging but is never used for security decisions.
CallerClass maps CallerIDs to trust levels via config/caller-classes.yaml:
classes:
trusted:
- claude-code # matches API key caller_id
# - "uuid-here" # to trust a specific OAuth client, add its client_id UUID
standard:
- partner-*
# Everything else defaults to "untrusted"
Security note: OAuth CallerIDs are opaque UUIDs and default to
untrusted. Only API key CallerIDs are admin-controlled and can be reliably mapped to elevated trust levels. To elevate an OAuth client, add itsclient_idUUID to the config after registration.
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_name, 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';
Authorization Mode
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.
Configuration
See docs/configuration.md for all environment variables.
Timeout tuning
| 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
Logging
| 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)
Per-backend debug file
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 file
The ./data directory is typically volume-mounted to the host, so the debug file is directly accessible without docker cp.
Architecture
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
Adding an External MCP Server
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-xxxxx
Tools 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.
REST Proxy Mode (DADL)
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 .dadl
The 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.
A taste of DADL
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.
Writing a .dadl File
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: none
REST 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 });
DADL Features
- 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) - Access Classification: Per-tool access levels (
read/write/admin/dangerous+ custom) for role-based authorization policies - Composite Tools: Server-side multi-endpoint orchestration in TypeScript — sandboxed, audited, max 50 API calls per execution
- Hints & Examples: Per-tool domain knowledge and few-shot prompts for better LLM accuracy
- Type Definitions: Reusable type schemas for large APIs (100+ tools)
- File Handling: Built-in file broker — upload, download, and reference files by URL (never inline)
Creating DADLs
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, browse and contribute at dadl.ai, email it to [email protected], or open a PR on the dadl-registry.
Code Mode
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).
Extension Model
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, rest (DADL), 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.
Contributing
See CONTRIBUTING.md.
License
Apache 2.0 — Copyright 2025–2026 Dunkel Cloud GmbH