Prechádzať zdrojové kódy

add "MCP Server" as destination

Gildas 2 mesiacov pred
rodič
commit
012bd280de

+ 12 - 0
_locales/az/messages.json

@@ -723,6 +723,18 @@
 		"message": "şifrə",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "MCP serverinə yaddaşa qeyd edin",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "server URL",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "avtorizasiya tokeni",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "SingleFile Companion ilə yaddaşa qeyd edin",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/de/messages.json

@@ -723,6 +723,18 @@
 		"message": "Passwort",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "Hochladen auf einen MCP-Server",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "Server-URL",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "Authentifizierungstoken",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "Speichern mit SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/en/messages.json

@@ -723,6 +723,18 @@
 		"message": "password",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "upload to an MCP server",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "server URL",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "authentication token",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "save with SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/es/messages.json

@@ -723,6 +723,18 @@
 		"message": "contraseña",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "subir a un servidor MCP",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "URL del servidor",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "token de autenticación",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "guardar la página con SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/fr/messages.json

@@ -723,6 +723,18 @@
 		"message": "mot de passe",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "téléverser sur un serveur MCP",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "URL du serveur",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "jeton d'authentification",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "sauvegarder avec SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/it/messages.json

@@ -723,6 +723,18 @@
 		"message": "password",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "carica su un server MCP",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "URL del server",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "token di autenticazione",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "salva con SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/ja/messages.json

@@ -723,6 +723,18 @@
 		"message": "パスワード",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "MCPサーバーにアップロードする",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "サーバーURL",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "認証トークン",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "SingleFile Companionで保存する",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/nl_NL/messages.json

@@ -723,6 +723,18 @@
 		"message": "wachtwoord",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "upload naar een MCP-server",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "server-URL",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "authenticatietoken",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "opslaan met SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/pl/messages.json

@@ -723,6 +723,18 @@
 		"message": "hasło",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "upload to an MCP server",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "server URL",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "authentication token",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "zapisuj z SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/pt_PT/messages.json

@@ -723,6 +723,18 @@
 		"message": "palavra-passe",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "carregar para um servidor MCP",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "URL do servidor",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "token de autenticação",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "guardar com o SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/pt_br/messages.json

@@ -723,6 +723,18 @@
 		"message": "senha",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "enviar para um servidor MCP",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "URL do servidor",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "token de autenticação",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "salvar via SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/ru/messages.json

@@ -723,6 +723,18 @@
 		"message": "пароль",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "upload to an MCP server",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "server URL",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "authentication token",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "сохранять с помощью SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/sv/messages.json

@@ -723,6 +723,18 @@
 		"message": "lösenord",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "ladda upp till en MCP-server",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "server URL",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "autentiseringstoken",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "spara med SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/tr/messages.json

@@ -723,6 +723,18 @@
 		"message": "şifre",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "MCP sunucusuna yükle",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "sunucu URL'si",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "kimlik doğrulama belirteci",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "SingleFile Companion ile kaydet",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/uk/messages.json

@@ -723,6 +723,18 @@
 		"message": "пароль",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "завантажити на MCP-сервер",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "URL сервера",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "токен автентифікації",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "зберегти за допомогою програми SingleFile Companion",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/zh_CN/messages.json

@@ -723,6 +723,18 @@
 		"message": "密码",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "upload to an MCP server",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "server URL",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "authentication token",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "使用 SingleFile Companion 保存",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 12 - 0
_locales/zh_TW/messages.json

@@ -723,6 +723,18 @@
 		"message": "密碼",
 		"description": "Options page label: 'password'"
 	},
