enhanced-word-mcp-server-live
Version:
Enhanced Word MCP server with live editing capabilities. Features real-time Word document collaboration via WebSocket, Office Add-in integration, and comprehensive document operations with regex positioning fixes.
281 lines (236 loc) • 8.93 kB
JavaScript
/*
* Word Live Connector: Bridge between a live Word document and the local MCP server.
* This script manages the WebSocket connection and executes commands via the Office.js API.
*/
/* global document, Office, Word */
const WEBSOCKET_URL = "ws://localhost:8765";
let socket = null;
let documentPath = ""; // Will hold the unique URL or generated ID for this document.
let connectButton;
let statusText;
let statusIndicator;
let autoConnectInterval = null;
let retryCount = 0;
const MAX_RETRY_ATTEMPTS = 10;
const RETRY_INTERVAL = 2000; // 2 seconds
Office.onReady((info) => {
if (info.host === Office.HostType.Word) {
// DOM elements are ready, assign them.
connectButton = document.getElementById("connect-button");
statusText = document.getElementById("status-text");
statusIndicator = document.getElementById("status-indicator");
// Assign the primary event handler.
connectButton.onclick = toggleConnection;
updateUiState("disconnected");
// Start automatic connection detection
startAutoConnectDetection();
}
});
function updateUiState(state, message = "") {
switch (state) {
case "disconnected":
statusIndicator.className = "status-indicator-gray";
statusText.textContent = "DISCONNECTED";
connectButton.textContent = "Connect";
connectButton.classList.remove("is-connected");
connectButton.disabled = false;
break;
case "searching":
statusIndicator.className = "status-indicator-orange";
statusText.textContent = "SEARCHING MCP...";
connectButton.textContent = "Searching...";
connectButton.disabled = true;
break;
case "connecting":
statusIndicator.className = "status-indicator-orange";
statusText.textContent = "CONNECTING...";
connectButton.textContent = "Connecting...";
connectButton.disabled = true;
break;
case "connected":
statusIndicator.className = "status-indicator-green";
statusText.textContent = "LIVE";
connectButton.textContent = "Disconnect";
connectButton.classList.add("is-connected");
connectButton.disabled = false;
// Stop auto-connect detection when successfully connected
stopAutoConnectDetection();
break;
case "error":
statusIndicator.className = "status-indicator-red";
statusText.textContent = message || "ERROR";
connectButton.textContent = "Connect";
connectButton.classList.remove("is-connected");
connectButton.disabled = false;
break;
}
}
function toggleConnection() {
if (socket && socket.readyState === WebSocket.OPEN) {
disconnect();
} else {
// Stop auto-detection and connect manually
stopAutoConnectDetection();
connect();
}
}
function startAutoConnectDetection() {
console.log("Starting automatic MCP server detection...");
updateUiState("searching");
autoConnectInterval = setInterval(() => {
if (socket && socket.readyState === WebSocket.OPEN) {
// Already connected, stop detection
stopAutoConnectDetection();
return;
}
console.log(`Auto-connect attempt ${retryCount + 1}/${MAX_RETRY_ATTEMPTS}`);
attemptAutoConnect();
}, RETRY_INTERVAL);
// Try connecting immediately
attemptAutoConnect();
}
function stopAutoConnectDetection() {
if (autoConnectInterval) {
console.log("Stopping automatic MCP server detection");
clearInterval(autoConnectInterval);
autoConnectInterval = null;
retryCount = 0;
}
}
function attemptAutoConnect() {
if (retryCount >= MAX_RETRY_ATTEMPTS) {
console.log("Max auto-connect attempts reached. Stopping detection.");
stopAutoConnectDetection();
updateUiState("disconnected");
return;
}
retryCount++;
// Test if WebSocket server is available
const testSocket = new WebSocket(WEBSOCKET_URL);
testSocket.onopen = () => {
console.log("MCP server detected! Establishing connection...");
testSocket.close();
connect();
};
testSocket.onerror = () => {
console.log(`Auto-connect attempt ${retryCount} failed - MCP server not available`);
// Continue trying in the next interval
};
testSocket.onclose = () => {
// Normal close after testing
};
}
function connect() {
updateUiState("connecting");
// Asynchronously get the file's URL to use as a unique session key.
Office.context.document.getFilePropertiesAsync((asyncResult) => {
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
console.error("Error getting document properties: " + asyncResult.error.message);
updateUiState("error", "SETUP FAILED");
return;
}
documentPath = asyncResult.value.url;
// Fallback for environments where the URL is not available (e.g., new unsaved docs).
if (!documentPath || documentPath.trim() === "") {
documentPath = "live-unsaved-doc-" + Date.now();
console.warn("Document URL not available. Using generated ID:", documentPath);
}
console.log(`Attempting WebSocket connection for document: ${documentPath}`);
socket = new WebSocket(WEBSOCKET_URL);
socket.onopen = (event) => {
console.log("WebSocket connection established.");
updateUiState("connected");
const registrationMessage = {
type: "register",
path: documentPath,
};
socket.send(JSON.stringify(registrationMessage));
};
socket.onmessage = (event) => {
handleServerMessage(JSON.parse(event.data));
};
socket.onclose = (event) => {
console.log("WebSocket connection closed.", event.code, event.reason);
updateUiState("disconnected");
socket = null;
// Restart auto-detection if connection was lost unexpectedly
if (event.code !== 1000) { // 1000 = normal closure
console.log("Connection lost unexpectedly, restarting auto-detection...");
setTimeout(startAutoConnectDetection, 3000); // Wait 3 seconds before retrying
}
};
socket.onerror = (error) => {
console.error("WebSocket Error:", error);
updateUiState("error", "CONNECTION FAILED");
socket = null;
};
});
}
function disconnect() {
// Stop auto-detection when manually disconnecting
stopAutoConnectDetection();
if (socket) {
socket.close(1000, "Manual disconnect"); // 1000 = normal closure
}
}
async function handleServerMessage(message) {
console.log("Message from server: ", message);
const { command, correlation_id } = message;
try {
let result_data = {};
let command_status = "success";
switch (command) {
case "get_full_content":
result_data = { content: await getDocumentAsOoxml() };
break;
case "replace_full_content":
await setDocumentFromOoxml(message.content);
break;
case "find_and_format":
const count = await findAndFormat(message.find_text, message.formatting);
result_data = { replacements: count };
break;
default:
command_status = "error";
result_data = { error: `Unknown command: ${command}` };
throw new Error(result_data.error);
}
socket.send(JSON.stringify({ type: "response", status: command_status, correlation_id, data: result_data }));
} catch (error) {
console.error(`Error processing command '${command}':`, error);
socket.send(JSON.stringify({ type: "response", status: "error", correlation_id, error: error.message }));
}
}
// --- Office.js API Wrapper Functions ---
function getDocumentAsOoxml() {
return Word.run(async (context) => {
const ooxml = context.document.body.getOoxml();
await context.sync();
return ooxml.value;
});
}
function setDocumentFromOoxml(ooxml) {
return Word.run(async (context) => {
context.document.body.clear();
context.document.body.insertOoxml(ooxml, Word.InsertLocation.replace);
await context.sync();
});
}
function findAndFormat(searchText, formatting) {
return Word.run(async (context) => {
// This is an optimized path that uses Word's native search.
const searchResults = context.document.body.search(searchText, { matchCase: formatting.matchCase || false });
context.load(searchResults, 'font');
await context.sync();
searchResults.items.forEach(item => {
if (formatting.bold !== undefined) item.font.bold = formatting.bold;
if (formatting.italic !== undefined) item.font.italic = formatting.italic;
if (formatting.underline !== undefined) item.font.underline = formatting.underline;
if (formatting.color !== undefined) item.font.color = formatting.color;
if (formatting.font_name !== undefined) item.font.name = formatting.font_name;
if (formatting.font_size !== undefined) item.font.size = formatting.font_size;
});
await context.sync();
return searchResults.items.length;
});
}