feat(mcp): require an ETAPI token on /mcp

The MCP endpoint was unauthenticated, relying on a loopback source-IP check
plus the SDK's DNS-rebinding Host allow-list. That had three problems:

- Loopback is not a credential. Any local process (a package postinstall
  script, another user on a shared box) could read and rewrite every note.
- The Host allow-list was the only thing between a malicious page and the
  database, so a single parsing bug or a proxy that rewrites Host lost
  everything.
- Because req.ip honours trust proxy, a misconfigured trustedReverseProxy
  could forge X-Forwarded-For: 127.0.0.1 and walk straight in.

It also made the endpoint unusable in the deployment people actually have:
under Docker, host traffic arrives from the bridge gateway rather than
127.0.0.1, so port mapping alone never satisfied the check (#10646).

Require an ETAPI token on every request instead, mirroring what the web
clipper already does on the server build. This makes the rebinding
protection redundant rather than merely inconvenient — rebinding borrows the
victim browser's network position, and a bearer token is not ambient
authority, since cross-origin JS cannot set Authorization without a CORS
preflight we never approve. The Host allow-list is therefore dropped; it
would only have blocked legitimate remote clients, whose Host is their own
domain.

Only the Authorization header authenticates, never a session cookie, which
would make /mcp CSRF-able with full tool access. The 401 deliberately omits
WWW-Authenticate: in MCP that header triggers OAuth discovery (RFC 9728) and
must point at protected-resource metadata, so advertising one would send
spec-compliant clients into a flow Trilium cannot serve.

Adds a rate limiter mirroring the login one (15 min / 10 / skip successful)
since token guessing is otherwise unbounded once reachable off-loopback.

BREAKING CHANGE: existing MCP clients must send an ETAPI token. MCP is off
by default, so this affects only users who explicitly enabled it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Elian Doran
2026-07-25 20:35:54 +03:00
co-authored by Claude Opus 5
parent 4e54f3d92d
commit 829c7dba47
7 changed files with 163 additions and 71 deletions
+4 -1
View File
@@ -2,7 +2,10 @@
"mcpServers": {
"trilium": {
"type": "http",
"url": "http://localhost:8080/mcp"
"url": "http://localhost:8080/mcp",
"headers": {
"Authorization": "Bearer ${TRILIUM_ETAPI_TOKEN}"
}
}
}
}
+1
View File
@@ -341,6 +341,7 @@ Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/`
## MCP Server
- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json`
- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`)
- It requires an ETAPI token on every request — create one in Options → ETAPI and export it as `TRILIUM_ETAPI_TOKEN` before starting Claude Code (`.mcp.json` reads that variable). Without it the endpoint answers `401`
- It provides tools for reading, searching, and modifying notes directly from the AI assistant
- Use it to interact with actual note data when developing or debugging note-related features
+3 -2
View File
@@ -97,8 +97,9 @@ export default async function buildApp() {
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
// MCP is registered before session/auth middleware — it uses its own
// localhost-only guard and does not require Trilium authentication.
// MCP is registered before session/auth middleware — it authenticates with its own
// guard (an ETAPI token, always required) and never uses the Trilium session, which
// would make the endpoint CSRF-able.
mcpRoutes.register(app);
// Desktop only: gate web access (SPA/login, /share, /api, static assets) behind
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { resolveMcpAccess } from "./mcp.js";
describe("resolveMcpAccess", () => {
it("denies everything with 403 while MCP is disabled, token or not", () => {
for (const hasValidToken of [true, false]) {
expect(resolveMcpAccess({ mcpEnabled: false, hasValidToken }))
.toEqual({ type: "deny", status: 403, error: expect.stringContaining("disabled") });
}
});
it("allows a valid token", () => {
expect(resolveMcpAccess({ mcpEnabled: true, hasValidToken: true })).toEqual({ type: "allow" });
});
it("rejects a missing or invalid token with 401 and tells the user where to get one", () => {
const decision = resolveMcpAccess({ mcpEnabled: true, hasValidToken: false });
expect(decision).toEqual({ type: "deny", status: 401, error: expect.stringContaining("ETAPI token") });
expect(decision.type === "deny" && decision.error).toContain("Options > ETAPI");
});
});
+73 -50
View File
@@ -1,61 +1,89 @@
/**
* MCP (Model Context Protocol) HTTP route handler.
*
* Mounts the Streamable HTTP transport at `/mcp` with a localhost-only guard.
* No authentication is required — access is restricted to loopback addresses,
* with DNS-rebinding protection (Host-header allow-list) layered on top so a
* rebound attacker domain can't drive the transport from the victim's browser.
* Mounts the Streamable HTTP transport at `/mcp`. Every request must carry an ETAPI token
* in the `Authorization` header — there is no unauthenticated path, on any address.
*
* See {@link resolveMcpAccess} for why that replaces the previous loopback + DNS-rebinding
* arrangement outright rather than layering on top of it.
*/
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { options as optionService } from "@triliumnext/core";
import { getLog, options as optionService } from "@triliumnext/core";
import type express from "express";
import rateLimit from "express-rate-limit";
import { getLog } from "@triliumnext/core";
import etapiTokenService from "../services/etapi_tokens.js";
import { createMcpServer } from "../services/mcp/mcp_server.js";
import port from "../services/port.js";
export function register(app: express.Application) {
// Token guessing is unbounded once the endpoint is reachable off-loopback, so cap
// failures per IP. `skipSuccessfulRequests` keeps a working client from ever counting
// against the limit — only 4xx/5xx responses do. Mirrors the login limiter.
const mcpRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
skipSuccessfulRequests: true
});
app.post("/mcp", mcpRateLimiter, mcpGuard, handleMcpRequest);
app.get("/mcp", mcpRateLimiter, mcpGuard, handleMcpRequest);
app.delete("/mcp", mcpRateLimiter, mcpGuard, handleMcpRequest);
getLog().info("MCP server registered at /mcp (ETAPI token required)");
}
export type McpAccessDecision =
| { type: "allow" }
| { type: "deny"; status: number; error: string };
/**
* Host header values a legitimate (loopback) MCP client may target. The SDK's
* DNS-rebinding protection rejects any other Host before a tool can run, so a
* rebound attacker domain pointed at 127.0.0.1 — which the browser sends as its
* own Host — cannot reach the transport even though it originates from loopback.
* Pure access decision for {@link mcpGuard}, extracted so the branching is unit-testable.
*
* Both the port-qualified form (`localhost:8080`) and the bare form (`localhost`)
* are listed: a client reaching the server on a standard port (80/443) omits the
* port from the Host header. Bare loopback names still only ever resolve to
* loopback, so allowing them does not widen the DNS-rebinding surface.
* An ETAPI token is required unconditionally — loopback is *not* a credential. Any local
* process (a package postinstall script, another user on a shared box) can reach the
* loopback listener, so the previous "no token needed from 127.0.0.1" rule handed full
* read/write access over every note to anything running on the machine. It also meant a
* misconfigured `trustedReverseProxy` could forge `X-Forwarded-For: 127.0.0.1` and walk
* straight in. This mirrors what the web clipper already does on the server build
* (see routes.ts — `clipperMiddleware`).
*
* Requiring the token also makes the SDK's DNS-rebinding protection redundant rather than
* merely inconvenient: rebinding attacks work by borrowing the victim browser's *network
* position*, and a bearer token is not ambient authority — cross-origin JS cannot attach an
* `Authorization` header without a CORS preflight we never approve. See
* {@link handleMcpRequest} for why the Host allow-list is therefore gone.
*
* Note the 401 deliberately carries no `WWW-Authenticate` header. In MCP that header is
* the OAuth-discovery trigger (RFC 9728) and must point at protected-resource metadata;
* Trilium has no authorization server, so advertising one would send spec-compliant
* clients into a discovery flow that cannot succeed.
*/
const MCP_ALLOWED_HOSTS = [
"localhost",
`localhost:${port}`,
"127.0.0.1",
`127.0.0.1:${port}`,
"[::1]",
`[::1]:${port}`
];
export function resolveMcpAccess(opts: {
mcpEnabled: boolean;
hasValidToken: boolean;
}): McpAccessDecision {
if (!opts.mcpEnabled) {
return { type: "deny", status: 403, error: "MCP server is disabled. Enable it in Options > AI / LLM." };
}
function isLoopback(addr: string | undefined): boolean {
if (!addr) return false;
// IPv6 loopback
if (addr === "::1") return true;
// IPv4 loopback (127.0.0.0/8)
if (addr.startsWith("127.")) return true;
// IPv4-mapped IPv6 loopback
if (addr.startsWith("::ffff:127.")) return true;
return false;
if (!opts.hasValidToken) {
return { type: "deny", status: 401, error: "MCP requires an ETAPI token. Create one in Options > ETAPI and send it as 'Authorization: Bearer <token>'." };
}
return { type: "allow" };
}
function mcpGuard(req: express.Request, res: express.Response, next: express.NextFunction) {
if (optionService.getOptionOrNull("mcpEnabled") !== "true") {
res.status(403).json({ error: "MCP server is disabled. Enable it in Options > AI / LLM." });
return;
}
const decision = resolveMcpAccess({
mcpEnabled: optionService.getOptionOrNull("mcpEnabled") === "true",
// Only the Authorization header authenticates — never a session cookie. A cookie would
// make `/mcp` CSRF-able by any page the logged-in user visits, with full tool access.
hasValidToken: etapiTokenService.isValidAuthHeader(req.headers.authorization)
});
// Use req.ip which respects trust proxy settings, falling back to socket address
const clientIp = req.ip || req.socket.remoteAddress;
if (!isLoopback(clientIp)) {
res.status(403).json({ error: "MCP is only available from localhost" });
if (decision.type === "deny") {
res.status(decision.status).json({ error: decision.error });
return;
}
@@ -67,8 +95,11 @@ async function handleMcpRequest(req: express.Request, res: express.Response) {
const server = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
enableDnsRebindingProtection: true,
allowedHosts: MCP_ALLOWED_HOSTS
// Off because the guard above has already required a bearer token, which the
// browser in a rebinding attack cannot supply. Keeping a Host allow-list here
// would block legitimate remote clients — whose Host is their own domain —
// without denying an attacker anything the token doesn't already deny.
enableDnsRebindingProtection: false
});
res.on("close", () => {
@@ -86,12 +117,4 @@ async function handleMcpRequest(req: express.Request, res: express.Response) {
}
}
export function register(app: express.Application) {
app.post("/mcp", mcpGuard, handleMcpRequest);
app.get("/mcp", mcpGuard, handleMcpRequest);
app.delete("/mcp", mcpGuard, handleMcpRequest);
getLog().info("MCP server registered at /mcp (localhost only)");
}
export default { register };
+56 -16
View File
@@ -4,8 +4,16 @@ import supertest from "supertest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { type ApiTestContext, bootLoggedInApp, createTextNote } from "../../spec/support/internal_api.js";
import etapiTokenService from "../services/etapi_tokens.js";
import port from "../services/port.js";
const initializeRequest = {
jsonrpc: "2.0",
method: "initialize",
id: 1,
params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "spec", version: "1" } }
};
let ctx: ApiTestContext;
let app: Application;
@@ -129,35 +137,67 @@ describe("Route transport & middleware", () => {
expect(res.body.error).toContain("disabled");
});
it("reaches the MCP transport over loopback with a valid Host once enabled", async () => {
it("rejects an untokened request with 401 even over loopback, with no OAuth challenge", async () => {
cls.init(() => optionService.setOption("mcpEnabled", "true"));
// supertest connects over loopback so the guard passes; a Host matching
// the configured port clears DNS-rebinding protection and the request
// is handed to the streamable transport (any status is fine — we only
// need the handler to execute past header validation).
// supertest dials loopback, which used to be sufficient on its own. It no longer
// is: any local process can reach the listener, so a token is always required.
const res = await supertest(app)
.post("/mcp")
.set("Host", `localhost:${port}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json, text/event-stream")
.send({ jsonrpc: "2.0", method: "initialize", id: 1, params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "spec", version: "1" } } });
expect(res.status).toBeGreaterThanOrEqual(200);
expect(JSON.stringify(res.body)).not.toContain("Invalid Host header");
.send(initializeRequest)
.expect(401);
expect(res.body.error).toContain("ETAPI token");
// A WWW-Authenticate header would send spec-compliant MCP clients into an
// OAuth discovery flow Trilium cannot serve.
expect(res.headers["www-authenticate"]).toBeUndefined();
});
it("rejects a forged Host header (DNS rebinding) with 403", async () => {
it("rejects a bogus token with 401", async () => {
cls.init(() => optionService.setOption("mcpEnabled", "true"));
// A DNS-rebinding attacker reaches the loopback listener through the
// victim's browser (so the IP guard passes) but carries an attacker-
// controlled Host. It must be rejected before any MCP tool can run.
const res = await supertest(app)
.post("/mcp")
.set("Host", "attacker.example.com")
.set("Host", `localhost:${port}`)
.set("Authorization", "Bearer not_a_real_token")
.set("Content-Type", "application/json")
.set("Accept", "application/json, text/event-stream")
.send({ jsonrpc: "2.0", method: "initialize", id: 1, params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "spec", version: "1" } } })
.expect(403);
expect(res.body?.error?.message ?? "").toContain("Invalid Host header");
.send(initializeRequest)
.expect(401);
expect(res.body.error).toContain("ETAPI token");
});
it("serves a token-authenticated caller, including on a foreign Host", async () => {
cls.init(() => optionService.setOption("mcpEnabled", "true"));
const { authToken } = cls.init(() => etapiTokenService.createToken("mcp transport spec"));
// A remote client's Host is its own domain and could never satisfy a loopback
// allow-list; the token is what authorises it, so no Host check applies.
for (const host of [`localhost:${port}`, "trilium.example.com"]) {
const res = await supertest(app)
.post("/mcp")
.set("Host", host)
.set("Authorization", `Bearer ${authToken}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json, text/event-stream")
.send(initializeRequest);
expect(res.status).toBe(200);
expect(JSON.stringify(res.body)).not.toContain("Invalid Host header");
}
});
it("accepts the ETAPI Basic-auth form as well as Bearer", async () => {
cls.init(() => optionService.setOption("mcpEnabled", "true"));
const { authToken } = cls.init(() => etapiTokenService.createToken("mcp basic spec"));
// isValidAuthHeader also parses `Basic etapi:<token>`, which some clients emit
// when given a username/password pair rather than a raw header.
const res = await supertest(app)
.post("/mcp")
.auth("etapi", authToken, { type: "basic" })
.set("Content-Type", "application/json")
.set("Accept", "application/json, text/event-stream")
.send(initializeRequest);
expect(res.status).toBe(200);
});
});
});
@@ -8,8 +8,10 @@ import { isElectron } from "./utils.js";
* Paths that stay reachable on the desktop's loopback listener even when the user
* has NOT enabled network access. These are same-machine integrations rather than
* the web app:
* - `/mcp` — the MCP transport, which additionally self-restricts to loopback with
* DNS-rebinding protection (see routes/mcp.ts), so it never reaches the LAN.
* - `/mcp` — the MCP transport, which requires an ETAPI token on every request
* (see routes/mcp.ts), exactly like `/etapi`. The loopback Host check below still
* applies on top, so on desktop a token alone does not reach the LAN until the user
* opts into network access.
* - `/api/clipper` — the web clipper endpoint the browser extension talks to.
* - `/etapi` — the token-authenticated External API used by local automation.
*