+	"optionSaveWithMCP": {
+		"message": "upload to an MCP server",
+		"description": "Options page label: 'upload to an MCP server'"
+	},
+	"optionMCPServerUrl": {
+		"message": "server URL",
+		"description": "Options page label: 'server URL'"
+	},
+	"optionMCPAuthToken": {
+		"message": "authentication token",
+		"description": "Options page label: 'authentication token'"
+	},
 	"optionSaveWithCompanion": {
 		"message": "使用 SingleFile Companion 保存",
 		"description": "Options page label: 'save with SingleFile Companion'"

+ 9 - 5
src/core/bg/autosave.js

@@ -159,11 +159,11 @@ async function saveContent(message, tab) {
 				if (options.passReferrerOnError) {
 					enableReferrerOnError();
 				}
-				options.tabId = tabId;
-				pageData = await getPageData(options, { fetch }, null, null);
-				let skipped;
-				if (!options.saveToGDrive && !options.saveWithWebDAV && !options.saveToGitHub && !options.saveToDropbox && !options.saveWithCompanion && !options.saveToRestFormApi && !options.saveToS3) {
-					const testSkip = await downloads.testSkipSave(pageData.filename, options);
+			options.tabId = tabId;
+			pageData = await getPageData(options, { fetch }, null, null);
+			let skipped;
+			if (!options.saveToGDrive && !options.saveWithWebDAV && !options.saveWithMCP && !options.saveToGitHub && !options.saveToDropbox && !options.saveWithCompanion && !options.saveToRestFormApi && !options.saveToS3) {
+				const testSkip = await downloads.testSkipSave(pageData.filename, options);
 					skipped = testSkip.skipped;
 					options.filenameConflictAction = testSkip.filenameConflictAction;
 				}
@@ -192,6 +192,10 @@ async function saveContent(message, tab) {
 						await downloads.saveWithWebDAV(message.taskId, downloads.encodeSharpCharacter(pageData.filename), content, options.webDAVURL, options.webDAVUser, options.webDAVPassword, {
 							filenameConflictAction: options.filenameConflictAction
 						});
+					} else if (options.saveWithMCP) {
+						await downloads.saveWithMCP(message.taskId, downloads.encodeSharpCharacter(pageData.filename), content, options.mcpServerUrl, options.mcpAuthToken, {
+							filenameConflictAction: options.filenameConflictAction
+						});
 					} else if (options.saveToGitHub) {
 						await (await downloads.saveToGitHub(message.taskId, downloads.encodeSharpCharacter(pageData.filename), content, options.githubToken, options.githubUser, options.githubRepository, options.githubBranch, {
 							filenameConflictAction: options.filenameConflictAction

+ 3 - 0
src/core/bg/config.js

@@ -116,6 +116,9 @@ const DEFAULT_CONFIG = {
 	webDAVURL: "",
 	webDAVUser: "",
 	webDAVPassword: "",
+	saveWithMCP: false,
+	mcpServerUrl: "",
+	mcpAuthToken: "",
 	saveToGitHub: false,
 	saveToRestFormApi: false,
 	saveToS3: false,

+ 20 - 1
src/core/bg/downloads.js

@@ -36,6 +36,7 @@ import { Dropbox } from "./../../lib/dropbox/dropbox.js";
 import { WebDAV } from "./../../lib/webdav/webdav.js";
 import { GitHub } from "./../../lib/github/github.js";
 import { S3 } from "./../../lib/s3/s3.js";
+import { MCP } from "./../../lib/mcp/mcp.js";
 import { download } from "./download-util.js";
 import * as yabson from "./../../lib/yabson/yabson.js";
 import { RestFormApi } from "../../lib/../lib/rest-form-api/index.js";
@@ -69,6 +70,7 @@ export {
 	saveWithWebDAV,
 	saveToRestFormApi,
 	saveToS3,
+	saveWithMCP,
 	encodeSharpCharacter
 };
 
@@ -193,6 +195,8 @@ async function downloadContent(contents, tab, incognito, message) {
 				saveToClipboard(message);
 			} else if (message.saveWithWebDAV) {
 				response = await saveWithWebDAV(message.taskId, encodeSharpCharacter(message.filename), contents.join(""), message.webDAVURL, message.webDAVUser, message.webDAVPassword, { filenameConflictAction: message.filenameConflictAction, prompt });
+			} else if (message.saveWithMCP) {
+				response = await saveWithMCP(message.taskId, encodeSharpCharacter(message.filename), contents.join(""), message.mcpServerUrl, message.mcpAuthToken, { filenameConflictAction: message.filenameConflictAction, prompt });
 			} else if (message.saveToGDrive) {
 				await saveToGDrive(message.taskId, encodeSharpCharacter(message.filename), new Blob(contents, { type: message.mimeType }), {
 					forceWebAuthFlow: message.forceWebAuthFlow
@@ -284,7 +288,7 @@ async function downloadCompressedContent(message, tab) {
 	const tabId = tab.id;
 	try {
 		let skipped;
-		if (message.backgroundSave && !message.saveToGDrive && !message.saveToDropbox && !message.saveWithWebDAV && !message.saveToGitHub && !message.saveToRestFormApi && !message.sharePage) {
+		if (message.backgroundSave && !message.saveToGDrive && !message.saveToDropbox && !message.saveWithWebDAV && !message.saveWithMCP && !message.saveToGitHub && !message.saveToRestFormApi && !message.sharePage) {
 			const testSkip = await testSkipSave(message.filename, message);
 			message.filenameConflictAction = testSkip.filenameConflictAction;
 			skipped = testSkip.skipped;
@@ -333,6 +337,8 @@ async function downloadCompressedContent(message, tab) {
 				}
 			} else if (message.saveWithWebDAV) {
 				response = await saveWithWebDAV(message.taskId, encodeSharpCharacter(message.filename), blob, message.webDAVURL, message.webDAVUser, message.webDAVPassword, { filenameConflictAction: message.filenameConflictAction, prompt });
+			} else if (message.saveWithMCP) {
+				response = await saveWithMCP(message.taskId, encodeSharpCharacter(message.filename), blob, message.mcpServerUrl, message.mcpAuthToken, { filenameConflictAction: message.filenameConflictAction, prompt });
 			} else if (message.saveToGDrive) {
 				await saveToGDrive(message.taskId, encodeSharpCharacter(message.filename), blob, {
 					forceWebAuthFlow: message.forceWebAuthFlow
@@ -496,6 +502,19 @@ async function saveWithWebDAV(taskId, filename, content, url, username, password
 	}
 }
 
+async function saveWithMCP(taskId, filename, content, serverUrl, authToken, { filenameConflictAction, prompt }) {
+	try {
+		const taskInfo = business.getTaskInfo(taskId);
+		if (!taskInfo || !taskInfo.cancelled) {
+			const client = new MCP(serverUrl, authToken);
+			business.setCancelCallback(taskId, () => client.abort());
+			return await client.upload(filename, content, { filenameConflictAction, prompt });
+		}
+	} catch (error) {
+		throw new Error(error.message + " (MCP)");
+	}
+}
+
 async function saveToGDrive(taskId, filename, blob, authOptions, uploadOptions) {
 	try {
 		await getAuthInfo(authOptions);

+ 6 - 3
src/core/common/download.js

@@ -74,6 +74,9 @@ async function downloadPage(pageData, options) {
 		webDAVURL: options.webDAVURL,
 		webDAVUser: options.webDAVUser,
 		webDAVPassword: options.webDAVPassword,
+		saveWithMCP: options.saveWithMCP,
+		mcpServerUrl: options.mcpServerUrl,
+		mcpAuthToken: options.mcpAuthToken,
 		saveToGitHub: options.saveToGitHub,
 		githubToken: options.githubToken,
 		githubUser: options.githubUser,
@@ -128,7 +131,7 @@ async function downloadPage(pageData, options) {
 		browser.runtime.sendMessage({ method: "ping" }).then(() => { });
 	}, 15000);
 	if (options.compressContent) {
-		if ((!options.backgroundSave || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveToDropbox || options.saveToRestFormApi || options.saveToS3) && options.confirmFilename && !options.openEditor) {
+		if ((!options.backgroundSave || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveWithMCP || options.saveToDropbox || options.saveToRestFormApi || options.saveToS3) && options.confirmFilename && !options.openEditor) {
 			pageData.filename = ui.prompt("Save as", pageData.filename);
 		}
 		if (pageData.filename) {
@@ -165,9 +168,9 @@ async function downloadPage(pageData, options) {
 			browser.runtime.sendMessage({ method: "ui.processCancelled" });
 		}
 	} else {
-		if ((options.backgroundSave && !options.sharePage) || options.openEditor || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveToDropbox || options.saveToRestFormApi || options.saveToS3) {
+		if ((options.backgroundSave && !options.sharePage) || options.openEditor || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveWithMCP || options.saveToDropbox || options.saveToRestFormApi || options.saveToS3) {
 			let filename = pageData.filename;
-			if ((options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveToDropbox || options.saveToRestFormApi || options.saveToS3) && options.confirmFilename && !options.openEditor) {
+			if ((options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveWithMCP || options.saveToDropbox || options.saveToRestFormApi || options.saveToS3) && options.confirmFilename && !options.openEditor) {
 				filename = ui.prompt("Save as", pageData.filename);
 			}
 			if (filename) {

+ 225 - 0
src/lib/mcp/mcp.js

@@ -0,0 +1,225 @@
+/*
+ * Copyright 2010-2025 Gildas Lormeau
+ * contact : gildas.lormeau <at> gmail.com
+ * 
+ * This file is part of SingleFile.
+ *
+ *   The code in this file is free software: you can redistribute it and/or 
+ *   modify it under the terms of the GNU Affero General Public License 
+ *   (GNU AGPL) as published by the Free Software Foundation, either version 3
+ *   of the License, or (at your option) any later version.
+ * 
+ *   The code in this file is distributed in the hope that it will be useful, 
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of 
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 
+ *   General Public License for more details.
+ *
+ *   As additional permission under GNU AGPL version 3 section 7, you may 
+ *   distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU 
+ *   AGPL normally required by section 4, provided you include this license 
+ *   notice and a URL through which recipients can access the Corresponding 
+ *   Source.
+ */
+
+/* global fetch, Blob, AbortController */
+
+const EMPTY_STRING = "";
+const CONFLICT_ACTION_SKIP = "skip";
+const CONFLICT_ACTION_UNIQUIFY = "uniquify";
+const CONFLICT_ACTION_OVERWRITE = "overwrite";
+const CONFLICT_ACTION_PROMPT = "prompt";
+const EXTENSION_SEPARATOR = ".";
+const INDEX_FILENAME_PREFIX = " (";
+const INDEX_FILENAME_SUFFIX = ")";
+const INDEX_FILENAME_REGEXP = /\s\((\d+)\)$/;
+const ABORT_ERROR_NAME = "AbortError";
+const CONTENT_TYPE_HEADER = "Content-Type";
+const JSON_CONTENT_TYPE = "application/json";
+const MCP_JSONRPC_VERSION = "2.0";
+
+export { MCP };
+
+class MCP {
+    constructor(serverUrl, authToken) {
+        this.serverUrl = serverUrl;
+        this.authToken = authToken;
+        this.requestId = 0;
+    }
+
+    async upload(path, content, options) {
+        this.controller = new AbortController();
+        options.signal = this.controller.signal;
+        options.serverUrl = this.serverUrl;
+        options.authToken = this.authToken;
+        options.getRequestId = () => ++this.requestId;
+        let textContent;
+        if (content instanceof Blob) {
+            textContent = await content.text();
+        } else {
+            textContent = content;
+        }
+
+        return upload(path, textContent, options);
+    }
+
+    abort() {
+        if (this.controller) {
+            this.controller.abort();
+        }
+    }
+}
+
+async function upload(path, content, options) {
+    const { filenameConflictAction, prompt, signal, serverUrl, authToken, getRequestId } = options;
+
+    try {
+        const existsResponse = await checkFileExists(serverUrl, authToken, path, signal, getRequestId);
+
+        if (existsResponse.exists) {
+            if (filenameConflictAction == CONFLICT_ACTION_SKIP) {
+                return { url: path, skipped: true };
+            } else if (filenameConflictAction == CONFLICT_ACTION_OVERWRITE) {
+                // Continue to write
+            } else if (filenameConflictAction == CONFLICT_ACTION_UNIQUIFY) {
+                const { filenameWithoutExtension, extension, indexFilename } = splitFilename(path);
+                options.indexFilename = indexFilename + 1;
+                path = getFilename(filenameWithoutExtension, extension, options.indexFilename);
+                return await upload(path, content, options);
+            } else if (filenameConflictAction == CONFLICT_ACTION_PROMPT) {
+                if (prompt) {
+                    path = await prompt(path);
+                    if (path) {
+                        return await upload(path, content, options);
+                    } else {
+                        return { url: path, skipped: true };
+                    }
+                } else {
+                    options.filenameConflictAction = CONFLICT_ACTION_UNIQUIFY;
+                    return await upload(path, content, options);
+                }
+            }
+        }
+
+        const writeResponse = await writeFile(serverUrl, authToken, path, content, signal, getRequestId);
+
+        if (writeResponse.success) {
+            return { url: path };
+        } else {
+            throw new Error(writeResponse.error || "Failed to write file via MCP");
+        }
+    } catch (error) {
+        if (error.name != ABORT_ERROR_NAME) {
+            throw error;
+        }
+    }
+}
+
+async function checkFileExists(serverUrl, authToken, path, signal, getRequestId) {
+    const requestBody = {
+        jsonrpc: MCP_JSONRPC_VERSION,
+        id: getRequestId(),
+        method: "tools/call",
+        params: {
+            name: "get_file_info",
+            arguments: {
+                path: path
+            }
+        }
+    };
+
+    const headers = {
+        [CONTENT_TYPE_HEADER]: JSON_CONTENT_TYPE,
+        "Accept": "application/json, text/event-stream"
+    };
+    if (authToken) {
+        headers.Authorization = `Bearer ${authToken}`;
+    }
+    const response = await fetch(serverUrl, {
+        method: "POST",
+        headers,
+        body: JSON.stringify(requestBody),
+        signal
+    });
+    if (!response.ok) {
+        throw new Error(`MCP server error: ${response.status} ${response.statusText}`);
+    }
+    const data = await response.json();
+    if (data.error) {
+        return { exists: false };
+    }
+    if (data.result && data.result.isError) {
+        return { exists: false };
+    }
+    if (data.result) {
+        return { exists: true };
+    }
+    return { exists: false };
+}
+
+async function writeFile(serverUrl, authToken, path, content, signal, getRequestId) {
+    const requestBody = {
+        jsonrpc: MCP_JSONRPC_VERSION,
+        id: getRequestId(),
+        method: "tools/call",
+        params: {
+            name: "write_file",
+            arguments: {
+                path: path,
+                content: content
+            }
+        }
+    };
+    const headers = {
+        [CONTENT_TYPE_HEADER]: JSON_CONTENT_TYPE,
+        "Accept": "application/json, text/event-stream"
+    };
+    if (authToken) {
+        headers.Authorization = `Bearer ${authToken}`;
+    }
+    const response = await fetch(serverUrl, {
+        method: "POST",
+        headers,
+        body: JSON.stringify(requestBody),
+        signal
+    });
+    if (!response.ok) {
+        throw new Error(`MCP server error: ${response.status} ${response.statusText}`);
+    }
+    const data = await response.json();
+    if (data.error) {
+        throw new Error(data.error.message);
+    }
+    return { success: true };
+}
+
+function splitFilename(filename) {
+    let filenameWithoutExtension = filename;
+    let extension = EMPTY_STRING;
+    const indexExtensionSeparator = filename.lastIndexOf(EXTENSION_SEPARATOR);
+    if (indexExtensionSeparator > -1) {
+        filenameWithoutExtension = filename.substring(0, indexExtensionSeparator);
+        extension = filename.substring(indexExtensionSeparator + 1);
+    }
+    let indexFilename;
+    ({ filenameWithoutExtension, indexFilename } = extractIndexFilename(filenameWithoutExtension));
+    return { filenameWithoutExtension, extension, indexFilename };
+}
+
+function extractIndexFilename(filenameWithoutExtension) {
+    const indexFilenameMatch = filenameWithoutExtension.match(INDEX_FILENAME_REGEXP);
+    let indexFilename = 0;
+    if (indexFilenameMatch && indexFilenameMatch.length > 1) {
+        const parsedIndexFilename = Number(indexFilenameMatch[indexFilenameMatch.length - 1]);
+        if (!Number.isNaN(parsedIndexFilename)) {
+            indexFilename = parsedIndexFilename;
+            filenameWithoutExtension = filenameWithoutExtension.replace(INDEX_FILENAME_REGEXP, EMPTY_STRING);
+        }
+    }
+    return { filenameWithoutExtension, indexFilename };
+}
+
+function getFilename(filenameWithoutExtension, extension, indexFilename) {
+    return filenameWithoutExtension +
+        INDEX_FILENAME_PREFIX + indexFilename + INDEX_FILENAME_SUFFIX +
+        (extension ? EXTENSION_SEPARATOR + extension : EMPTY_STRING);
+}

+ 19 - 1
src/ui/bg/ui-options.js

@@ -89,6 +89,9 @@ const saveWithWebDAVLabel = document.getElementById("saveWithWebDAVLabel");
 const webDAVURLLabel = document.getElementById("webDAVURLLabel");
 const webDAVUserLabel = document.getElementById("webDAVUserLabel");
 const webDAVPasswordLabel = document.getElementById("webDAVPasswordLabel");
+const saveWithMCPLabel = document.getElementById("saveWithMCPLabel");
+const mcpServerUrlLabel = document.getElementById("mcpServerUrlLabel");
+const mcpAuthTokenLabel = document.getElementById("mcpAuthTokenLabel");
 const saveToGitHubLabel = document.getElementById("saveToGitHubLabel");
 const githubTokenLabel = document.getElementById("githubTokenLabel");
 const githubUserLabel = document.getElementById("githubUserLabel");
@@ -239,6 +242,9 @@ const saveWithWebDAVInput = document.getElementById("saveWithWebDAVInput");
 const webDAVURLInput = document.getElementById("webDAVURLInput");
 const webDAVUserInput = document.getElementById("webDAVUserInput");
 const webDAVPasswordInput = document.getElementById("webDAVPasswordInput");
+const saveWithMCPInput = document.getElementById("saveWithMCPInput");
+const mcpServerUrlInput = document.getElementById("mcpServerUrlInput");
+const mcpAuthTokenInput = document.getElementById("mcpAuthTokenInput");
 const saveToGitHubInput = document.getElementById("saveToGitHubInput");
 const saveToS3Input = document.getElementById("saveToS3Input");
 const githubTokenInput = document.getElementById("githubTokenInput");
@@ -580,6 +586,7 @@ saveWithCompanionInput.addEventListener("click", () => disableDestinationPermiss
 saveToGDriveInput.addEventListener("click", () => disableDestinationPermissions(["clipboardWrite", "nativeMessaging"], false), false);
 saveToDropboxInput.addEventListener("click", () => disableDestinationPermissions(["clipboardWrite", "nativeMessaging"], true, false), false);
 saveWithWebDAVInput.addEventListener("click", () => disableDestinationPermissions(["clipboardWrite", "nativeMessaging"]), false);
+saveWithMCPInput.addEventListener("click", () => disableDestinationPermissions(["clipboardWrite", "nativeMessaging"]), false);
 saveToRestFormApiInput.addEventListener("click", () => disableDestinationPermissions(["clipboardWrite", "nativeMessaging"]), false);
 sharePageInput.addEventListener("click", () => disableDestinationPermissions(["clipboardWrite", "nativeMessaging"]), false);
 saveCreatedBookmarksInput.addEventListener("click", saveCreatedBookmarks, false);
@@ -689,6 +696,9 @@ saveWithWebDAVLabel.textContent = browser.i18n.getMessage("optionSaveWithWebDAV"
 webDAVURLLabel.textContent = browser.i18n.getMessage("optionWebDAVURL");
 webDAVUserLabel.textContent = browser.i18n.getMessage("optionWebDAVUser");
 webDAVPasswordLabel.textContent = browser.i18n.getMessage("optionWebDAVPassword");
+saveWithMCPLabel.textContent = browser.i18n.getMessage("optionSaveWithMCP");
+mcpServerUrlLabel.textContent = browser.i18n.getMessage("optionMCPServerUrl");
+mcpAuthTokenLabel.textContent = browser.i18n.getMessage("optionMCPAuthToken");
 saveToGitHubLabel.textContent = browser.i18n.getMessage("optionSaveToGitHub");
 githubTokenLabel.textContent = browser.i18n.getMessage("optionGitHubToken");
 githubUserLabel.textContent = browser.i18n.getMessage("optionGitHubUser");
@@ -1011,6 +1021,11 @@ async function refresh(profileName) {
 	webDAVUserInput.disabled = !profileOptions.saveWithWebDAV;
 	webDAVPasswordInput.value = profileOptions.webDAVPassword;
 	webDAVPasswordInput.disabled = !profileOptions.saveWithWebDAV;
+	saveWithMCPInput.checked = profileOptions.saveWithMCP;
+	mcpServerUrlInput.value = profileOptions.mcpServerUrl;
+	mcpServerUrlInput.disabled = !profileOptions.saveWithMCP;
+	mcpAuthTokenInput.value = profileOptions.mcpAuthToken;
+	mcpAuthTokenInput.disabled = !profileOptions.saveWithMCP;
 	saveToGitHubInput.checked = profileOptions.saveToGitHub;
 	githubTokenInput.value = profileOptions.githubToken;
 	githubTokenInput.disabled = !profileOptions.saveToGitHub;
@@ -1042,7 +1057,7 @@ async function refresh(profileName) {
 	S3SecretKeyInput.value = profileOptions.S3SecretKey;
 	S3SecretKeyInput.disabled = !profileOptions.saveToS3;
 	sharePageInput.checked = profileOptions.sharePage;
-	saveToFilesystemInput.checked = !profileOptions.saveToGDrive && !profileOptions.saveToGitHub && !profileOptions.saveToS3 && !profileOptions.saveWithCompanion && !profileOptions.saveToClipboard && !profileOptions.saveWithWebDAV && !profileOptions.saveToDropbox && !profileOptions.saveToRestFormApi && !profileOptions.sharePage;
+	saveToFilesystemInput.checked = !profileOptions.saveToGDrive && !profileOptions.saveToGitHub && !profileOptions.saveToS3 && !profileOptions.saveWithCompanion && !profileOptions.saveToClipboard && !profileOptions.saveWithWebDAV && !profileOptions.saveWithMCP && !profileOptions.saveToDropbox && !profileOptions.saveToRestFormApi && !profileOptions.sharePage;
 	compressHTMLInput.checked = profileOptions.compressHTML;
 	compressCSSInput.checked = profileOptions.compressCSS;
 	groupDuplicateStylesheetsInput.checked = profileOptions.groupDuplicateStylesheets;
@@ -1179,6 +1194,9 @@ async function update() {
 			webDAVURL: webDAVURLInput.value,
 			webDAVUser: webDAVUserInput.value,
 			webDAVPassword: webDAVPasswordInput.value,
+			saveWithMCP: saveWithMCPInput.checked,
+			mcpServerUrl: mcpServerUrlInput.value,
+			mcpAuthToken: mcpAuthTokenInput.value,
 			saveToGitHub: saveToGitHubInput.checked,
 			githubToken: githubTokenInput.value,
 			githubUser: githubUserInput.value,

+ 28 - 5
src/ui/pages/help.html

@@ -515,6 +515,25 @@
 					<li data-options-label="webDAVPasswordLabel"> <span class="option">Option: password</span>
 						<p>Enter your password.</p>
 					</li>
+					<li data-options-label="saveWithMCPLabel"> <span class="option">Option: save with MCP server</span>
+						<p>Check this option to save the page via a Model Context Protocol (MCP) server.</p>
+						<p>MCP is an open protocol that standardizes how applications provide context to AI models.
+							When enabled, SingleFile will send saved pages to an MCP server that implements the
+							filesystem tools (write_file, get_file_info).</p>
+						<p class="notice">It is recommended to <u>uncheck</u> this option unless you have an MCP server
+							running</p>
+					</li>
+					<li data-options-label="mcpServerUrlLabel"> <span class="option">Option: server URL</span>
+						<p>Enter the URL of your MCP server (e.g. <code>http://localhost:3000/mcp</code>).</p>
+						<p>The server must implement the HTTP transport as specified in the MCP protocol and provide
+							the <code>write_file</code> and <code>get_file_info</code> tools.</p>
+					</li>
+					<li data-options-label="mcpAuthTokenLabel"> <span class="option">Option: authentication token</span>
+						<p>Enter the Bearer authentication token for your MCP server (optional).</p>
+						<p>If your MCP server requires authentication, the token will be sent in the
+							<code>Authorization: Bearer &lt;token&gt;</code> header with each request.
+						</p>
+					</li>
 					<li data-options-label="saveToGDriveLabel" id="saveToGDriveOption"> <span class="option">Option:
 							upload to Google Drive</span>
 						<p>Check this option to save the page on Google Drive.</p>
@@ -958,7 +977,8 @@
 					<li><code>{url-href}</code>: the URL of the page (e.g. "http://example.com/category/index.html")
 					</li>
 					<li><code>{url-href-digest-sha-1}</code>: the SHA-1 hash value of the URL of the page (e.g.
-						4b826844d9f5c128533e4ff14d746334f3ac9e00) <strong>(note: only supported in filename, not infobar)</strong></li>
+						4b826844d9f5c128533e4ff14d746334f3ac9e00) <strong>(note: only supported in filename, not
+							infobar)</strong></li>
 					<li><code>{url-href-flat}</code>: the URL of the page with replaced slashes (e.g.
 						"http__example.com_category_index.html")</li>
 					<li><code>{url-pathname}</code>: the path name of the URL (e.g. "category/index.html")</li>
@@ -994,9 +1014,12 @@
 					<li><code>{tab-id}</code>: the unique identifier of the tab (e.g. "326")</li>
 					<li><code>{tab-index}</code>: the index of the tab in the window (e.g. "1")</li>
 					<li><code>{digest-sha-256}</code>: the SHA-256 hash value of the entire page content (e.g.
-						e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855) <strong>(note: only supported in filename, not infobar)</strong></li>
-					<li><code>{digest-sha-384}</code>: the SHA-384 hash value of the entire page content <strong>(note: only supported in filename, not infobar)</strong></li>
-					<li><code>{digest-sha-512}</code>: the SHA-512 hash value of the entire page content <strong>(note: only supported in filename, not infobar)</strong></li>
+						e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855) <strong>(note: only supported
+							in filename, not infobar)</strong></li>
+					<li><code>{digest-sha-384}</code>: the SHA-384 hash value of the entire page content <strong>(note:
+							only supported in filename, not infobar)</strong></li>
+					<li><code>{digest-sha-512}</code>: the SHA-512 hash value of the entire page content <strong>(note:
+							only supported in filename, not infobar)</strong></li>
 					<li><code>{profile-name}</code>: the name of the profile used to save the page</li>
 					<li><code>{filename-extension}</code>: the extension of the filename depending on the file format
 						(i.e. "html", "u.zip.html", "zip.html", "zip")</li>
@@ -1372,4 +1395,4 @@
 	<script type="module" src="../bg/ui-help.js"></script>
 </body>
 
-</html>
+</html>

+ 12 - 0
src/ui/pages/options.html

@@ -302,6 +302,18 @@
 				<label for="webDAVPasswordInput" id="webDAVPasswordLabel"></label>
 				<input type="password" id="webDAVPasswordInput" class="medium-input">
 			</div>
+			<div class="option">
+				<label for="saveWithMCPInput" id="saveWithMCPLabel"></label>
+				<input type="radio" id="saveWithMCPInput" name="destinationInput">
+			</div>
+			<div class="option second-level">
+				<label for="mcpServerUrlInput" id="mcpServerUrlLabel"></label>
+				<input type="text" id="mcpServerUrlInput" class="medium-input">
+			</div>
+			<div class="option second-level">
+				<label for="mcpAuthTokenInput" id="mcpAuthTokenLabel"></label>
+				<input type="password" id="mcpAuthTokenInput" class="medium-input">
+			</div>
 			<div class="option" id="saveToGDriveOption">
 				<label for="saveToGDriveInput" id="saveToGDriveLabel"></label>
 				<input type="radio" id="saveToGDriveInput" name="destinationInput">