A lightweight Cloudflare Worker that adds CORS headers to proxied requests. Chief of Staff needs this because browser security policy blocks cross-origin requests from the Roam Research SPA to Composio's MCP endpoint (which doesn't return CORS headers).
LLM API calls (Anthropic / OpenAI) use Roam's own built-in CORS proxy automatically — this worker is only needed for Composio MCP.
- A free Cloudflare account
- Node.js 18+ and npm
npm install -g wranglerwrangler loginThis opens a browser window to authorise Wrangler with your Cloudflare account.
npm installnpx wrangler deployWrangler will output the deployed URL, e.g.:
Published roam-mcp-proxy (x.xx sec)
https://roam-mcp-proxy.<your-subdomain>.workers.dev
Copy this URL — you'll need it when configuring Chief of Staff.
In Roam → Settings → Chief of Staff, set Composio MCP URL to your worker URL with the real Composio MCP endpoint appended as the path:
https://roam-mcp-proxy.<your-subdomain>.workers.dev/
The worker strips the leading /, forwards the request to the target URL, and adds CORS headers to the response.
For every incoming request:
- Origin check — rejects requests whose
Originheader is not an exact match for an allowlisted Roam origin (https://roamresearch.comorhttps://www.roamresearch.com). - OPTIONS (CORS preflight) — returns CORS headers with validated
Access-Control-Allow-Headers(echoes back only headers from the static allowlist plusmcp-*andx-composio-*prefixes — no wildcard*). Methods restricted toGET, POST, OPTIONS. - GET to
/tool_router/— returns204 No Content. Composio's MCP endpoint returns405for SSE probe GETs, which causes noisy browser console errors. The proxy intercepts these silently. - Target allowlist check — only proxies to allowlisted upstream hosts (Composio MCP + local dev hosts).
- Redirect hardening — upstream redirects are blocked (the worker does not follow redirects).
- CORS response headers — all responses include
Vary: Originfor correct cache behaviour, origin-specificAccess-Control-Allow-Origin(no wildcard), and validatedAccess-Control-Allow-Headers. - Everything else — forwards the request (method, allowlisted headers, body) to the target URL extracted from the path, then copies the response back with CORS headers added.
npm run devThis starts a local dev server (typically http://localhost:8787). You can point your Composio MCP URL at http://localhost:8787/ for testing.
The proxy applies multiple layers of security:
- Caller origin allowlist (exact match) — only requests from:
https://roamresearch.comhttps://www.roamresearch.com
- Upstream target allowlist — only proxies to:
mcp.composio.dev(Composio MCP hostname)backend.composio.dev(Composio streamable HTTP / tool router hostname used by some endpoints)localhost,127.0.0.1, and private IPv4 ranges (for local development/testing)
Requests to any other target host are rejected with 403 Forbidden target.
-
CORS hardening:
Vary: Originheader on all responses (correct cache behaviour when serving multiple origins)- Methods restricted to
GET, POST, OPTIONSonly (no PUT, DELETE, PATCH) Access-Control-Allow-Headersuses a validated echo approach: the browser'sAccess-Control-Request-Headersare checked against a static allowlist (accept,authorization,cache-control,content-type,last-event-id,pragma,x-api-key) plusmcp-*andx-composio-*prefix patterns. Disallowed headers are silently dropped. No wildcard*.Access-Control-Max-Age: 86400reduces preflight round-trips
-
Redirect blocking — upstream redirects are intercepted (
redirect: "manual") and return502to prevent SSRF via redirect chains -
Header filtering — only a narrow set of request headers are forwarded upstream (see "Notes on forwarded headers" below)
This means the worker is not a general-purpose CORS proxy.
If your Composio endpoint uses a different hostname (for example, a region-specific or custom domain), add it to ALLOWED_TARGET_HOSTS in src/index.js and redeploy:
const ALLOWED_TARGET_HOSTS = new Set([
"mcp.composio.dev",
"backend.composio.dev",
"my-custom-composio-host.example.com",
"localhost",
"127.0.0.1",
]);If you enable Web Page Fetching in Chief of Staff, you'll need to add "api.cloudflare.com" to ALLOWED_TARGET_HOSTS and redeploy.
To allow additional origins (e.g. a local dev server), edit the ALLOWED_ORIGINS array at the top of src/index.js:
const ALLOWED_ORIGINS = [
"https://roamresearch.com",
"https://www.roamresearch.com",
"http://localhost:3000", // local dev
];Then redeploy with npx wrangler deploy.
For additional protection, you can require a secret header. Set a Cloudflare Worker secret:
npx wrangler secret put PROXY_SECRETThen check it in the worker:
// Change export to accept env:
export default {
async fetch(request, env) {
if (request.headers.get("x-proxy-secret") !== env.PROXY_SECRET) {
return new Response("Forbidden", { status: 403 });
}
// ... rest of handler
}
};You would then need to add this header in the extension's transport fetch. This is an advanced setup and not required for basic use.
The worker forwards only a small allowlist of request headers (for example Authorization, Content-Type, Accept, MCP headers like mcp-*, and Composio headers like x-composio-*). It also rewrites the upstream Origin header to match the target URL.
This reduces accidental leakage and avoids proxy/header confusion issues.
The worker sets redirect: "manual" for upstream fetches and blocks redirects. This prevents an allowlisted hostname from redirecting the proxy to a non-allowlisted destination.
The proxy has two test suites:
All 85 tests run via vitest in the Cloudflare Workers test pool:
npx vitest runThe test suite is split into two files:
test/security.test.mjs— Pure-logic unit tests covering origin allowlist, target host allowlist, private IP detection, redirect status detection, local dev targets, CORSgetAllowedHeadersvalidated echo, and CORS response headers. These re-declare the proxy's validation functions inline.test/index.spec.js— Integration tests using@cloudflare/vitest-pool-workersto test the full workerfetch()handler with synthetic requests.
To deploy changes after editing src/index.js:
npx wrangler deployThe worker URL stays the same — no need to update Chief of Staff settings.