feat(web): manage MCP HTTP access from settings

This commit is contained in:
Hand Sonic
2026-09-26 00:29:23 +08:00
committed by GitHub
parent 705ea7464d
commit fc0d576f6d
29 changed files with 1040 additions and 159 deletions
@@ -140,6 +140,8 @@ import {
mcpHttpServerStatus,
rotateMcpHttpServerToken,
loadWebMcpHttpStatus,
saveWebMcpHttpSettings,
rotateWebMcpToken,
forgetSnippetSavedToken,
forgetWebdavSyncSecretsPassphrase,
forgetWebdavSavedPassword,
@@ -173,6 +175,7 @@ import {
type McpHttpServerSettings,
type McpHttpServerStatus,
type WebMcpHttpStatus,
type WebMcpHttpSettings,
type McpServerStatus,
type SnippetProvider,
type SnippetSyncConfig,
@@ -3303,6 +3306,11 @@ const mcpHttpSettings = ref<McpHttpServerSettings>({
});
const mcpHttpStatus = ref<McpHttpServerStatus | null>(null);
const webMcpHttpStatus = ref<WebMcpHttpStatus | null>(null);
const webMcpEnabledDraft = ref(false);
const webMcpAllowedHostsText = ref("");
const webMcpAllowedOriginsText = ref("");
const webMcpSaving = ref(false);
const webMcpError = ref("");
const mcpHttpLoading = ref(false);
const mcpHttpSaving = ref(false);
const mcpHttpError = ref("");
@@ -3808,7 +3816,12 @@ async function loadMcpHttpSettings() {
mcpHttpError.value = "";
try {
if (isWeb) {
webMcpHttpStatus.value = await loadWebMcpHttpStatus();
const status = await loadWebMcpHttpStatus();
webMcpHttpStatus.value = status;
webMcpEnabledDraft.value = status.enabled;
webMcpAllowedHostsText.value = status.allowedHosts.join("\n");
webMcpAllowedOriginsText.value = status.allowedOrigins.join("\n");
webMcpError.value = "";
return;
}
const [settings, status] = await Promise.all([loadMcpHttpServerSettings(), mcpHttpServerStatus()]);
@@ -3824,6 +3837,41 @@ async function loadMcpHttpSettings() {
}
}
async function saveWebMcpSettings() {
if (webMcpSaving.value || webMcpHttpStatus.value?.deploymentManaged) return;
webMcpSaving.value = true;
webMcpError.value = "";
const settings: WebMcpHttpSettings = {
enabled: webMcpEnabledDraft.value,
allowedHosts: mcpHttpList(webMcpAllowedHostsText.value),
allowedOrigins: mcpHttpList(webMcpAllowedOriginsText.value),
};
try {
const status = await saveWebMcpHttpSettings(settings);
webMcpHttpStatus.value = status;
webMcpEnabledDraft.value = status.enabled;
webMcpAllowedHostsText.value = status.allowedHosts.join("\n");
webMcpAllowedOriginsText.value = status.allowedOrigins.join("\n");
} catch (error: unknown) {
webMcpError.value = formatMcpHttpError(error);
} finally {
webMcpSaving.value = false;
}
}
async function rotateWebMcpAccessToken() {
if (webMcpSaving.value || webMcpHttpStatus.value?.deploymentManaged) return;
webMcpSaving.value = true;
webMcpError.value = "";
try {
webMcpHttpStatus.value = await rotateWebMcpToken();
} catch (error: unknown) {
webMcpError.value = formatMcpHttpError(error);
} finally {
webMcpSaving.value = false;
}
}
async function saveMcpHttpSettings() {
if (mcpHttpSaving.value) return;
mcpHttpSaving.value = true;
@@ -9737,25 +9785,63 @@ LIMIT 100;</pre
</TabsList>
<TabsContent value="http" class="m-0 space-y-4">
<div v-if="isWeb" class="space-y-4">
<div class="rounded-md border bg-muted/20 p-4 space-y-2">
<div class="space-y-3 border-t border-border/60 pt-3">
<div class="flex items-center justify-between gap-3">
<Label class="text-base">{{ t("settings.mcpHttpWebServiceTitle") }}</Label>
<Badge :variant="webMcpHttpStatus?.enabled ? 'default' : 'outline'">{{ webMcpHttpStatus?.enabled ? t("settings.mcpHttpStatusLabelEnabled") : t("settings.mcpHttpStatusLabelDisabled") }}</Badge>
</div>
<p class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebServiceDescription") }}</p>
<p v-if="mcpHttpError" class="text-xs text-destructive">{{ mcpHttpError }}</p>
<template v-if="webMcpHttpStatus">
<code class="block rounded border bg-background px-2 py-1.5 text-xs">{{ webMcpEndpoint }}</code>
<p v-if="!webMcpHttpStatus.enabled" class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebDisabledHint") }}</p>
<code v-else class="block overflow-x-auto rounded border bg-background px-2 py-1.5 text-xs">{{ webMcpEndpoint }}</code>
<p class="text-[11px] text-muted-foreground">
{{
t("settings.mcpHttpWebTokenSourceAndHosts", {
tokenSource: webMcpHttpStatus.tokenSource || t("settings.mcpHttpNotConfigured"),
tokenSource: webMcpHttpStatus.tokenSource === "managed" ? t("settings.mcpHttpWebManagedTokenSource") : webMcpHttpStatus.tokenSource || t("settings.mcpHttpNotConfigured"),
hosts: webMcpHttpStatus.allowedHosts.join(", ") || t("settings.mcpHttpNotConfigured"),
})
}}
</p>
<p v-if="webMcpHttpStatus.allowedOrigins.length" class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpWebAllowedOrigins", { origins: webMcpHttpStatus.allowedOrigins.join(", ") }) }}</p>
<p v-if="webMcpHttpStatus.deploymentManaged" class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebDeploymentManaged") }}</p>
<p v-else-if="!webMcpHttpStatus.managementAvailable" class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebPasswordRequired") }}</p>
<template v-else>
<div class="flex items-center justify-between gap-3 border-t border-border/60 pt-3">
<Label for="web-mcp-enabled" class="text-sm">{{ t("settings.mcpHttpWebEnableLabel") }}</Label>
<Switch id="web-mcp-enabled" v-model="webMcpEnabledDraft" :disabled="webMcpSaving" />
</div>
<div class="space-y-1.5">
<Label for="web-mcp-hosts">{{ t("settings.mcpHttpAllowedHostsLabel") }}</Label>
<textarea id="web-mcp-hosts" v-model="webMcpAllowedHostsText" rows="2" :disabled="webMcpSaving" class="min-h-16 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs" placeholder="192.168.0.77:4224" />
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpHostsHint") }}</p>
</div>
<div class="space-y-1.5">
<Label for="web-mcp-origins">{{ t("settings.mcpHttpAllowedOriginsLabel") }}</Label>
<textarea id="web-mcp-origins" v-model="webMcpAllowedOriginsText" rows="2" :disabled="webMcpSaving" class="min-h-16 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs" placeholder="https://mcp-client.example.com" />
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpWebOriginsHint") }}</p>
</div>
<div v-if="webMcpHttpStatus.enabled && webMcpHttpStatus.accessToken" class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<Label>Bearer Token</Label>
<Button type="button" variant="outline" size="sm" :disabled="webMcpSaving" @click="rotateWebMcpAccessToken">{{ t("settings.mcpHttpRotateToken") }}</Button>
</div>
<div class="flex min-w-0 items-center gap-2">
<code class="min-w-0 flex-1 overflow-x-auto rounded border bg-background px-2 py-1.5 text-xs">{{ webMcpHttpStatus.accessToken }}</code>
<Button type="button" variant="outline" size="icon" :title="t('common.copy')" @click="copyMcpText('http-token', webMcpHttpStatus.accessToken || '')">
<CheckCircle2 v-if="mcpCopied === 'http-token'" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p v-if="webMcpError" class="text-xs text-destructive">{{ webMcpError }}</p>
<div class="flex flex-wrap justify-end gap-2">
<Button type="button" variant="outline" size="sm" :disabled="mcpHttpLoading || webMcpSaving" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReloadStatus") }}</Button>
<Button type="button" size="sm" :disabled="webMcpSaving || (webMcpEnabledDraft && !mcpHttpList(webMcpAllowedHostsText).length)" @click="saveWebMcpSettings">{{ t("settings.mcpHttpWebSave") }}</Button>
</div>
</template>
</template>
<Button type="button" variant="outline" size="sm" :disabled="mcpHttpLoading" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReloadStatus") }}</Button>
<Button v-if="!webMcpHttpStatus || webMcpHttpStatus.deploymentManaged" type="button" variant="outline" size="sm" :disabled="mcpHttpLoading" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReloadStatus") }}</Button>
</div>
</div>
+8 -1
View File
@@ -8111,7 +8111,14 @@ export default withEnglishFallback({
mcpTransportLocalStdio: "Yerli stdio",
mcpTransportHttpService: "HTTP xidməti",
mcpHttpWebServiceTitle: "Veb Streamable HTTP xidməti",
mcpHttpWebServiceDescription: "Veb/Docker cari veb dinləmə portundan təkrar istifadə edir. Onu yerləşdirmə mühitinin dəyişənləri ilə aktivləşdirin; parametrlər səhifəsi tokeni heç vaxt saxlamır.",
mcpHttpWebServiceDescription: "Veb/Docker mövcud veb portundan istifadə edir. Girişi burada idarə edin və ya bu parametrləri yerləşdirmə sirləri ilə əvəz edin.",
mcpHttpWebDisabledHint: "Xidmət deaktivdir; bu ünvandan hələ istifadə etmək olmaz.",
mcpHttpWebDeploymentManaged: "Bu xidmət yerləşdirmə mühiti və ya gizli fayl ilə idarə olunur. Tokeni və icazə siyahılarını yerləşdirmə konfiqurasiyasında dəyişin.",
mcpHttpWebPasswordRequired: "Veb MCP idarəsi üçün veb giriş parolu tələb olunur və demo rejimində mövcud deyil.",
mcpHttpWebManagedTokenSource: "Veb parametrləri",
mcpHttpWebEnableLabel: "HTTP MCP-ni aktivləşdir",
mcpHttpWebOriginsHint: "Yalnız brauzer müştəriləri üçün dəqiq Origin tələb olunur; yerli müştərilər bunu boş saxlaya bilər.",
mcpHttpWebSave: "Yadda saxla və tətbiq et",
mcpHttpStatusLabelEnabled: "Aktivdir",
mcpHttpStatusLabelDisabled: "Aktiv deyil",
mcpHttpNotConfigured: "Tənzimlənməyib",
+8 -1
View File
@@ -8791,7 +8791,14 @@ export default {
mcpTransportLocalStdio: "Local stdio",
mcpTransportHttpService: "HTTP service",
mcpHttpWebServiceTitle: "Web Streamable HTTP service",
mcpHttpWebServiceDescription: "Web/Docker reuses the current web listening port. Enable it with deployment environment variables; the settings page never stores the token.",
mcpHttpWebServiceDescription: "Web/Docker uses the existing web port. Manage access here, or use deployment secrets to override these settings.",
mcpHttpWebDisabledHint: "The service is disabled; this endpoint cannot be used yet.",
mcpHttpWebDeploymentManaged: "Deployment environment or a secret file controls this service. Change the token and allowlists in your deployment configuration.",
mcpHttpWebPasswordRequired: "Web MCP management requires a configured Web login password and is unavailable in demo mode.",
mcpHttpWebManagedTokenSource: "Web settings",
mcpHttpWebEnableLabel: "Enable HTTP MCP",
mcpHttpWebOriginsHint: "Only browser clients need an exact Origin; native clients can leave this empty.",
mcpHttpWebSave: "Save and apply",
mcpHttpStatusLabelEnabled: "Enabled",
mcpHttpStatusLabelDisabled: "Not enabled",
mcpHttpNotConfigured: "Not configured",
+8 -1
View File
@@ -8101,7 +8101,14 @@ export default withEnglishFallback({
mcpTransportLocalStdio: "stdio local",
mcpTransportHttpService: "Servicio HTTP",
mcpHttpWebServiceTitle: "Servicio Web Streamable HTTP",
mcpHttpWebServiceDescription: "Web/Docker reutiliza el puerto de escucha web actual. Se activa con variables de entorno de despliegue; la página de ajustes nunca guarda el Token.",
mcpHttpWebServiceDescription: "Web/Docker usa el puerto web existente. Administra el acceso aquí o usa secretos de despliegue para sustituir estos ajustes.",
mcpHttpWebDisabledHint: "El servicio está desactivado; este punto de acceso aún no se puede usar.",
mcpHttpWebDeploymentManaged: "Este servicio se controla mediante el entorno de despliegue o un archivo secreto. Cambia el token y las listas de permitidos en la configuración del despliegue.",
mcpHttpWebPasswordRequired: "La administración de Web MCP requiere una contraseña de acceso web y no está disponible en el modo de demostración.",
mcpHttpWebManagedTokenSource: "Ajustes web",
mcpHttpWebEnableLabel: "Activar HTTP MCP",
mcpHttpWebOriginsHint: "Solo los clientes de navegador necesitan un Origin exacto; los clientes nativos pueden dejarlo vacío.",
mcpHttpWebSave: "Guardar y aplicar",
mcpHttpStatusLabelEnabled: "Activado",
mcpHttpStatusLabelDisabled: "Desactivado",
mcpHttpNotConfigured: "Sin configurar",
+8 -1
View File
@@ -8026,7 +8026,14 @@ export default withEnglishFallback({
mcpTransportLocalStdio: "stdio locale",
mcpTransportHttpService: "Servizio HTTP",
mcpHttpWebServiceTitle: "Servizio Web Streamable HTTP",
mcpHttpWebServiceDescription: "Web/Docker riusa la porta di ascolto web attuale. Viene abilitato con variabili d'ambiente di distribuzione; la pagina delle impostazioni non salva mai il Token.",
mcpHttpWebServiceDescription: "Web/Docker usa la porta web esistente. Gestisci l'accesso qui oppure usa i segreti di distribuzione per sostituire queste impostazioni.",
mcpHttpWebDisabledHint: "Il servizio è disattivato; questo endpoint non è ancora utilizzabile.",
mcpHttpWebDeploymentManaged: "Questo servizio è gestito dall'ambiente di distribuzione o da un file segreto. Modifica il token e le liste consentite nella configurazione di distribuzione.",
mcpHttpWebPasswordRequired: "La gestione di Web MCP richiede una password di accesso Web e non è disponibile in modalità demo.",
mcpHttpWebManagedTokenSource: "Impostazioni Web",
mcpHttpWebEnableLabel: "Abilita HTTP MCP",
mcpHttpWebOriginsHint: "Solo i client browser richiedono un Origin esatto; i client nativi possono lasciare vuoto questo campo.",
mcpHttpWebSave: "Salva e applica",
mcpHttpStatusLabelEnabled: "Attivo",
mcpHttpStatusLabelDisabled: "Non attivo",
mcpHttpNotConfigured: "Non configurato",
+8 -1
View File
@@ -8029,7 +8029,14 @@ export default withEnglishFallback({
mcpTransportLocalStdio: "ローカル stdio",
mcpTransportHttpService: "HTTP サービス",
mcpHttpWebServiceTitle: "Web Streamable HTTP サービス",
mcpHttpWebServiceDescription: "Web/Docker は現在の Web リスニングポートを再利用します。デプロイの環境変数で有効化され、設定ページに Token は保存されません。",
mcpHttpWebServiceDescription: "Web/Docker は既存の Web ポートを使用します。ここでアクセスを管理するか、デプロイ時のシークレットで設定を上書きできます。",
mcpHttpWebDisabledHint: "サービスは無効です。このエンドポイントはまだ使用できません。",
mcpHttpWebDeploymentManaged: "このサービスはデプロイ環境またはシークレットファイルで管理されています。Token と許可リストはデプロイ設定で変更してください。",
mcpHttpWebPasswordRequired: "Web MCP の管理には Web ログインパスワードが必要です。デモモードでは利用できません。",
mcpHttpWebManagedTokenSource: "Web 設定",
mcpHttpWebEnableLabel: "HTTP MCP を有効化",
mcpHttpWebOriginsHint: "正確な Origin が必要なのはブラウザクライアントのみです。ネイティブクライアントでは空欄にできます。",
mcpHttpWebSave: "保存して適用",
mcpHttpStatusLabelEnabled: "有効",
mcpHttpStatusLabelDisabled: "無効",
mcpHttpNotConfigured: "未設定",
+8 -1
View File
@@ -7816,7 +7816,14 @@ export default withEnglishFallback({
mcpTransportLocalStdio: "로컬 stdio",
mcpTransportHttpService: "HTTP 서비스",
mcpHttpWebServiceTitle: "Web Streamable HTTP 서비스",
mcpHttpWebServiceDescription: "Web/Docker은 현재 웹 수신 포트를 재사용합니다. 배포 환경 변수로 활성화되며 설정 페이지에는 Token이 저장되지 않습니다.",
mcpHttpWebServiceDescription: "Web/Docker는 기존 웹 포트를 사용합니다. 여기서 접근을 관리하거나 배포 시크릿으로 이 설정을 덮어쓸 수 있습니다.",
mcpHttpWebDisabledHint: "서비스가 비활성화되어 이 엔드포인트를 아직 사용할 수 없습니다.",
mcpHttpWebDeploymentManaged: "이 서비스는 배포 환경 또는 시크릿 파일로 관리됩니다. 토큰과 허용 목록은 배포 설정에서 변경하세요.",
mcpHttpWebPasswordRequired: "Web MCP 관리에는 웹 로그인 비밀번호가 필요하며 데모 모드에서는 사용할 수 없습니다.",
mcpHttpWebManagedTokenSource: "웹 설정",
mcpHttpWebEnableLabel: "HTTP MCP 활성화",
mcpHttpWebOriginsHint: "브라우저 클라이언트에만 정확한 Origin이 필요합니다. 네이티브 클라이언트는 비워 둘 수 있습니다.",
mcpHttpWebSave: "저장하고 적용",
mcpHttpStatusLabelEnabled: "활성화됨",
mcpHttpStatusLabelDisabled: "비활성화됨",
mcpHttpNotConfigured: "미구성",
+8 -1
View File
@@ -8027,7 +8027,14 @@ export default withEnglishFallback({
mcpTransportLocalStdio: "stdio local",
mcpTransportHttpService: "Serviço HTTP",
mcpHttpWebServiceTitle: "Serviço Web Streamable HTTP",
mcpHttpWebServiceDescription: "O Web/Docker reutiliza a porta de escuta web atual. É ativado por variáveis de ambiente de implantação; a página de configurações nunca salva o Token.",
mcpHttpWebServiceDescription: "O Web/Docker usa a porta web existente. Gerencie o acesso aqui ou use segredos de implantação para substituir estas configurações.",
mcpHttpWebDisabledHint: "O serviço está desativado; este endpoint ainda não pode ser usado.",
mcpHttpWebDeploymentManaged: "Este serviço é controlado pelo ambiente de implantação ou por um arquivo secreto. Altere o token e as listas de permissões na configuração da implantação.",
mcpHttpWebPasswordRequired: "O gerenciamento do Web MCP exige uma senha de login web e não está disponível no modo de demonstração.",
mcpHttpWebManagedTokenSource: "Configurações web",
mcpHttpWebEnableLabel: "Ativar HTTP MCP",
mcpHttpWebOriginsHint: "Somente clientes de navegador precisam de um Origin exato; clientes nativos podem deixar este campo vazio.",
mcpHttpWebSave: "Salvar e aplicar",
mcpHttpStatusLabelEnabled: "Ativado",
mcpHttpStatusLabelDisabled: "Desativado",
mcpHttpNotConfigured: "Não configurado",
+8 -1
View File
@@ -9312,7 +9312,14 @@ export default withEnglishFallback({
mcpTransportLocalStdio: "Локальный stdio",
mcpTransportHttpService: "Служба HTTP",
mcpHttpWebServiceTitle: "Веб-служба Streamable HTTP",
mcpHttpWebServiceDescription: "Web/Docker повторно использует текущий порт веб-прослушивания. Включите её переменными среды развёртывания; страница настроек никогда не хранит токен.",
mcpHttpWebServiceDescription: "Web/Docker использует существующий веб-порт. Управляйте доступом здесь или переопределите настройки секретами развёртывания.",
mcpHttpWebDisabledHint: "Служба отключена; этот адрес пока недоступен.",
mcpHttpWebDeploymentManaged: "Эта служба управляется средой развёртывания или файлом с секретом. Изменяйте токен и списки разрешений в конфигурации развёртывания.",
mcpHttpWebPasswordRequired: "Для управления Web MCP требуется пароль входа в Web; в демонстрационном режиме функция недоступна.",
mcpHttpWebManagedTokenSource: "Настройки Web",
mcpHttpWebEnableLabel: "Включить HTTP MCP",
mcpHttpWebOriginsHint: "Точный Origin нужен только браузерным клиентам; для нативных клиентов поле можно оставить пустым.",
mcpHttpWebSave: "Сохранить и применить",
mcpHttpStatusLabelEnabled: "Включено",
mcpHttpStatusLabelDisabled: "Не включено",
mcpHttpNotConfigured: "Не настроено",
+8 -1
View File
@@ -8009,7 +8009,14 @@ export default withEnglishFallback({
mcpTransportLocalStdio: "Yerel stdio",
mcpTransportHttpService: "HTTP servisi",
mcpHttpWebServiceTitle: "Web Streamable HTTP servisi",
mcpHttpWebServiceDescription: "Web/Docker, geçerli web dinleme portunu yeniden kullanır. Dağıtım ortam değişkenleriyle etkinleştirin; ayarlar sayfası belirteci hiçbir zaman saklamaz.",
mcpHttpWebServiceDescription: "Web/Docker mevcut web portunu kullanır. Erişimi buradan yönetin veya dağıtım gizli bilgileriyle bu ayarları geçersiz kılın.",
mcpHttpWebDisabledHint: "Hizmet devre dışı; bu uç nokta henüz kullanılamaz.",
mcpHttpWebDeploymentManaged: "Bu hizmet dağıtım ortamı veya gizli dosya tarafından yönetiliyor. Belirteci ve izin listelerini dağıtım yapılandırmasında değiştirin.",
mcpHttpWebPasswordRequired: "Web MCP yönetimi için bir Web giriş parolası gerekir ve demo modunda kullanılamaz.",
mcpHttpWebManagedTokenSource: "Web ayarları",
mcpHttpWebEnableLabel: "HTTP MCP'yi etkinleştir",
mcpHttpWebOriginsHint: "Yalnızca tarayıcı istemcileri tam bir Origin gerektirir; yerel istemciler bu alanı boş bırakabilir.",
mcpHttpWebSave: "Kaydet ve uygula",
mcpHttpStatusLabelEnabled: "Etkin",
mcpHttpStatusLabelDisabled: "Etkin değil",
mcpHttpNotConfigured: "Yapılandırılmadı",
+8 -1
View File
@@ -8764,7 +8764,14 @@ export default withEnglishFallback({
mcpTransportLocalStdio: "本地 stdio",
mcpTransportHttpService: "HTTP 服务",
mcpHttpWebServiceTitle: "Web Streamable HTTP 服务",
mcpHttpWebServiceDescription: "Web/Docker 复用当前 Web 监听端口。通过部署环境变量启用,设置页不会保存 Token。",
mcpHttpWebServiceDescription: "Web/Docker 复用当前 Web 监听端口。可在此管理接入,部署环境变量配置优先。",
mcpHttpWebDisabledHint: "服务尚未启用,当前地址不可连接。",
mcpHttpWebDeploymentManaged: "此服务由部署环境变量或 Secret 文件管理;请在部署配置中修改 Token 和白名单。",
mcpHttpWebPasswordRequired: "页面管理 MCP 需要先设置 Web 登录密码,演示模式不可用。",
mcpHttpWebManagedTokenSource: "Web 设置",
mcpHttpWebEnableLabel: "启用 HTTP MCP",
mcpHttpWebOriginsHint: "仅浏览器客户端需要,填写完整 Origin;原生客户端可留空。",
mcpHttpWebSave: "保存并应用",
mcpHttpStatusLabelEnabled: "已启用",
mcpHttpStatusLabelDisabled: "未启用",
mcpHttpNotConfigured: "未配置",
+8 -1
View File
@@ -7395,7 +7395,14 @@ export default withEnglishFallback({
mcpTransportLocalStdio: "本機 stdio",
mcpTransportHttpService: "HTTP 服務",
mcpHttpWebServiceTitle: "Web Streamable HTTP 服務",
mcpHttpWebServiceDescription: "Web/Docker 重用目前 Web 監聽埠。透過部署環境變數啟用,設定頁不會儲存 Token。",
mcpHttpWebServiceDescription: "Web/Docker 重用目前 Web 監聽埠。可在此管理存取,部署環境變數設定優先。",
mcpHttpWebDisabledHint: "服務尚未啟用,目前位址無法連線。",
mcpHttpWebDeploymentManaged: "此服務由部署環境變數或 Secret 檔案管理;請在部署設定中修改 Token 與白名單。",
mcpHttpWebPasswordRequired: "頁面管理 MCP 需要先設定 Web 登入密碼,展示模式不可用。",
mcpHttpWebManagedTokenSource: "Web 設定",
mcpHttpWebEnableLabel: "啟用 HTTP MCP",
mcpHttpWebOriginsHint: "僅瀏覽器用戶端需要填寫完整 Origin;原生用戶端可留空。",
mcpHttpWebSave: "儲存並套用",
mcpHttpStatusLabelEnabled: "已啟用",
mcpHttpStatusLabelDisabled: "未啟用",
mcpHttpNotConfigured: "未設定",
+3
View File
@@ -930,6 +930,8 @@ export const saveMcpHttpServerSettings = forward("saveMcpHttpServerSettings");
export const mcpHttpServerStatus = forward("mcpHttpServerStatus");
export const rotateMcpHttpServerToken = forward("rotateMcpHttpServerToken");
export const loadWebMcpHttpStatus = forward("loadWebMcpHttpStatus");
export const saveWebMcpHttpSettings = forward("saveWebMcpHttpSettings");
export const rotateWebMcpToken = forward("rotateWebMcpToken");
export const checkForUpdates = forward("checkForUpdates");
export const fetchChangelog = forward("fetchChangelog");
export const getSystemProxyUrl = forward("getSystemProxyUrl");
@@ -999,6 +1001,7 @@ export type {
McpHttpServerSettings,
McpHttpServerStatus,
WebMcpHttpStatus,
WebMcpHttpSettings,
UpdateInfo,
DownloadedUpdate,
RedisBlob,
+16
View File
@@ -2217,6 +2217,22 @@ export async function loadWebMcpHttpStatus(): Promise<import("@/lib/backend/taur
return get("/api/app-settings/mcp-http-status");
}
export async function saveWebMcpHttpSettings(settings: import("@/lib/backend/tauri").WebMcpHttpSettings): Promise<import("@/lib/backend/tauri").WebMcpHttpStatus> {
const res = await fetch(apiUrl("/api/app-settings/mcp-http"), {
method: "PUT",
headers: { "Content-Type": "application/json", "X-DBX-MCP-Settings": "1" },
body: JSON.stringify(settings),
});
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
export async function rotateWebMcpToken(): Promise<import("@/lib/backend/tauri").WebMcpHttpStatus> {
const res = await fetch(apiUrl("/api/app-settings/mcp-http/rotate-token"), { method: "POST", headers: { "X-DBX-MCP-Settings": "1" } });
if (!res.ok) throw await backendResponseError(res);
return res.json();
}
export async function loadMaxAgentTurns(): Promise<number> {
return get("/api/app-settings/max-agent-turns");
}
+19 -2
View File
@@ -816,7 +816,16 @@ export interface McpHttpServerStatus {
export interface WebMcpHttpStatus {
enabled: boolean;
endpointPath: string;
tokenSource: "environment" | "file" | null;
tokenSource: "environment" | "file" | "managed" | null;
allowedHosts: string[];
allowedOrigins: string[];
deploymentManaged: boolean;
managementAvailable: boolean;
accessToken: string | null;
}
export interface WebMcpHttpSettings {
enabled: boolean;
allowedHosts: string[];
allowedOrigins: string[];
}
@@ -838,7 +847,15 @@ export async function rotateMcpHttpServerToken(): Promise<McpHttpServerStatus> {
}
export async function loadWebMcpHttpStatus(): Promise<WebMcpHttpStatus> {
return { enabled: false, endpointPath: "/mcp", tokenSource: null, allowedHosts: [], allowedOrigins: [] };
return { enabled: false, endpointPath: "/mcp", tokenSource: null, allowedHosts: [], allowedOrigins: [], deploymentManaged: false, managementAvailable: false, accessToken: null };
}
export async function saveWebMcpHttpSettings(_settings: WebMcpHttpSettings): Promise<WebMcpHttpStatus> {
throw new Error("Web MCP settings are available only in DBX Web");
}
export async function rotateWebMcpToken(): Promise<WebMcpHttpStatus> {
throw new Error("Web MCP settings are available only in DBX Web");
}
export async function loadMaxAgentTurns(): Promise<number> {
@@ -53,6 +53,7 @@ const APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY: &str = "saved_sql_editor_positio
const APP_STATE_TRANSFER_TASK_LIBRARY_KEY: &str = "transfer_task_library";
const MCP_GLOBAL_POLICY_KEY: &str = "mcp_global_policy";
const MCP_HTTP_SERVER_SETTINGS_KEY: &str = "mcp_http_server_settings";
const WEB_MCP_SETTINGS_KEY: &str = "web_mcp_settings";
const MAX_RETRIES_KEY: &str = "max_retries";
const HISTORY_RETENTION_LIMIT_KEY: &str = "history_retention_limit";
const SQL_FILE_UPLOAD_MAX_MB_KEY: &str = "sql_file_upload_max_mb";
@@ -537,6 +538,20 @@ pub struct McpHttpServerSettings {
pub allowed_origins: Vec<String>,
}
/// Configuration for the optional DBX Web MCP endpoint. The bearer token is
/// deliberately kept out of this JSON and stored through the encrypted secret
/// store instead.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebMcpSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed_hosts: Vec<String>,
#[serde(default)]
pub allowed_origins: Vec<String>,
}
impl Default for McpHttpServerSettings {
fn default() -> Self {
Self {
@@ -4020,6 +4035,7 @@ impl Storage {
.map_err(|e| e.to_string())?;
let dedicated_keys = [
MCP_GLOBAL_POLICY_KEY,
WEB_MCP_SETTINGS_KEY,
MAX_RETRIES_KEY,
SQL_FILE_UPLOAD_MAX_MB_KEY,
HISTORY_RETENTION_LIMIT_KEY,
@@ -4246,6 +4262,32 @@ impl Storage {
self.save_app_settings_json(&app_settings).await
}
pub async fn load_web_mcp_settings(&self) -> Result<WebMcpSettings, String> {
let settings = self.load_app_settings_json().await?;
match settings.get(WEB_MCP_SETTINGS_KEY) {
Some(value) => {
serde_json::from_value(value.clone()).map_err(|error| format!("invalid Web MCP settings: {error}"))
}
None => Ok(WebMcpSettings::default()),
}
}
pub async fn save_web_mcp_credentials(&self, settings: &WebMcpSettings, token: Option<&str>) -> Result<(), String> {
let token = token.unwrap_or_default().to_string();
let codec = self.secret_codec_for_write(!token.is_empty()).await?;
let value = serde_json::to_value(settings).map_err(|error| error.to_string())?;
self.with_conn(move |conn| {
let tx =
conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(|error| error.to_string())?;
let mut app_settings = app_settings_map_from_conn(&tx)?;
app_settings.insert(WEB_MCP_SETTINGS_KEY.to_string(), value);
persist_secret_in_tx(&tx, &codec, GLOBAL_SECRET_NAMESPACE, "web_mcp_token", &token)?;
write_app_settings_map(&tx, &app_settings)?;
tx.commit().map_err(|error| error.to_string())
})
.await
}
pub async fn save_desktop_settings(&self, desktop_settings: &DesktopSettings) -> Result<(), String> {
let mut settings = self.load_app_settings_json().await?;
settings.remove("run_in_background");
+16 -7
View File
@@ -27,7 +27,7 @@ pub fn streamable_http_router(
auth: HttpAuth,
allowed_hosts: Vec<String>,
web_mode: bool,
) -> Router {
) -> Result<Router, String> {
build_streamable_http_router(backend, path, auth, allowed_hosts, web_mode, None, Default::default())
}
@@ -39,8 +39,15 @@ fn build_streamable_http_router(
web_mode: bool,
cancellation: Option<CancellationToken>,
session_manager: Arc<LocalSessionManager>,
) -> Router {
let mut rmcp_config = StreamableHttpServerConfig::default().with_allowed_hosts(allowed_hosts);
) -> Result<Router, String> {
auth.set_allowed_hosts(allowed_hosts.clone())?;
// Web settings update the shared policy without rebuilding the router.
// Keep rmcp's existing checks for the standalone server.
let mut rmcp_config = if web_mode {
StreamableHttpServerConfig::default().disable_allowed_hosts().disable_allowed_origins()
} else {
StreamableHttpServerConfig::default().with_allowed_hosts(allowed_hosts)
};
if let Some(cancellation) = cancellation {
rmcp_config = rmcp_config.with_cancellation_token(cancellation);
}
@@ -59,14 +66,14 @@ fn build_streamable_http_router(
let cors_auth = auth.clone();
let router =
Router::new().nest_service(path, service).layer(middleware::from_fn_with_state(auth, authorize_request));
router.layer(
Ok(router.layer(
CorsLayer::new()
.allow_origin(AllowOrigin::predicate(move |origin, _| {
origin.to_str().is_ok_and(|origin| cors_auth.origin_is_allowed(origin))
}))
.allow_methods([Method::GET, Method::POST, Method::DELETE])
.allow_headers(Any),
)
))
}
/// Serves one stateful rmcp Streamable HTTP endpoint. Every MCP protocol
@@ -111,7 +118,8 @@ pub async fn serve_streamable_http_on_listener(
false,
Some(cancellation.child_token()),
session_manager.clone(),
);
)
.map_err(io::Error::other)?;
let router = Router::new().route("/healthz", get(health)).route("/readyz", get(health)).merge(mcp_router);
eprintln!("DBX MCP Streamable HTTP listening on http://{}{}", config.bind_addr, config.path);
@@ -322,7 +330,8 @@ mod tests {
false,
Some(cancellation.child_token()),
manager.clone(),
);
)
.unwrap();
let shutdown = cancellation.clone();
let shutdown_manager = manager.clone();
let task = tokio::spawn(async move {
+237 -48
View File
@@ -1,8 +1,11 @@
use std::{collections::HashSet, sync::Arc};
use std::{
collections::HashSet,
sync::{Arc, RwLock},
};
use axum::{
extract::{Request, State},
http::{header, HeaderValue, StatusCode},
http::{header, HeaderValue, StatusCode, Uri},
middleware::Next,
response::{IntoResponse, Response},
};
@@ -12,79 +15,171 @@ use url::Url;
/// The token is intentionally not `Debug` and is never exposed by diagnostics.
#[derive(Clone)]
pub struct HttpAuth {
token: Arc<[u8]>,
config: Arc<RwLock<HttpAuthConfig>>,
}
#[derive(Clone)]
struct HttpAuthConfig {
token: Option<Arc<[u8]>>,
allowed_hosts: Vec<HostRule>,
allowed_origins: HashSet<String>,
allow_loopback_origins: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct HostRule {
host: String,
port: Option<u16>,
}
impl HttpAuth {
pub fn new(
token: String,
allowed_origins: impl IntoIterator<Item = String>,
allow_loopback_origins: bool,
) -> Result<Self, String> {
if token.trim().is_empty() || token.contains(char::is_whitespace) {
return Err("MCP HTTP bearer token must be non-empty and contain no whitespace".into());
}
let mut normalized_origins = HashSet::new();
for origin in allowed_origins {
normalized_origins.insert(normalize_origin(&origin)?);
}
Ok(Self { token: Arc::from(token.into_bytes()), allowed_origins: normalized_origins, allow_loopback_origins })
Self::new_with_hosts(Some(token), Vec::<String>::new(), allowed_origins, allow_loopback_origins)
}
fn token_matches(&self, candidate: &str) -> bool {
let candidate = candidate.as_bytes();
if candidate.len() != self.token.len() {
return false;
}
pub fn new_with_hosts(
token: Option<String>,
allowed_hosts: impl IntoIterator<Item = String>,
allowed_origins: impl IntoIterator<Item = String>,
allow_loopback_origins: bool,
) -> Result<Self, String> {
let config = HttpAuthConfig {
token: validate_token(token)?.map(|token| Arc::from(token.into_bytes())),
allowed_hosts: normalize_hosts(allowed_hosts)?,
allowed_origins: normalize_origins(allowed_origins)?,
allow_loopback_origins,
};
Ok(Self { config: Arc::new(RwLock::new(config)) })
}
// Keep comparison work independent of the first mismatching byte.
let difference = self
.token
.iter()
.zip(candidate)
.fold(0_u8, |difference, (expected, actual)| difference | (expected ^ actual));
difference == 0
/// Replaces the live bearer token and request-origin/host policy. The
/// shared lock lets embedded Web MCP rotate credentials without rebuilding
/// the Axum router or dropping existing application state.
pub fn reconfigure(
&self,
token: Option<String>,
allowed_hosts: impl IntoIterator<Item = String>,
allowed_origins: impl IntoIterator<Item = String>,
) -> Result<(), String> {
let next = HttpAuthConfig {
token: validate_token(token)?.map(|token| Arc::from(token.into_bytes())),
allowed_hosts: normalize_hosts(allowed_hosts)?,
allowed_origins: normalize_origins(allowed_origins)?,
allow_loopback_origins: self
.config
.read()
.unwrap_or_else(|error| error.into_inner())
.allow_loopback_origins,
};
*self.config.write().unwrap_or_else(|error| error.into_inner()) = next;
Ok(())
}
pub fn set_allowed_hosts(&self, allowed_hosts: impl IntoIterator<Item = String>) -> Result<(), String> {
let allowed_hosts = normalize_hosts(allowed_hosts)?;
self.config.write().unwrap_or_else(|error| error.into_inner()).allowed_hosts = allowed_hosts;
Ok(())
}
pub fn enabled(&self) -> bool {
self.config.read().unwrap_or_else(|error| error.into_inner()).token.is_some()
}
#[cfg(test)]
fn token_matches(&self, candidate: &str) -> bool {
let config = self.config.read().unwrap_or_else(|error| error.into_inner());
token_matches(&config, candidate)
}
pub fn origin_is_allowed(&self, origin: &str) -> bool {
let Ok(origin) = normalize_origin(origin) else {
return false;
};
if self.allowed_origins.contains(&origin) {
return true;
}
self.allow_loopback_origins && origin_is_loopback(&origin)
let config = self.config.read().unwrap_or_else(|error| error.into_inner());
origin_is_allowed(&config, origin)
}
#[cfg(test)]
fn host_is_allowed(&self, uri: &Uri, headers: &axum::http::HeaderMap) -> bool {
let config = self.config.read().unwrap_or_else(|error| error.into_inner());
host_is_allowed(&config, uri, headers)
}
}
fn token_matches(config: &HttpAuthConfig, candidate: &str) -> bool {
let Some(token) = config.token.as_ref() else {
return false;
};
let candidate = candidate.as_bytes();
if candidate.len() != token.len() {
return false;
}
// Keep comparison work independent of the first mismatching byte.
let difference =
token.iter().zip(candidate).fold(0_u8, |difference, (expected, actual)| difference | (expected ^ actual));
difference == 0
}
fn origin_is_allowed(config: &HttpAuthConfig, origin: &str) -> bool {
let Ok(origin) = normalize_origin(origin) else {
return false;
};
config.allowed_origins.contains(&origin) || (config.allow_loopback_origins && origin_is_loopback(&origin))
}
fn host_is_allowed(config: &HttpAuthConfig, uri: &Uri, headers: &axum::http::HeaderMap) -> bool {
let authority = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.or_else(|| uri.authority().map(|authority| authority.as_str()));
let Some(authority) = authority.and_then(|value| parse_host_rule(value).ok()) else {
return false;
};
config.allowed_hosts.is_empty()
|| config.allowed_hosts.iter().any(|allowed| {
allowed.host == authority.host && allowed.port.is_none_or(|port| authority.port == Some(port))
})
}
pub async fn authorize_request(State(auth): State<HttpAuth>, request: Request, next: Next) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
if let Some(origin) = request.headers().get(header::ORIGIN) {
let origin = match origin.to_str() {
Ok(origin) => origin,
Err(_) => {
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} reason=invalid-origin");
{
let config = auth.config.read().unwrap_or_else(|error| error.into_inner());
if config.token.is_none() {
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} reason=server-disabled");
return not_found();
}
if let Some(origin) = request.headers().get(header::ORIGIN) {
let origin = match origin.to_str() {
Ok(origin) => origin,
Err(_) => {
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} reason=invalid-origin");
return forbidden();
}
};
if !origin_is_allowed(&config, origin) {
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} origin={origin} reason=origin-not-allowed");
return forbidden();
}
};
if !auth.origin_is_allowed(origin) {
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} origin={origin} reason=origin-not-allowed");
}
if !host_is_allowed(&config, request.uri(), request.headers()) {
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} reason=host-not-allowed");
return forbidden();
}
}
let Some(token) = bearer_token(request.headers().get(header::AUTHORIZATION)) else {
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} reason=missing-bearer-token");
return unauthorized();
};
if !auth.token_matches(token) {
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} reason=invalid-bearer-token");
return unauthorized();
let Some(token) = bearer_token(request.headers().get(header::AUTHORIZATION)) else {
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} reason=missing-bearer-token");
return unauthorized();
};
if !token_matches(&config, token) {
log::warn!(target: "dbx_mcp::audit", "MCP HTTP request rejected: method={method} path={path} reason=invalid-bearer-token");
return unauthorized();
}
}
let response = next.run(request).await;
@@ -132,3 +227,97 @@ fn unauthorized() -> Response {
fn forbidden() -> Response {
(StatusCode::FORBIDDEN, "Forbidden").into_response()
}
fn not_found() -> Response {
(StatusCode::NOT_FOUND, "Not Found").into_response()
}
fn validate_token(token: Option<String>) -> Result<Option<String>, String> {
token
.map(|token| {
let token = token.trim().to_string();
if token.is_empty() || token.contains(char::is_whitespace) {
return Err("MCP HTTP bearer token must be non-empty and contain no whitespace".into());
}
Ok(token)
})
.transpose()
}
fn normalize_origins(origins: impl IntoIterator<Item = String>) -> Result<HashSet<String>, String> {
origins.into_iter().map(|origin| normalize_origin(&origin)).collect()
}
fn normalize_hosts(hosts: impl IntoIterator<Item = String>) -> Result<Vec<HostRule>, String> {
hosts.into_iter().map(|host| parse_host_rule(&host)).collect()
}
fn parse_host_rule(value: &str) -> Result<HostRule, String> {
let authority =
axum::http::uri::Authority::try_from(value.trim()).map_err(|_| format!("invalid allowed host: {value}"))?;
if value.contains('@') {
return Err(format!("invalid allowed host: {value}"));
}
let host = authority.host().to_ascii_lowercase();
if host.is_empty() {
return Err(format!("invalid allowed host: {value}"));
}
Ok(HostRule { host, port: authority.port_u16() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn live_rotation_rejects_old_token_and_updates_hosts_and_origins() {
let auth = HttpAuth::new_with_hosts(
Some("old-token".to_string()),
["dbx.example.test:4224".to_string()],
["https://client.example.test".to_string()],
false,
)
.unwrap();
let route_auth = auth.clone();
let uri: Uri = "/mcp".parse().unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert(header::HOST, HeaderValue::from_static("dbx.example.test:4224"));
assert!(route_auth.token_matches("old-token"));
assert!(route_auth.host_is_allowed(&uri, &headers));
assert!(route_auth.origin_is_allowed("https://client.example.test"));
auth.reconfigure(
Some("new-token".to_string()),
["new.example.test:443".to_string()],
["https://new-client.example.test".to_string()],
)
.unwrap();
assert!(!route_auth.token_matches("old-token"));
assert!(route_auth.token_matches("new-token"));
assert!(!route_auth.host_is_allowed(&uri, &headers));
assert!(!route_auth.origin_is_allowed("https://client.example.test"));
auth.reconfigure(None, Vec::<String>::new(), Vec::<String>::new()).unwrap();
assert!(!route_auth.enabled());
assert!(!route_auth.token_matches("new-token"));
}
#[test]
fn host_validation_requires_exact_port_when_configured() {
let auth = HttpAuth::new_with_hosts(
Some("token".to_string()),
["192.168.0.77:4224".to_string()],
Vec::<String>::new(),
false,
)
.unwrap();
let uri: Uri = "/mcp".parse().unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert(header::HOST, HeaderValue::from_static("192.168.0.77:4224"));
assert!(auth.host_is_allowed(&uri, &headers));
headers.insert(header::HOST, HeaderValue::from_static("192.168.0.77:5225"));
assert!(!auth.host_is_allowed(&uri, &headers));
assert!(parse_host_rule("https://dbx.example.test").is_err());
assert!(parse_host_rule("user@dbx.example.test:4224").is_err());
}
}
+5
View File
@@ -137,6 +137,11 @@ pub async fn setup(State(state): State<Arc<WebState>>, Json(body): Json<LoginReq
// Update in-memory state
*state.password_hash.write().await = Some(hash);
if state.migration_ready.load(std::sync::atomic::Ordering::Acquire) {
if let Err(error) = state.web_mcp.reload(&state.app.storage, true).await {
log::error!("Web MCP remained disabled after password setup: {error}");
}
}
// Auto-login: create session
let token = uuid::Uuid::new_v4().to_string();
+3
View File
@@ -53,6 +53,9 @@ const BLOCKED_EXACT: &[&str] = &[
"tunnel-profiles/save",
// 已存连接凭据解密出口
"app-settings/config/decrypt",
// 演示实例不得开放可访问全部连接的 MCP bearer token
"app-settings/mcp-http",
"app-settings/mcp-http/rotate-token",
// 读取服务器本地 ~/.ssh/config
"ssh/config-hosts",
// 插件数据访问授权写入(插件读取已存连接数据的许可)
+39 -51
View File
@@ -5,6 +5,7 @@ mod routes;
mod sse;
mod ssh_prompt;
mod state;
mod web_mcp;
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
@@ -17,20 +18,21 @@ use axum::extract::DefaultBodyLimit;
use axum::http::{Request, StatusCode, Uri};
use axum::middleware;
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{delete, get, post};
use axum::routing::{delete, get, post, put};
use axum::Router;
use dbx_core::connection::AppState;
use dbx_core::persistence::secret_codec::SecretKeyPolicy;
use dbx_core::sql_dialect::dialect_loader::{register_core_dialects, DialectPluginLoader, DialectRegistry};
use dbx_core::sql_dialect::hot_reload::DialectHotReload;
use dbx_core::storage::Storage;
use dbx_mcp::{streamable_http_router, DbxBackend, HttpAuth, LocalBackend};
use dbx_mcp::{streamable_http_router, DbxBackend, LocalBackend};
use state::WebState;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::RwLock;
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
use tower_http::compression::CompressionLayer;
use utoipa::OpenApi;
use web_mcp::WebMcpRuntime;
const XLSX_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
const DATA_GRID_EXTRACTOR_BODY_LIMIT_BYTES: usize = 96 * 1024 * 1024;
@@ -106,6 +108,17 @@ async fn migration_gate(
next.run(request).await
}
async fn web_mcp_demo_gate(
state: axum::extract::State<Arc<WebState>>,
request: Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Response {
if state.demo_mode {
return StatusCode::FORBIDDEN.into_response();
}
next.run(request).await
}
async fn storage_migration_ready(app: &Arc<AppState>) -> bool {
app.storage.inspect_data_migration().await.map(|status| status.is_ready()).unwrap_or(false)
}
@@ -271,54 +284,16 @@ fn mount_static_assets(mut app: Router, public_base_path: &str, source: Option<S
app
}
/// Builds the native Web MCP endpoint. It is intentionally opt-in: exposing a
/// token-bearing MCP server on a Web listener must never happen merely because
/// DBX Web itself was started.
fn web_mcp_router(web_state: &Arc<WebState>) -> Result<Option<Router>, String> {
let token = web_mcp_token()?;
let Some(token) = token else {
return Ok(None);
};
let allowed_hosts = comma_separated_env("DBX_WEB_MCP_ALLOWED_HOSTS");
if allowed_hosts.is_empty() {
return Err("DBX_WEB_MCP_ALLOWED_HOSTS is required when DBX Web MCP is enabled".into());
}
let allowed_origins = comma_separated_env("DBX_WEB_MCP_ALLOWED_ORIGINS");
let auth = HttpAuth::new(token, allowed_origins, false)?;
/// Builds the native Web MCP endpoint. The route remains mounted while the
/// feature is disabled so an authenticated settings action can enable it
/// without restarting the Web process; the shared auth middleware returns 404
/// until a token is configured.
fn web_mcp_router(web_state: &Arc<WebState>) -> Result<Router, String> {
let auth = web_state.web_mcp.auth();
let backend: Arc<dyn DbxBackend> =
Arc::new(LocalBackend::from_app_state(web_state.app.clone(), web_state.data_dir.clone()));
Ok(Some(streamable_http_router(backend, "/mcp", auth, allowed_hosts, true)))
}
fn web_mcp_token() -> Result<Option<String>, String> {
let inline_token = std::env::var("DBX_WEB_MCP_TOKEN").ok();
let token_file = std::env::var("DBX_WEB_MCP_TOKEN_FILE").ok();
match (inline_token, token_file) {
(Some(_), Some(_)) => Err("set only one of DBX_WEB_MCP_TOKEN or DBX_WEB_MCP_TOKEN_FILE".into()),
(Some(token), None) if !token.trim().is_empty() => Ok(Some(token)),
(Some(_), None) => Err("DBX_WEB_MCP_TOKEN must not be empty".into()),
(None, Some(path)) => std::fs::read_to_string(&path)
.map_err(|error| format!("failed to read DBX_WEB_MCP_TOKEN_FILE: {error}"))
.map(|token| token.trim_end_matches(['\r', '\n']).to_owned())
.and_then(|token| {
(!token.is_empty()).then_some(token).ok_or_else(|| "DBX_WEB_MCP_TOKEN_FILE is empty".into())
})
.map(Some),
(None, None) => Ok(None),
}
}
fn comma_separated_env(name: &str) -> Vec<String> {
std::env::var(name)
.ok()
.into_iter()
.flat_map(|value| {
value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned).collect::<Vec<_>>()
})
.collect()
streamable_http_router(backend, "/mcp", auth, web_state.web_mcp.allowed_hosts(), true)
}
#[cfg(feature = "mq-admin")]
@@ -481,6 +456,13 @@ async fn serve() {
let demo_mode = demo::demo_mode_from_env();
let migration_ready = storage_migration_ready(&app_state).await;
let web_mcp = Arc::new(if migration_ready {
WebMcpRuntime::load(&app_state.storage, !password_disabled && password_hash.is_some())
.await
.expect("Invalid DBX Web MCP configuration")
} else {
WebMcpRuntime::disabled()
});
let web_state = Arc::new(WebState {
app: app_state,
data_dir,
@@ -499,6 +481,7 @@ async fn serve() {
export_files: RwLock::new(HashMap::new()),
ssh_prompts: Arc::new(ssh_prompt::SshPromptHub::new()),
migration_ready: Arc::new(AtomicBool::new(migration_ready)),
web_mcp,
});
ssh_prompt::install_web_ssh_prompt_bridge(web_state.ssh_prompts.clone());
@@ -1310,6 +1293,8 @@ async fn serve() {
get(routes::app_settings::load_mcp_global_policy).put(routes::app_settings::save_mcp_global_policy),
)
.route("/app-settings/mcp-http-status", get(routes::app_settings::load_web_mcp_http_status))
.route("/app-settings/mcp-http", put(routes::app_settings::save_web_mcp_http_settings))
.route("/app-settings/mcp-http/rotate-token", post(routes::app_settings::rotate_web_mcp_token))
.route(
"/app-settings/max-agent-turns",
get(routes::app_settings::load_max_agent_turns).put(routes::app_settings::save_max_agent_turns),
@@ -1373,10 +1358,13 @@ async fn serve() {
.layer(CompressionLayer::new().compress_when(web_compression_predicate()))
.layer(tower_http::trace::TraceLayer::new_for_http());
if let Some(mcp_router) = web_mcp_router(&web_state).expect("Invalid DBX Web MCP configuration") {
app = app.merge(mcp_router.layer(middleware::from_fn_with_state(web_state.clone(), migration_gate)));
tracing::info!("DBX Web MCP is enabled at /mcp");
}
let mcp_router = web_mcp_router(&web_state).expect("Invalid DBX Web MCP configuration");
app = app.merge(
mcp_router
.layer(middleware::from_fn_with_state(web_state.clone(), web_mcp_demo_gate))
.layer(middleware::from_fn_with_state(web_state.clone(), migration_gate)),
);
tracing::info!("DBX Web MCP endpoint is available at /mcp when enabled");
let static_dir = std::env::var_os("DBX_STATIC_DIR").map(std::path::PathBuf::from);
// DBX_STATIC_DIR always wins (frontend development); otherwise serve the
+47 -31
View File
@@ -3,6 +3,9 @@ use std::sync::Arc;
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use axum::extract::State;
use axum::http::header;
use axum::http::HeaderMap;
use axum::response::IntoResponse;
use axum::Json;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use dbx_core::storage::{McpGlobalPolicy, McpGlobalPolicyState};
@@ -12,6 +15,7 @@ use sha2::Sha256;
use crate::error::AppError;
use crate::state::WebState;
use crate::web_mcp::{UpdateWebMcpRequest, WebMcpHttpStatus};
const CONFIG_PBKDF2_ITERATIONS: u32 = 100_000;
@@ -65,41 +69,53 @@ pub async fn save_mcp_global_policy(
Ok(Json(()))
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WebMcpHttpStatus {
pub enabled: bool,
pub endpoint_path: String,
pub token_source: Option<&'static str>,
pub allowed_hosts: Vec<String>,
pub allowed_origins: Vec<String>,
}
pub async fn load_web_mcp_http_status(State(state): State<Arc<WebState>>) -> Json<WebMcpHttpStatus> {
let token_source = match (std::env::var_os("DBX_WEB_MCP_TOKEN"), std::env::var_os("DBX_WEB_MCP_TOKEN_FILE")) {
(Some(_), None) => Some("environment"),
(None, Some(_)) => Some("file"),
_ => None,
};
pub async fn load_web_mcp_http_status(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let endpoint_path =
if state.public_base_path == "/" { "/mcp".to_string() } else { format!("{}/mcp", state.public_base_path) };
Json(WebMcpHttpStatus {
enabled: token_source.is_some(),
endpoint_path,
token_source,
allowed_hosts: comma_separated_env("DBX_WEB_MCP_ALLOWED_HOSTS"),
allowed_origins: comma_separated_env("DBX_WEB_MCP_ALLOWED_ORIGINS"),
})
let management_available =
!state.password_disabled && state.password_hash.read().await.is_some() && !state.demo_mode;
let mut status = state.web_mcp.status(endpoint_path, management_available);
if state.demo_mode {
status.enabled = false;
}
web_mcp_status_response(status)
}
fn comma_separated_env(name: &str) -> Vec<String> {
std::env::var(name)
.ok()
.into_iter()
.flat_map(|value| {
value.split(',').map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned).collect::<Vec<_>>()
})
.collect()
pub async fn save_web_mcp_http_settings(
State(state): State<Arc<WebState>>,
headers: HeaderMap,
Json(request): Json<UpdateWebMcpRequest>,
) -> Result<impl IntoResponse, AppError> {
ensure_web_mcp_management_allowed(&state, &headers).await?;
state.web_mcp.update(&state.app.storage, request).await.map_err(AppError::bad_request)?;
let endpoint_path =
if state.public_base_path == "/" { "/mcp".to_string() } else { format!("{}/mcp", state.public_base_path) };
Ok(web_mcp_status_response(state.web_mcp.status(endpoint_path, true)))
}
pub async fn rotate_web_mcp_token(
State(state): State<Arc<WebState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
ensure_web_mcp_management_allowed(&state, &headers).await?;
state.web_mcp.rotate(&state.app.storage).await.map_err(AppError::bad_request)?;
let endpoint_path =
if state.public_base_path == "/" { "/mcp".to_string() } else { format!("{}/mcp", state.public_base_path) };
Ok(web_mcp_status_response(state.web_mcp.status(endpoint_path, true)))
}
fn web_mcp_status_response(status: WebMcpHttpStatus) -> impl IntoResponse {
([(header::CACHE_CONTROL, "no-store")], Json(status))
}
async fn ensure_web_mcp_management_allowed(state: &WebState, headers: &HeaderMap) -> Result<(), AppError> {
if state.demo_mode || state.password_disabled || state.password_hash.read().await.is_none() {
return Err(AppError::forbidden("Web MCP management requires password-protected DBX Web"));
}
if headers.get("x-dbx-mcp-settings").and_then(|value| value.to_str().ok()) != Some("1") {
return Err(AppError::forbidden("Web MCP management requires a same-origin settings request"));
}
Ok(())
}
#[derive(Deserialize)]
+6
View File
@@ -19,6 +19,12 @@ pub async fn start(
Ok(report) => {
let ready =
state.app.storage.inspect_data_migration().await.map(|status| status.is_ready()).unwrap_or(false);
if ready {
let allow_managed = !state.password_disabled && state.password_hash.read().await.is_some();
if let Err(error) = state.web_mcp.reload(&state.app.storage, allow_managed).await {
log::error!("Web MCP remained disabled after data migration: {error}");
}
}
state.migration_ready.store(ready, std::sync::atomic::Ordering::Release);
Ok(Json(report))
}
+1
View File
@@ -1574,6 +1574,7 @@ mod tests {
export_files: RwLock::new(HashMap::new()),
ssh_prompts: Arc::new(crate::ssh_prompt::SshPromptHub::new()),
migration_ready: Arc::new(std::sync::atomic::AtomicBool::new(true)),
web_mcp: Arc::new(crate::web_mcp::WebMcpRuntime::disabled()),
});
(state, dir)
}
+2
View File
@@ -48,6 +48,7 @@ pub struct WebState {
pub export_files: RwLock<HashMap<String, WebExportFile>>,
pub ssh_prompts: Arc<crate::ssh_prompt::SshPromptHub>,
pub migration_ready: Arc<AtomicBool>,
pub web_mcp: Arc<crate::web_mcp::WebMcpRuntime>,
}
impl WebState {
@@ -76,6 +77,7 @@ impl WebState {
export_files: RwLock::new(HashMap::new()),
ssh_prompts: Arc::new(crate::ssh_prompt::SshPromptHub::new()),
migration_ready: Arc::new(AtomicBool::new(true)),
web_mcp: Arc::new(crate::web_mcp::WebMcpRuntime::disabled()),
}
}
}
+417
View File
@@ -0,0 +1,417 @@
use std::{env, sync::RwLock};
use dbx_core::storage::{Storage, WebMcpSettings};
use dbx_mcp::HttpAuth;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const SECRET_NAMESPACE: &str = "dbx.global";
const SECRET_KEY: &str = "web_mcp_token";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TokenSource {
Environment,
File,
Managed,
None,
}
struct DeploymentConfig {
token: String,
source: TokenSource,
allowed_hosts: Vec<String>,
allowed_origins: Vec<String>,
}
impl TokenSource {
fn as_str(self) -> Option<&'static str> {
match self {
Self::Environment => Some("environment"),
Self::File => Some("file"),
Self::Managed => Some("managed"),
Self::None => None,
}
}
}
#[derive(Clone, Debug)]
struct RuntimeState {
settings: WebMcpSettings,
source: TokenSource,
token: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WebMcpHttpStatus {
pub enabled: bool,
pub endpoint_path: String,
pub token_source: Option<&'static str>,
pub allowed_hosts: Vec<String>,
pub allowed_origins: Vec<String>,
pub deployment_managed: bool,
pub management_available: bool,
/// Only Web-managed tokens are returned to the authenticated settings UI.
/// Deployment-provided secrets are never echoed back by the API.
pub access_token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateWebMcpRequest {
pub enabled: bool,
#[serde(default)]
pub allowed_hosts: Vec<String>,
#[serde(default)]
pub allowed_origins: Vec<String>,
#[serde(default)]
pub rotate_token: bool,
}
pub struct WebMcpRuntime {
auth: HttpAuth,
state: RwLock<RuntimeState>,
operation_lock: tokio::sync::Mutex<()>,
}
impl WebMcpRuntime {
pub async fn load(storage: &Storage, allow_managed: bool) -> Result<Self, String> {
Self::load_with_deployment(storage, deployment_config()?, allow_managed).await
}
async fn load_with_deployment(
storage: &Storage,
deployment: Option<DeploymentConfig>,
allow_managed: bool,
) -> Result<Self, String> {
if deployment.is_none() && !allow_managed {
return Ok(Self::disabled());
}
let (token, source, settings) = match deployment {
Some(deployment) => {
let settings = WebMcpSettings {
enabled: true,
allowed_hosts: deployment.allowed_hosts,
allowed_origins: deployment.allowed_origins,
};
(Some(deployment.token), deployment.source, settings)
}
None => {
let managed_settings = storage.load_web_mcp_settings().await?;
let token = if managed_settings.enabled {
storage.get_secret(SECRET_NAMESPACE, SECRET_KEY).await?
} else {
None
};
let source = if managed_settings.enabled { TokenSource::Managed } else { TokenSource::None };
(token, source, managed_settings)
}
};
validate_settings(&settings, token.as_deref())?;
let auth = HttpAuth::new_with_hosts(
token.clone(),
settings.allowed_hosts.clone(),
settings.allowed_origins.clone(),
false,
)?;
Ok(Self {
auth,
state: RwLock::new(RuntimeState { settings, source, token }),
operation_lock: tokio::sync::Mutex::new(()),
})
}
pub fn disabled() -> Self {
Self {
auth: HttpAuth::new_with_hosts(None, Vec::<String>::new(), Vec::<String>::new(), false).unwrap(),
state: RwLock::new(RuntimeState {
settings: WebMcpSettings::default(),
source: TokenSource::None,
token: None,
}),
operation_lock: tokio::sync::Mutex::new(()),
}
}
pub fn auth(&self) -> HttpAuth {
self.auth.clone()
}
pub async fn reload(&self, storage: &Storage, allow_managed: bool) -> Result<(), String> {
let _guard = self.operation_lock.lock().await;
let refreshed = Self::load(storage, allow_managed).await?;
let state = refreshed.state.into_inner().unwrap_or_else(|error| error.into_inner());
self.auth.reconfigure(
state.token.clone(),
state.settings.allowed_hosts.clone(),
state.settings.allowed_origins.clone(),
)?;
*self.state.write().unwrap_or_else(|error| error.into_inner()) = state;
Ok(())
}
pub fn allowed_hosts(&self) -> Vec<String> {
self.state.read().unwrap_or_else(|error| error.into_inner()).settings.allowed_hosts.clone()
}
pub fn status(&self, endpoint_path: String, management_available: bool) -> WebMcpHttpStatus {
let state = self.state.read().unwrap_or_else(|error| error.into_inner());
let managed = state.source == TokenSource::Managed;
WebMcpHttpStatus {
enabled: state.settings.enabled && state.token.is_some(),
endpoint_path,
token_source: state.source.as_str(),
allowed_hosts: state.settings.allowed_hosts.clone(),
allowed_origins: state.settings.allowed_origins.clone(),
deployment_managed: matches!(state.source, TokenSource::Environment | TokenSource::File),
management_available,
access_token: if managed && management_available { state.token.clone() } else { None },
}
}
pub async fn update(&self, storage: &Storage, request: UpdateWebMcpRequest) -> Result<(), String> {
let _guard = self.operation_lock.lock().await;
self.update_locked(storage, request).await
}
async fn update_locked(&self, storage: &Storage, request: UpdateWebMcpRequest) -> Result<(), String> {
if matches!(
self.state.read().unwrap_or_else(|error| error.into_inner()).source,
TokenSource::Environment | TokenSource::File
) {
return Err("DBX Web MCP is managed by deployment environment configuration".to_string());
}
let settings = WebMcpSettings {
enabled: request.enabled,
allowed_hosts: normalize_list(request.allowed_hosts),
allowed_origins: normalize_list(request.allowed_origins),
};
let current_token = storage.get_secret(SECRET_NAMESPACE, SECRET_KEY).await?;
let token = if settings.enabled {
if request.rotate_token || current_token.is_none() {
Some(generate_token())
} else {
current_token
}
} else {
None
};
validate_settings(&settings, token.as_deref())?;
storage.save_web_mcp_credentials(&settings, token.as_deref()).await?;
self.auth.reconfigure(token.clone(), settings.allowed_hosts.clone(), settings.allowed_origins.clone())?;
*self.state.write().unwrap_or_else(|error| error.into_inner()) = RuntimeState {
settings,
source: if token.is_some() { TokenSource::Managed } else { TokenSource::None },
token,
};
Ok(())
}
pub async fn rotate(&self, storage: &Storage) -> Result<(), String> {
let _guard = self.operation_lock.lock().await;
let settings = self.state.read().unwrap_or_else(|error| error.into_inner()).settings.clone();
if !settings.enabled {
return Err("DBX Web MCP is not enabled".to_string());
}
self.update_locked(
storage,
UpdateWebMcpRequest {
enabled: true,
allowed_hosts: settings.allowed_hosts,
allowed_origins: settings.allowed_origins,
rotate_token: true,
},
)
.await
}
}
fn deployment_config() -> Result<Option<DeploymentConfig>, String> {
let inline = env::var("DBX_WEB_MCP_TOKEN").ok();
let file = env::var("DBX_WEB_MCP_TOKEN_FILE").ok();
let token_and_source = match (inline, file) {
(Some(_), Some(_)) => Err("set only one of DBX_WEB_MCP_TOKEN or DBX_WEB_MCP_TOKEN_FILE".to_string()),
(Some(token), None) => validate_token(token).map(|token| Some((token, TokenSource::Environment))),
(None, Some(path)) => std::fs::read_to_string(&path)
.map_err(|error| format!("failed to read DBX_WEB_MCP_TOKEN_FILE: {error}"))
.map(|token| token.trim_end_matches(['\r', '\n']).to_string())
.and_then(validate_token)
.map(|token| Some((token, TokenSource::File))),
(None, None) => Ok(None),
}?;
Ok(token_and_source.map(|(token, source)| DeploymentConfig {
token,
source,
allowed_hosts: comma_separated_env("DBX_WEB_MCP_ALLOWED_HOSTS"),
allowed_origins: comma_separated_env("DBX_WEB_MCP_ALLOWED_ORIGINS"),
}))
}
fn validate_settings(settings: &WebMcpSettings, token: Option<&str>) -> Result<(), String> {
if settings.enabled && token.is_none() {
return Err("DBX Web MCP is enabled but no token is configured".to_string());
}
if settings.enabled && settings.allowed_hosts.is_empty() {
return Err("DBX_WEB_MCP_ALLOWED_HOSTS or a Web MCP allowed Host is required".to_string());
}
HttpAuth::new_with_hosts(
token.map(ToOwned::to_owned),
settings.allowed_hosts.clone(),
settings.allowed_origins.clone(),
false,
)?;
Ok(())
}
fn validate_token(token: String) -> Result<String, String> {
if token.is_empty() || token.contains(char::is_whitespace) {
return Err("MCP HTTP bearer token must be non-empty and contain no whitespace".to_string());
}
Ok(token)
}
fn generate_token() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
fn normalize_list(values: Vec<String>) -> Vec<String> {
let mut normalized = values
.into_iter()
.flat_map(|value| value.split([',', '\n']).map(str::trim).map(ToOwned::to_owned).collect::<Vec<_>>())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
normalized.sort();
normalized.dedup();
normalized
}
fn comma_separated_env(name: &str) -> Vec<String> {
normalize_list(env::var(name).ok().into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
async fn test_storage() -> (tempfile::TempDir, Storage) {
let dir = tempfile::tempdir().unwrap();
let storage = dbx_core::persistence::test_storage::open(&dir.path().join("dbx.db")).await.unwrap();
(dir, storage)
}
fn request(enabled: bool, hosts: &[&str], rotate_token: bool) -> UpdateWebMcpRequest {
UpdateWebMcpRequest {
enabled,
allowed_hosts: hosts.iter().map(|host| host.to_string()).collect(),
allowed_origins: vec!["https://client.example.test".to_string()],
rotate_token,
}
}
#[test]
fn deployment_token_rejects_whitespace() {
assert!(validate_token(" token".to_string()).is_err());
assert!(validate_token("token ".to_string()).is_err());
assert!(validate_token("token\n".to_string()).is_err());
}
#[tokio::test]
async fn managed_token_survives_restart_and_is_encrypted() {
let (_dir, storage) = test_storage().await;
let runtime = WebMcpRuntime::load_with_deployment(&storage, None, true).await.unwrap();
assert!(!runtime.status("/mcp".to_string(), true).enabled);
runtime.update(&storage, request(true, &["dbx.example.test:4224"], false)).await.unwrap();
let status = runtime.status("/mcp".to_string(), true);
let token = status.access_token.clone().unwrap();
assert!(status.enabled);
assert_eq!(token.len(), 64);
assert_eq!(status.token_source, Some("managed"));
assert!(!serde_json::to_string(&storage.load_web_mcp_settings().await.unwrap()).unwrap().contains(&token));
let restarted = WebMcpRuntime::load_with_deployment(&storage, None, true).await.unwrap();
assert_eq!(restarted.status("/mcp".to_string(), true).access_token.as_deref(), Some(token.as_str()));
}
#[tokio::test]
async fn rotate_and_disable_update_live_auth() {
let (_dir, storage) = test_storage().await;
let runtime = WebMcpRuntime::load_with_deployment(&storage, None, true).await.unwrap();
runtime.update(&storage, request(true, &["dbx.example.test:4224"], false)).await.unwrap();
let old = runtime.status("/mcp".to_string(), true).access_token.unwrap();
runtime.rotate(&storage).await.unwrap();
let new = runtime.status("/mcp".to_string(), true).access_token.unwrap();
assert_ne!(old, new);
assert!(runtime.auth.enabled());
runtime.update(&storage, request(false, &[], false)).await.unwrap();
assert!(!runtime.auth.enabled());
assert!(!runtime.status("/mcp".to_string(), true).enabled);
assert!(storage.get_secret(SECRET_NAMESPACE, SECRET_KEY).await.unwrap().is_none());
assert!(
!WebMcpRuntime::load_with_deployment(&storage, None, true)
.await
.unwrap()
.status("/mcp".to_string(), true)
.enabled
);
}
#[tokio::test]
async fn deployment_token_overrides_managed_settings() {
let (_dir, storage) = test_storage().await;
let runtime = WebMcpRuntime::load_with_deployment(&storage, None, true).await.unwrap();
runtime.update(&storage, request(true, &["dbx.example.test:4224"], false)).await.unwrap();
let managed = runtime.status("/mcp".to_string(), true).access_token.unwrap();
let deployed = WebMcpRuntime::load_with_deployment(
&storage,
Some(DeploymentConfig {
token: "deployment-secret".to_string(),
source: TokenSource::Environment,
allowed_hosts: vec!["deployed.example.test:4224".to_string()],
allowed_origins: vec![],
}),
false,
)
.await
.unwrap();
let status = deployed.status("/mcp".to_string(), true);
assert!(status.enabled);
assert!(status.deployment_managed);
assert_eq!(status.token_source, Some("environment"));
assert_eq!(status.allowed_hosts, ["deployed.example.test:4224"]);
assert!(status.access_token.is_none());
assert!(deployed.update(&storage, request(false, &[], false)).await.is_err());
assert_eq!(storage.get_secret(SECRET_NAMESPACE, SECRET_KEY).await.unwrap().as_deref(), Some(managed.as_str()));
assert!(
!WebMcpRuntime::load_with_deployment(&storage, None, false)
.await
.unwrap()
.status("/mcp".to_string(), false)
.enabled
);
assert_eq!(
WebMcpRuntime::load_with_deployment(&storage, None, true)
.await
.unwrap()
.status("/mcp".to_string(), true)
.access_token
.as_deref(),
Some(managed.as_str())
);
}
#[tokio::test]
async fn invalid_host_or_origin_does_not_enable_mcp() {
let (_dir, storage) = test_storage().await;
let runtime = WebMcpRuntime::load_with_deployment(&storage, None, true).await.unwrap();
assert!(runtime.update(&storage, request(true, &["https://dbx.example.test"], false)).await.is_err());
assert!(!runtime.auth.enabled());
assert!(storage.get_secret(SECRET_NAMESPACE, SECRET_KEY).await.unwrap().is_none());
}
}
+3 -1
View File
@@ -304,7 +304,9 @@ DBX Web 和 Docker 同样需要独立 DuckDB 驱动。首次启动后可通过 D
### DBX Web 原生 Streamable HTTP
DBX Web 也可以直接托管 MCP。只有配置 `DBX_WEB_MCP_TOKEN` 或 `DBX_WEB_MCP_TOKEN_FILE` 后才会启用。它复用现有 Web 监听器和 `/mcp` 路径,因此 Docker 和反向代理部署不需要额外暴露第二个端口。将 `DBX_WEB_MCP_ALLOWED_HOSTS` 配置为客户端在 `Host` 请求头中发送的公网访问地址;如有端口映射,必须包含映射后的端口。生产环境请使用 `DBX_WEB_MCP_TOKEN_FILE` 或部署平台的 Secret 管理能力,不要把真实 Token 提交到 Compose 文件中。
DBX Web 可以直接托管 MCP,复用现有 Web 监听器和 `/mcp` 路径,因此 Docker 和反向代理部署不需要额外暴露第二个端口。单实例、已启用 Web 登录密码的部署可在 **设置 → MCP → HTTP 服务**填写允许的 Host 并启用服务;DBX 自动生成 Bearer Token,使用现有加密 Secret 存储,可在页面复制、轮换或禁用,修改立即对新请求生效。未启用时页面不会把 `/mcp` 显示为可连接地址。无 Web 登录密码时,页面管理的 MCP 不会生效;演示部署禁用 MCP。
部署环境变量仍然优先:配置 `DBX_WEB_MCP_TOKEN` 或 `DBX_WEB_MCP_TOKEN_FILE` 后,页面只读显示状态,不会覆盖部署 Secret。此前启用的页面 Token 仍会保留;移除部署 Token 并重启 Web 后,它会重新生效。将 `DBX_WEB_MCP_ALLOWED_HOSTS` 配置为客户端在 `Host` 请求头中发送的公网访问地址;如有端口映射,必须包含映射后的端口。多实例部署应使用一致的部署 Secret 与白名单,而不是页面管理模式。生产环境请使用 `DBX_WEB_MCP_TOKEN_FILE` 或部署平台的 Secret 管理能力,不要把真实 Token 提交到 Compose 文件中。
例如容器映射为 `4225:4224` 时,MCP 地址为 `http://localhost:4225/mcp`:
+3 -1
View File
@@ -304,7 +304,9 @@ DBX Web and Docker require the same standalone DuckDB driver. Install it from Dr
### Native Streamable HTTP for DBX Web
DBX Web can also host MCP itself. This mode is disabled unless `DBX_WEB_MCP_TOKEN` (or `DBX_WEB_MCP_TOKEN_FILE`) is configured. It uses the existing Web listener and the `/mcp` path, so Docker and reverse-proxy deployments do not need a second exposed port. Configure `DBX_WEB_MCP_ALLOWED_HOSTS` with the public authorities that clients send in the `Host` header, including a mapped port when applicable. Use `DBX_WEB_MCP_TOKEN_FILE` or your deployment secret manager instead of committing a real token to a Compose file.
DBX Web can also host MCP itself on its existing listener at `/mcp`, so Docker and reverse-proxy deployments do not need a second exposed port. A single-instance deployment with a Web login password can enable it in **Settings → MCP → HTTP Service** after entering the allowed Hosts. DBX generates a bearer token in its encrypted secret store; the page can copy, rotate, or disable it, with changes applying to new requests immediately. The page does not show `/mcp` as a usable endpoint while it is disabled. Page-managed MCP is inactive without a Web login password; demo deployments disable MCP entirely.
Deployment configuration takes precedence: when `DBX_WEB_MCP_TOKEN` or `DBX_WEB_MCP_TOKEN_FILE` is set, the page shows read-only status and cannot replace the deployment secret. A previously enabled page-managed token remains stored and becomes active again if the deployment token is removed and Web is restarted. Configure `DBX_WEB_MCP_ALLOWED_HOSTS` with the public authorities clients send in the `Host` header, including mapped ports. Multi-instance deployments should use a shared deployment secret and allowlist, not the page-managed mode. Use `DBX_WEB_MCP_TOKEN_FILE` or your deployment secret manager instead of committing a real token to a Compose file.
For example, a container published as `4225:4224` uses `http://localhost:4225/mcp`:
+2 -2
View File
@@ -258,7 +258,7 @@ It listens on `http://127.0.0.1:5225/mcp` by default. Configure an HTTP-capable
The default loopback address accepts only clients on the same computer. Binding to a non-loopback address requires all of the following: `DBX_MCP_HTTP_ALLOW_REMOTE=1`, the `--http-allow-remote` flag, and non-empty `DBX_MCP_HTTP_ALLOWED_HOSTS` plus `DBX_MCP_HTTP_ALLOWED_ORIGINS` allowlists. Use exact public Host authorities and browser Origins.
DBX Web can host native Streamable HTTP on its existing listener, rather than opening a second port. Enable it with `DBX_WEB_MCP_TOKEN` (or `DBX_WEB_MCP_TOKEN_FILE`) and configure the public Host allowlist. For a container published as `4225:4224`, the endpoint is `http://localhost:4225/mcp`:
DBX Web can host native Streamable HTTP on its existing listener, rather than opening a second port. On a single password-protected Web instance, enable it in **Settings → MCP → HTTP Service** with the public Host allowlist; the generated token is encrypted in DBX's secret store and can be rotated there. For multi-instance or deployment-managed setups, set `DBX_WEB_MCP_TOKEN` (or `DBX_WEB_MCP_TOKEN_FILE`) and `DBX_WEB_MCP_ALLOWED_HOSTS` in the deployment. Deployment secrets take precedence and make the page read-only. For a container published as `4225:4224`, the endpoint is `http://localhost:4225/mcp`:
```yaml
environment:
@@ -634,7 +634,7 @@ DBX_MCP_HTTP_TOKEN=replace-with-a-long-random-secret dbx-mcp-server --http
默认回环地址仅允许同一台电脑上的客户端访问。监听非回环地址时,必须同时设置 `DBX_MCP_HTTP_ALLOW_REMOTE=1`、传入 `--http-allow-remote`,并提供非空的 `DBX_MCP_HTTP_ALLOWED_HOSTS` 和 `DBX_MCP_HTTP_ALLOWED_ORIGINS` 白名单。Host authority 与浏览器 Origin 均应使用精确的公网值。
DBX Web 可以通过现有 Web 监听器提供原生 Streamable HTTP,无需额外开放第二个端口。设置 `DBX_WEB_MCP_TOKEN`(或 `DBX_WEB_MCP_TOKEN_FILE`)并配置公网 Host 白名单即可启用。容器映射为 `4225:4224` 时,端点为 `http://localhost:4225/mcp`:
DBX Web 可以通过现有 Web 监听器提供原生 Streamable HTTP,无需额外开放第二个端口。单实例且启用 Web 登录密码时,可在 **设置 → MCP → HTTP 服务**配置公网 Host 白名单并启用;生成的 Token 存在加密 Secret 存储中,可在页面轮换。多实例或部署管理场景仍使用 `DBX_WEB_MCP_TOKEN`(或 `DBX_WEB_MCP_TOKEN_FILE`)和 `DBX_WEB_MCP_ALLOWED_HOSTS`;部署 Secret 优先,页面只读。容器映射为 `4225:4224` 时,端点为 `http://localhost:4225/mcp`:
```yaml
environment: