Read-only MCP server (HTTP, GUI-controlled)

Expose the mailbox to an LLM client (Claude Desktop / Code) over an
in-process MCP server, so the bridge itself hosts it and the GUI controls
it live. Strictly read-only: there is no tool that sends, moves, deletes or
mutates mail — by design and asserted in tests.

Transport: Streamable HTTP (MCP 2025-06-18) on a single POST /mcp endpoint
bound to 127.0.0.1, answering each JSON-RPC request with application/json
(no SSE — the server never pushes). Auth is a bearer token (the bridge
password); the Origin header is validated to block DNS-rebinding.

Permission tiers (config.McpPermission, default Disabled = server off):
- Metadata — folders, metadata search (subject/sender/date), headers only.
- Full — the above plus full-text body search and message body text.

Tools: list_folders, search_messages, list_unread, get_message. Search
combines subject/sender always and the encrypted FTS body index under Full;
get_message returns headers always and body only under Full.

Wiring: spawned in-process by both the CLI (main.rs) and the GUI bridge
task (bridge.rs); a Disabled tier makes serve() a no-op, and it is kept out
of the select! so it never triggers teardown. GUI gains an MCP section
(tier selector, port, full-read warning, "copy client config" button) and a
get_mcp_client_config command that emits the ready-to-paste client snippet.

Validated live on a ~19k-message mailbox: initialize / tools/list /
tools/call all conform; 401 without the bearer token, 403 on a foreign
Origin, 202 on notifications; list_folders, body search and get_message
(HTML stripped to text) all return correctly. 240 unit tests.
This commit is contained in:
Anthony
2026-06-03 11:47:01 +02:00
parent db14b8fd53
commit a0601d1230
11 changed files with 833 additions and 8 deletions
+65 -1
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import type { Config, BridgeStatus } from "../types";
import { invoke } from "@tauri-apps/api/core";
import type { Config, BridgeStatus, McpPermission } from "../types";
interface Props {
config: Config | null;
@@ -16,6 +17,9 @@ export function ConfigPanel({ config, status, loading, onSave, onRestart }: Prop
const [apiUrl, setApiUrl] = useState("https://app.tuta.com");
const [syncLimit, setSyncLimit] = useState(500);
const [fetchAll, setFetchAll] = useState(false);
const [mcpPermission, setMcpPermission] = useState<McpPermission>("disabled");
const [mcpPort, setMcpPort] = useState(1944);
const [mcpCopied, setMcpCopied] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
@@ -26,6 +30,8 @@ export function ConfigPanel({ config, status, loading, onSave, onRestart }: Prop
setApiUrl(config.api_url);
setFetchAll(config.sync_limit === 0);
setSyncLimit(config.sync_limit === 0 ? 500 : config.sync_limit);
setMcpPermission(config.mcp_permission ?? "disabled");
setMcpPort(config.mcp_port ?? 1944);
}
}, [config]);
@@ -38,11 +44,24 @@ export function ConfigPanel({ config, status, loading, onSave, onRestart }: Prop
smtp_port: smtpPort,
api_url: apiUrl,
sync_limit: fetchAll ? 0 : syncLimit,
mcp_permission: mcpPermission,
mcp_port: mcpPort,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const handleCopyMcpConfig = async () => {
try {
const snippet = await invoke<string>("get_mcp_client_config");
await navigator.clipboard.writeText(snippet);
setMcpCopied(true);
setTimeout(() => setMcpCopied(false), 2000);
} catch {
/* clipboard denied — ignore */
}
};
return (
<div className="panel">
<h2>Configuration</h2>
@@ -110,6 +129,51 @@ export function ConfigPanel({ config, status, loading, onSave, onRestart }: Prop
/>
)}
</div>
<div className="form-group">
<label>AI access (MCP server)</label>
<small className="field-hint">
Lets an LLM client (Claude Desktop / Code) <strong>read</strong> this
mailbox over a local MCP server. Strictly read-only it can never
send, move or delete mail.
</small>
<select
value={mcpPermission}
onChange={(e) => setMcpPermission(e.target.value as McpPermission)}
>
<option value="disabled">Disabled (off)</option>
<option value="metadata">
Metadata only folders, search, headers (no body)
</option>
<option value="full">Full read also message bodies</option>
</select>
{mcpPermission === "full" && (
<small className="field-hint">
The connected LLM can read full message content. Body text is
untrusted a malicious email could try to mislead the model. Only
enable with a client you trust.
</small>
)}
{mcpPermission !== "disabled" && (
<>
<input
type="number"
value={mcpPort}
onChange={(e) => setMcpPort(Number(e.target.value))}
placeholder="MCP port (127.0.0.1)"
/>
<button type="button" onClick={handleCopyMcpConfig}>
{mcpCopied ? "Copied!" : "Copy client config"}
</button>
<small className="field-hint">
Save first, then paste the copied snippet into your MCP client.
The server listens on 127.0.0.1 and requires the bridge password
as a bearer token.
</small>
</>
)}
</div>
{isRunning && (
<small className="field-hint">Changes apply after a restart.</small>
)}
+7
View File
@@ -1,3 +1,6 @@
/** Read-only MCP server access tier. `disabled` = server off. */
export type McpPermission = "disabled" | "metadata" | "full";
export interface Config {
email: string;
imap_port: number;
@@ -5,6 +8,10 @@ export interface Config {
api_url: string;
/** Max mails synced per folder; 0 = fetch all. */
sync_limit: number;
/** Read-only MCP server permission tier. */
mcp_permission: McpPermission;
/** Port the read-only MCP HTTP server listens on (127.0.0.1). */
mcp_port: number;
}
export type BridgeStatus = "Stopped" | "Starting" | "Running" | { Error: string };