From 7748999a8b0c3ab5e7b107bf7c42f24580cb23aa Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Sun, 15 Mar 2026 01:57:17 -0500 Subject: Real-time comment sync, MCP server expansion, multi-tab terminal, UI fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Socket.IO v0.9 ack parser to handle acks without data (6:::N format), fixing comment creation stuck at "sending" - Rewrite comment sync to use local state updates from socket events (new-comment, resolve-thread, reopen-thread, delete-thread, edit-message, delete-message) instead of REST re-fetches — instant UI updates - Optimistic updates for all comment actions (resolve, reopen, delete, reply, edit) - Fetch threads + contexts on project connect so editor highlights are correct from startup, not only when review panel is opened - Add comment context to store immediately after creation for instant highlight - Rename MCP server from overleaf-comments to lattex, add 6 new tools: reopen_comment, delete_comment, get_chat_messages, send_chat_message, list_project_files, compile_latex — all auto-granted permissions - Refactor terminal from fixed Terminal/Claude tabs to dynamic multi-tab with bottom tab bar and unlimited new terminal creation - Fix chat panel layout overflow pushing other components Co-Authored-By: Claude Opus 4.6 --- src/mcp/lattex.mjs | 560 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 560 insertions(+) create mode 100644 src/mcp/lattex.mjs (limited to 'src/mcp/lattex.mjs') diff --git a/src/mcp/lattex.mjs b/src/mcp/lattex.mjs new file mode 100644 index 0000000..50c4a0e --- /dev/null +++ b/src/mcp/lattex.mjs @@ -0,0 +1,560 @@ +#!/usr/bin/env node +// Copyright (c) 2026 Yuren Hao +// Licensed under AGPL-3.0 - see LICENSE file + +// MCP Server: LatteX +// Provides tools for Claude Code to interact with the Overleaf project: +// comments, chat, file listing, compilation + +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + CallToolRequestSchema, + ListToolsRequestSchema +} from '@modelcontextprotocol/sdk/types.js' +import { readFileSync, readdirSync, statSync } from 'fs' +import { join, relative } from 'path' +import https from 'https' + +// ── State ────────────────────────────────────────────────────── + +function readState() { + const cwd = process.cwd() + const statePath = join(cwd, '.lattex-mcp.json') + try { + return JSON.parse(readFileSync(statePath, 'utf-8')) + } catch { + throw new Error( + 'Cannot read .lattex-mcp.json — is LatteX running and connected to an Overleaf project?' + ) + } +} + +// ── HTTP helper ──────────────────────────────────────────────── + +function overleafRequest(method, path, cookie, csrf, body) { + return new Promise((resolve, reject) => { + const options = { + hostname: 'www.overleaf.com', + path, + method, + headers: { + Cookie: cookie, + Accept: 'application/json', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' + } + } + if (body) { + options.headers['Content-Type'] = 'application/json' + } + if (csrf && method !== 'GET') { + options.headers['x-csrf-token'] = csrf + } + + const req = https.request(options, (res) => { + let data = '' + res.on('data', (chunk) => (data += chunk)) + res.on('end', () => { + let parsed + try { + parsed = JSON.parse(data) + } catch { + parsed = data + } + resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + status: res.statusCode, + data: parsed + }) + }) + }) + req.on('error', reject) + if (body) req.write(JSON.stringify(body)) + req.end() + }) +} + +// ── Helpers ──────────────────────────────────────────────────── + +function fmtTime(ts) { + if (!ts) return '' + return new Date(ts).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) +} + +function userName(user) { + if (!user) return '' + return [user.first_name, user.last_name].filter(Boolean).join(' ') || + user.email?.split('@')[0] || '' +} + +function textResult(text) { + return { content: [{ type: 'text', text }] } +} + +function errorResult(text) { + return { content: [{ type: 'text', text }], isError: true } +} + +// Walk a directory recursively and return relative paths +function walkDir(dir, base) { + const results = [] + try { + for (const entry of readdirSync(dir)) { + if (entry.startsWith('.')) continue + const full = join(dir, entry) + const rel = relative(base, full) + try { + const st = statSync(full) + if (st.isDirectory()) { + results.push({ path: rel + '/', size: 0, isDir: true }) + results.push(...walkDir(full, base)) + } else { + results.push({ path: rel, size: st.size, isDir: false }) + } + } catch { /* skip */ } + } + } catch { /* skip */ } + return results +} + +// ── Tool definitions ─────────────────────────────────────────── + +const TOOLS = [ + // ── Comments ── + { + name: 'get_comments', + description: + 'Get unresolved Overleaf comments. Optionally filter by file path. Returns comment text, position, author, time, and thread_id for each comment.', + inputSchema: { + type: 'object', + properties: { + file: { + type: 'string', + description: + 'Optional file path to filter comments (e.g. "latex/main.tex"). If omitted, returns all unresolved comments.' + }, + include_resolved: { + type: 'boolean', + description: 'If true, also include resolved comments. Default: false.' + } + } + } + }, + { + name: 'resolve_comment', + description: 'Resolve (close) an Overleaf comment thread by its thread_id.', + inputSchema: { + type: 'object', + properties: { + thread_id: { + type: 'string', + description: 'The thread_id of the comment to resolve.' + } + }, + required: ['thread_id'] + } + }, + { + name: 'reopen_comment', + description: 'Reopen a previously resolved Overleaf comment thread.', + inputSchema: { + type: 'object', + properties: { + thread_id: { + type: 'string', + description: 'The thread_id of the comment to reopen.' + } + }, + required: ['thread_id'] + } + }, + { + name: 'reply_to_comment', + description: 'Reply to an existing Overleaf comment thread.', + inputSchema: { + type: 'object', + properties: { + thread_id: { + type: 'string', + description: 'The thread_id to reply to.' + }, + content: { + type: 'string', + description: 'The reply message content.' + } + }, + required: ['thread_id', 'content'] + } + }, + { + name: 'delete_comment', + description: 'Delete an entire Overleaf comment thread and its highlight. This is permanent.', + inputSchema: { + type: 'object', + properties: { + thread_id: { + type: 'string', + description: 'The thread_id of the comment to delete.' + } + }, + required: ['thread_id'] + } + }, + // ── Chat ── + { + name: 'get_chat_messages', + description: 'Get recent chat messages from the Overleaf project.', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Max number of messages to return. Default: 50.' + } + } + } + }, + { + name: 'send_chat_message', + description: 'Send a message to the Overleaf project chat.', + inputSchema: { + type: 'object', + properties: { + content: { + type: 'string', + description: 'The message text to send.' + } + }, + required: ['content'] + } + }, + // ── Project ── + { + name: 'list_project_files', + description: 'List all files in the synced project directory with their paths and sizes.', + inputSchema: { + type: 'object', + properties: {} + } + }, + { + name: 'compile_latex', + description: 'Trigger LaTeX compilation of the project. Returns compilation status and log output.', + inputSchema: { + type: 'object', + properties: { + main_file: { + type: 'string', + description: 'Optional main .tex file path (e.g. "main.tex"). Uses project default if omitted.' + } + } + } + } +] + +// ── Server ───────────────────────────────────────────────────── + +const server = new Server( + { name: 'lattex', version: '2.0.0' }, + { capabilities: { tools: {} } } +) + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: TOOLS +})) + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + + try { + const state = readState() + const { projectId, cookie, csrf, commentContexts, pathDocMap } = state + + switch (name) { + // ── Comments ────────────────────────────────── + + case 'get_comments': { + const filterFile = args?.file || null + const includeResolved = args?.include_resolved || false + + const result = await overleafRequest( + 'GET', + `/project/${projectId}/threads`, + cookie, + csrf + ) + if (!result.ok) { + return errorResult(`Failed to fetch comments: HTTP ${result.status}`) + } + + const threads = result.data + const lines = [] + + for (const [threadId, thread] of Object.entries(threads)) { + if (!includeResolved && thread.resolved) continue + const ctx = commentContexts?.[threadId] + if (!ctx) continue + if (filterFile && ctx.file !== filterFile) continue + + const firstMsg = thread.messages?.[0] + if (!firstMsg) continue + + const author = userName(firstMsg.user) + const time = fmtTime(firstMsg.timestamp) + const attribution = [author, time].filter(Boolean).join(', ') + const status = thread.resolved ? ' [RESOLVED]' : '' + + let entry = `Thread ${threadId}${status}:\n File: ${ctx.file}\n Position: ${ctx.pos}\n Highlighted text: "${ctx.text}"\n Comment: "${firstMsg.content}"${attribution ? ` — ${attribution}` : ''}` + + for (let i = 1; i < thread.messages.length; i++) { + const reply = thread.messages[i] + const rAuthor = userName(reply.user) + const rTime = fmtTime(reply.timestamp) + const rAttr = [rAuthor, rTime].filter(Boolean).join(', ') + entry += `\n Reply: "${reply.content}"${rAttr ? ` — ${rAttr}` : ''}` + } + + lines.push(entry) + } + + if (lines.length === 0) { + return textResult( + filterFile + ? `No ${includeResolved ? '' : 'unresolved '}comments in ${filterFile}.` + : `No ${includeResolved ? '' : 'unresolved '}comments.` + ) + } + + return textResult( + `${lines.length} comment(s):\n\n${lines.join('\n\n')}` + ) + } + + case 'resolve_comment': { + const threadId = args.thread_id + const ctx = commentContexts?.[threadId] + const docId = ctx ? pathDocMap?.[ctx.file] : null + const docSegment = docId ? `/doc/${docId}` : '' + const result = await overleafRequest( + 'POST', + `/project/${projectId}${docSegment}/thread/${threadId}/resolve`, + cookie, + csrf, + {} + ) + return textResult( + result.ok + ? `Comment ${threadId} resolved.` + : `Failed to resolve: HTTP ${result.status}` + ) + } + + case 'reopen_comment': { + const threadId = args.thread_id + const ctx = commentContexts?.[threadId] + const docId = ctx ? pathDocMap?.[ctx.file] : null + const docSegment = docId ? `/doc/${docId}` : '' + const result = await overleafRequest( + 'POST', + `/project/${projectId}${docSegment}/thread/${threadId}/reopen`, + cookie, + csrf, + {} + ) + return textResult( + result.ok + ? `Comment ${threadId} reopened.` + : `Failed to reopen: HTTP ${result.status}` + ) + } + + case 'reply_to_comment': { + const { thread_id: threadId, content } = args + const result = await overleafRequest( + 'POST', + `/project/${projectId}/thread/${threadId}/messages`, + cookie, + csrf, + { content } + ) + return textResult( + result.ok + ? `Replied to thread ${threadId}.` + : `Failed to reply: HTTP ${result.status}` + ) + } + + case 'delete_comment': { + const threadId = args.thread_id + const ctx = commentContexts?.[threadId] + const docId = ctx ? pathDocMap?.[ctx.file] : null + if (!docId) { + return errorResult(`Cannot delete: no doc found for thread ${threadId}`) + } + const result = await overleafRequest( + 'DELETE', + `/project/${projectId}/doc/${docId}/thread/${threadId}`, + cookie, + csrf + ) + return textResult( + result.ok + ? `Comment ${threadId} deleted.` + : `Failed to delete: HTTP ${result.status}` + ) + } + + // ── Chat ────────────────────────────────────── + + case 'get_chat_messages': { + const limit = args?.limit || 50 + const result = await overleafRequest( + 'GET', + `/project/${projectId}/messages?limit=${limit}`, + cookie, + csrf + ) + if (!result.ok) { + return errorResult(`Failed to fetch chat: HTTP ${result.status}`) + } + + const messages = result.data + if (!Array.isArray(messages) || messages.length === 0) { + return textResult('No chat messages.') + } + + const lines = messages.map((msg) => { + const author = userName(msg.user) + const time = fmtTime(msg.timestamp) + const attr = [author, time].filter(Boolean).join(', ') + return `${attr ? `[${attr}] ` : ''}${msg.content}` + }) + + // Messages come newest-first from API, reverse for chronological + lines.reverse() + + return textResult( + `${messages.length} chat message(s):\n\n${lines.join('\n')}` + ) + } + + case 'send_chat_message': { + const { content } = args + const result = await overleafRequest( + 'POST', + `/project/${projectId}/messages`, + cookie, + csrf, + { content } + ) + return textResult( + result.ok + ? 'Message sent.' + : `Failed to send: HTTP ${result.status}` + ) + } + + // ── Project ─────────────────────────────────── + + case 'list_project_files': { + const cwd = process.cwd() + const files = walkDir(cwd, cwd) + .filter(f => !f.path.startsWith('.')) + + if (files.length === 0) { + return textResult('No files found in project directory.') + } + + const lines = files.map(f => { + if (f.isDir) return `📁 ${f.path}` + const sizeKb = (f.size / 1024).toFixed(1) + return ` ${f.path} (${sizeKb} KB)` + }) + + return textResult( + `${files.filter(f => !f.isDir).length} files in project:\n\n${lines.join('\n')}` + ) + } + + case 'compile_latex': { + // Compilation happens via the LatteX app's local LaTeX installation + // We trigger it by writing a signal file that the app watches, + // or we can call the Overleaf compile endpoint + const mainFile = args?.main_file || null + + // Use Overleaf's server-side compilation + const body = { + check: 'silent', + draft: false, + incrementalCompilesEnabled: true, + rootDoc_id: null, + stopOnFirstError: false + } + + // If a specific main file is given, find its docId + if (mainFile && pathDocMap) { + const docId = pathDocMap[mainFile] + if (docId) body.rootDoc_id = docId + } + + const result = await overleafRequest( + 'POST', + `/project/${projectId}/compile`, + cookie, + csrf, + body + ) + + if (!result.ok) { + return errorResult(`Compilation request failed: HTTP ${result.status}`) + } + + const compileData = result.data + const status = compileData?.status || 'unknown' + + if (status === 'success') { + return textResult('Compilation successful.') + } else if (status === 'failure' || status === 'error') { + // Try to extract error info from output files + const outputFiles = compileData?.outputFiles || [] + const logFile = outputFiles.find(f => f.path === 'output.log') + if (logFile) { + // Fetch the log + const logUrl = `/project/${projectId}/output/${logFile.path}?build=${logFile.build}` + const logResult = await overleafRequest('GET', logUrl, cookie, csrf) + if (logResult.ok && typeof logResult.data === 'string') { + // Extract just the error lines + const logLines = logResult.data.split('\n') + const errorLines = logLines.filter(l => + l.startsWith('!') || l.includes('Error') || l.includes('error') + ).slice(0, 20) + + return textResult( + `Compilation failed.\n\nErrors:\n${errorLines.join('\n') || 'See full log for details.'}` + ) + } + } + return textResult(`Compilation failed with status: ${status}`) + } else { + return textResult(`Compilation status: ${status}`) + } + } + + default: + return errorResult(`Unknown tool: ${name}`) + } + } catch (e) { + return errorResult(`Error: ${e.message}`) + } +}) + +// ── Start ────────────────────────────────────────────────────── + +const transport = new StdioServerTransport() +await server.connect(transport) -- cgit v1.2.3