mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(web): manage MCP HTTP access from settings
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "未設定",
|
||||
|
||||
@@ -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: "미구성",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Не настроено",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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: "未配置",
|
||||
|
||||
@@ -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: "未設定",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user