Skip to content

Example request: signing outbound requests from a Cloudflare Agent (MCP client transport.fetch) #86

@joeblew999

Description

@joeblew999

Type: documentation / example request
Area: examples/ (signing side)

Summary

The repo has good coverage of verifying web-bot-auth on the edge (examples/verification-workers, examples/caddy-plugin) and signing from a browser (examples/browser-extension) or a hardcoded Rust request (examples/rust). What’s missing is the case that’s becoming common: a Cloudflare Agent signing its own outbound HTTP requests so they can be verified by an origin the operator controls (e.g. a self-hosted backend behind Caddy/the existing caddy-plugin).

This is non-obvious enough to be worth an example, for two reasons specific to the Agents SDK:

  1. Durable Object outbound fetch isn’t interceptable. Cloudflare Agents run on Durable Objects, and outbound fetch() from a DO is not intercepted by Outbound Workers, proxies, or mTLS bindings (see Outbound Workers and Durable Objects workers-sdk#13946). So there is no transparent layer to attach a signature — it has to be signed inline, at the point the request leaves the agent.
  2. The correct injection point is the MCP client transport. When the agent reaches the origin via its built-in MCP client (this.mcp), the developer never holds the Request object directly, so signatureHeaders(request, …) can’t be called at a call site. The clean hook is the transport’s custom fetch.

The pattern (verified against types, not yet a built example)

agents (MCPTransportOptions) forwards to the MCP TypeScript SDK’s StreamableHTTPClientTransportOptions, which exposes:

/** Custom fetch implementation used for all network requests. */
fetch?: FetchLike;   // @modelcontextprotocol/sdk/shared/transport

So a signing wrapper can be passed straight through:

import { signatureHeaders } from "web-bot-auth";
import { signerFromJWK } from "web-bot-auth/crypto";
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";

function makeSigningFetch(jwk: JsonWebKey): FetchLike {
  return async (url, init = {}) => {
    const req = new Request(url, init);
    const signer = await signerFromJWK(jwk);
    const now = new Date();
    const sig = await signatureHeaders(req, signer, {
      created: now,
      expires: new Date(now.getTime() + 60_000),
    });
    const headers = new Headers(init.headers);
    for (const [k, v] of Object.entries(sig)) headers.set(k, v as string);
    return fetch(url, { ...init, headers });
  };
}

// inside the Agent
await this.mcp.connect("https://origin.example.com/mcp", {
  transport: { fetch: makeSigningFetch(JSON.parse(this.env.SIGNING_JWK)) },
});

The same makeSigningFetch works for a hand-written tool that calls fetch directly, not just the MCP path.

Why it’s useful in the repo

  • It closes the loop with the existing verify-side examples: examples/caddy-plugin / examples/verification-workers show the origin verifying; this would show a Cloudflare-native client producing the signatures they verify.
  • The DO-outbound and MCP-transport details are easy to get wrong and aren’t discoverable from the current examples.
  • It demonstrates web-bot-auth for the agent-to-self-hosted-origin topology (the operator owns both ends, so the directory is just their own public key), which is distinct from the public-crawler framing.

Proposal

Add examples/cloudflare-agents-signing/ (or a docs section) containing:

  • A minimal Agent that signs outbound calls via transport.fetch.
  • Key generation + how the public JWK maps to a /.well-known/http-message-signatures-directory consumed by the caddy-plugin example.
  • A note on the DO-outbound-interception constraint and why signing is inline.

Caveats / open questions

  • I’ve verified the transport.fetch hook against agents@0.14.1 and @modelcontextprotocol/sdk@1.29.0 types, but have not built/run the end-to-end example, so the streamable-http vs SSE auto-probe path and the exact covered-component set should be exercised against the live test directory before this is published.
  • If maintainers feel the signing side is already represented by browser-extension, the differentiator here is purely the Agents-SDK / MCP-transport specifics — happy to scope it down to a docs snippet rather than a full example if preferred.

Happy to contribute the PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions