diff --git a/docs/toolhive/guides-cli/webhooks.mdx b/docs/toolhive/guides-cli/webhooks.mdx index 0e552924..1c098e4b 100644 --- a/docs/toolhive/guides-cli/webhooks.mdx +++ b/docs/toolhive/guides-cli/webhooks.mdx @@ -101,14 +101,14 @@ mutating: ### Webhook fields -| Field | Required | Description | -| ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | Yes | Unique identifier for this webhook. Used for deduplication when merging multiple config files. | -| `url` | Yes | HTTPS endpoint to call. Plain HTTP is accepted for in-cluster or development use (see `insecure_skip_verify` below). | -| `failure_policy` | Yes | `fail` (deny the request on webhook error) or `ignore` (allow through on error). | -| `timeout` | No | Maximum wait time for a response. Accepts duration strings like `5s` or `30s`. Minimum: `1s`, maximum: `30s`. Default: `10s`. | -| `tls_config` | No | TLS options for the webhook HTTP client (see below). | -| `hmac_secret_ref` | No | Environment variable name containing an HMAC secret for payload signing. **Not yet implemented** - accepted in config but currently has no effect. | +| Field | Required | Description | +| ----------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | Yes | Unique identifier for this webhook. Used for deduplication when merging multiple config files. | +| `url` | Yes | HTTPS endpoint to call. Plain HTTP is accepted for in-cluster or development use (see `insecure_skip_verify` below). | +| `failure_policy` | Yes | `fail` (deny the request on webhook error) or `ignore` (allow through on error). | +| `timeout` | No | Maximum wait time for a response. Accepts duration strings like `5s` or `30s`. Minimum: `1s`, maximum: `30s`. Default: `10s`. | +| `tls_config` | No | TLS options for the webhook HTTP client (see below). | +| `hmac_secret_ref` | No | Absolute path to a file containing an HMAC secret, or a secret name resolved by your configured secrets provider. ToolHive signs each payload with HMAC-SHA256. | ### TLS configuration @@ -172,6 +172,175 @@ thv run fetch \ ToolHive validates all webhook configurations at startup and exits with an error if any are invalid, so configuration problems surface before the server starts. +## Example: allow fetch requests to specific hosts + +This self-contained example allows the `fetch` tool to request HTTP(S) URLs on +`example.com`. It denies other hosts, other tools, URLs with credentials or +non-default ports, and malformed requests. The webhook checks the requested URL +without fetching it. + +You need Python 3.10 or later, OpenSSL, and a container runtime supported by +ToolHive. Run the commands in the same directory on your host machine. + +### Create the webhook service + +Save this Python standard-library service as `url_allowlist.py`: + +```python title="url_allowlist.py" +import json +import ssl +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +ALLOWED_HOSTS = {"example.com"} +# These methods let an MCP client initialize and discover the fetch tool. +ALLOWED_METHODS = {"initialize", "notifications/initialized", "ping", "tools/list"} + + +def allow_request(request): + if request.get("jsonrpc") != "2.0": + return False + method = request.get("method") + if method in ALLOWED_METHODS: + return True + if method != "tools/call": + return False + params = request.get("params") + if not isinstance(params, dict) or params.get("name") != "fetch": + return False + arguments = params.get("arguments") + if not isinstance(arguments, dict): + return False + url = arguments.get("url") + if not isinstance(url, str) or any(c.isspace() for c in url) or "\\" in url: + return False + try: + parsed = urlsplit(url) + return ( + parsed.scheme in {"http", "https"} + and parsed.hostname in ALLOWED_HOSTS + and parsed.username is None + and parsed.password is None + and parsed.port in {None, {"http": 80, "https": 443}[parsed.scheme]} + ) + except ValueError: + return False + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path != "/validate": + self.send_error(404) + return + try: + size = int(self.headers.get("Content-Length", "0")) + if not 0 < size <= 1024 * 1024: + raise ValueError("Invalid body size") + payload = json.loads(self.rfile.read(size)) + if ( + not isinstance(payload, dict) + or payload.get("version") != "v0.1.0" + or not isinstance(payload.get("uid"), str) + or not payload["uid"] + or not isinstance(payload.get("mcp_request"), dict) + ): + raise ValueError("Invalid webhook request") + allowed = allow_request(payload["mcp_request"]) + except (ValueError, TypeError): + self.send_error(422) + return + response = { + "version": payload["version"], + "uid": payload["uid"], + "allowed": allowed, + } + if not allowed: + response["reason"] = "url_allowlist_denied" + body = json.dumps(response).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +if __name__ == "__main__": + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain("webhook.crt", "webhook.key") + server = ThreadingHTTPServer(("127.0.0.1", 9443), Handler) + server.socket = context.wrap_socket(server.socket, server_side=True) + server.serve_forever() +``` + +### Start the service with HTTPS + +Create a one-day certificate for the local webhook. ToolHive will trust this +certificate only through the webhook's `ca_bundle_path`; you don't need to add +it to your system's trust store. + +```bash +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout webhook.key -out webhook.crt \ + -subj "/CN=127.0.0.1" -addext "subjectAltName=IP:127.0.0.1" +python3 url_allowlist.py +``` + +Keep this terminal open. The service listens only on the host's loopback +interface. For a shared deployment, use your own HTTPS endpoint and authenticate +requests, for example with HMAC signing or mutual TLS. + +### Connect ToolHive to the webhook + +In a second terminal in the same directory, create the webhook configuration. +The absolute CA path lets the ToolHive proxy find the certificate when it runs +in the background: + +```bash +cat > allowlist-webhooks.yaml <