diff --git a/.github/agents/gnome-doc-librarian.agent.md b/.github/agents/gnome-doc-librarian.agent.md new file mode 100644 index 0000000..89a20ab --- /dev/null +++ b/.github/agents/gnome-doc-librarian.agent.md @@ -0,0 +1,8 @@ +--- +description: 'Expert GNOME documentation librarian agent for finding and retrieving information and examples from GNOME docs.' +tools: ['gnome-docs/*'] +--- +You are a GNOME documentation librarian agent. Your task is to assist users in finding and retrieving information from the GNOME documentation set. You have access to a variety of tools that allow you to search and read documentation about GNOME libraries. + +To effectively assist the user, do a deep research using the available tools, then come up with a concise and accurate answer based on the information you found. +You may need to retrieve multiple pieces of information and come up with a synthesized example to effectively illustrate your response. \ No newline at end of file diff --git a/src/application.js b/src/application.js index 7d24a7d..0f79cc2 100644 --- a/src/application.js +++ b/src/application.js @@ -3,6 +3,7 @@ import Adw from "gi://Adw"; import Actions from "./actions.js"; import { settings } from "./util.js"; import Window from "./window.js"; +import { startMCPServer, stopMCPServer } from "./mcp-server-launcher.js"; const application = new Adw.Application({ application_id: pkg.name, @@ -18,12 +19,34 @@ application.connect("activate", () => { } setColorScheme(); window.open(); + + // Start the MCP server on app activation + // Using --http mode on port 8080 for external tool integration + startMCPServer({ + port: 8080, + debug: __DEV__, // Enable debug logging in development + }).then((success) => { + if (success) { + if (__DEV__) console.log("MCP server started successfully"); + } else { + console.warn("Failed to start MCP server - some features may not work"); + } + }); }); application.set_option_context_description( "", ); +// Handle cleanup when the application is shutting down +application.connect("shutdown", () => { + stopMCPServer(__DEV__).then((stopped) => { + if (stopped && __DEV__) { + console.log("MCP server stopped"); + } + }); +}); + Actions({ application }); function setColorScheme() { diff --git a/src/mcp-server-launcher.js b/src/mcp-server-launcher.js new file mode 100644 index 0000000..ad6f155 --- /dev/null +++ b/src/mcp-server-launcher.js @@ -0,0 +1,51 @@ +import GLib from "gi://GLib"; +import Gio from "gi://Gio"; + +let subprocess = null; + +export async function startMCPServer() { + if (subprocess) return true; + + const path = getMCPServerPath(); + if (!path) return false; + + try { + subprocess = Gio.Subprocess.new( + ["gjs", "-m", path, "--http"], + Gio.SubprocessFlags.NONE + ); + return true; + } catch (e) { + console.error(`Failed to start MCP server: ${e}`); + return false; + } +} + +export async function stopMCPServer() { + if (!subprocess) return false; + + try { + subprocess.force_exit(); + subprocess = null; + return true; + } catch (e) { + console.error(`Failed to stop MCP server: ${e}`); + return false; + } +} + +function getMCPServerPath() { + const paths = [ + "/app/share/biblioteca/mcp-server.js", + "/app/share/app.drey.Biblioteca.Devel/mcp-server.js", + "/app/share/app.drey.Biblioteca/mcp-server.js", + "/usr/share/biblioteca/mcp-server.js", + "/usr/local/share/biblioteca/mcp-server.js", + GLib.build_filenamev([GLib.get_current_dir(), "src/mcp-server/mcp-server.js"]), + ]; + + for (const path of paths) { + if (path && GLib.file_test(path, GLib.FileTest.EXISTS)) return path; + } + return null; +} diff --git a/src/mcp-server/mcp-framework.js b/src/mcp-server/mcp-framework.js new file mode 100644 index 0000000..e569ee8 --- /dev/null +++ b/src/mcp-server/mcp-framework.js @@ -0,0 +1,566 @@ +import GLib from 'gi://GLib'; +import GLibUnix from 'gi://GLibUnix'; +import Gio from 'gi://Gio'; +import Soup from 'gi://Soup?version=3.0'; + +// ==================================================== +// Section 1: Constants & Protocol Definitions (Spec 2025-06-18) +// ==================================================== + +const JSONRPC_VERSION = "2.0"; +// Per Prompt Requirement +const PROTOCOL_VERSION = "2025-06-18"; + +const JSONRPC_ERRORS = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + SERVER_NOT_INITIALIZED: -32002, + UNKNOWN_ERROR: -32001, + REQUEST_CANCELLED: -32800 // Added per 2025 spec for cancellation +}; + +const LogLevel = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }; +const LogLevelNames = ['debug', 'info', 'warning', 'error']; // Lowercase for protocol compliance + +export class RPCError extends Error { + constructor(code, message, data = null) { + super(message); + this.code = code; + this.data = data; + } +} + +// ==================================================== +// Section 2: Validation Logic +// ==================================================== + +class Validator { + /** + * Validates data against a simplified JSON Schema. + * Section 3.4: Dynamic Schema Validation + */ + static validate(schema, data, path = '$') { + if (!schema) return; + + // 1. Type Check + if (schema.type) { + const type = schema.type; + const dataType = Array.isArray(data) ? 'array' : (data === null ? 'null' : typeof data); + + let valid = false; + if (type === dataType) valid = true; + else if (type === 'number' && dataType === 'number') valid = true; + else if (type === 'integer' && Number.isInteger(data)) valid = true; + else if (type === 'object' && dataType === 'object') valid = true; + else if (type === 'string' && dataType === 'string') valid = true; + else if (type === 'any') valid = true; + + if (!valid) { + throw new Error(`At ${path}: Expected type '${type}', got '${dataType}'`); + } + } + + // 2. Object Properties + if (schema.type === 'object') { + if (schema.required) { + for (const field of schema.required) { + if (!(field in data)) { + throw new Error(`At ${path}: Missing required field '${field}'`); + } + } + } + if (schema.properties && data) { + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (key in data) { + this.validate(propSchema, data[key], `${path}.${key}`); + } + } + } + } + } +} + +// ==================================================== +// Section 3: Transport Layer +// ==================================================== + +export class Transport { + constructor() { + this.onMessage = null; + this.onClose = null; + } + send(data) { throw new Error("Not implemented"); } + close() {} +} + +/** + * Implementation of Standard Input/Output Transport + * Spec Section 4.1: Stdio Transport + */ +export class StdioTransport extends Transport { + constructor() { + super(); + this._decoder = new TextDecoder('utf-8'); + // Use UnixInputStream to ensure we don't buffer excessively on the GJS side + this._stdin = new Gio.DataInputStream({ + base_stream: new Gio.UnixInputStream({ fd: 0, close_fd: false }), + newline_type: Gio.DataStreamNewlineType.LF + }); + this._active = false; + } + + start(mainLoop) { + this._active = true; + this._readLoop(mainLoop); + } + + send(data) { + // Critical: JSON-RPC over Stdio must be separated by newlines. + // We use 'print' which appends a newline in GJS, but strictly + // using stdout.write is safer to avoid platform specific line endings. + const json = JSON.stringify(data); + print(json); + } + + _readLoop(mainLoop) { + if (!this._active) return; + + // Read line async to prevent blocking the MainLoop + this._stdin.read_line_async(GLib.PRIORITY_DEFAULT, null, (stream, res) => { + try { + const [bytes] = stream.read_line_finish(res); + + if (bytes === null) { + // EOF detected + if (this.onClose) this.onClose(); + mainLoop.quit(); + return; + } + + const line = this._decoder.decode(bytes).trim(); + if (line && this.onMessage) { + try { + const msg = JSON.parse(line); + this.onMessage(msg, null); + } catch (e) { + printerr(`[MCP-Stdio] JSON Parse Error: ${e.message}\n`); + } + } + + // Recursively call to continue loop + this._readLoop(mainLoop); + + } catch (e) { + printerr(`[MCP-Stdio] Read Error: ${e.message}\n`); + mainLoop.quit(); + } + }); + } +} + +/** + * Implementation of HTTP with SSE (Server-Sent Events) + * Spec Section 4.2: HTTP/SSE Transport + */ +export class HttpTransport extends Transport { + constructor(port = 8080, logger) { + super(logger); + this.port = port; + this.sessions = new Map(); // sessionId -> msg + this.server = new Soup.Server(); + } + + start() { + this.server.add_handler('/', this._handleRoot.bind(this)); + try { + this.server.listen_all(this.port, 0); + printerr(`[MCP-Http] Listening on port ${this.port}\n`); + } catch (e) { + printerr(`[MCP-Http] Failed to bind: ${e.message}\n`); + throw e; + } + + // Heartbeat to keep connections alive + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 15, () => { + this._broadcastHeartbeat(); + return GLib.SOURCE_CONTINUE; + }); + } + + _handleRoot(server, msg, path, query) { + const method = msg.get_method(); // Fix: Use method getter per your example + if (method === 'GET') this._handleSSE(server, msg, path, query); + else if (method === 'POST') this._handlePost(server, msg, path, query); + else msg.set_status(405, 'Method Not Allowed'); + } + + _handleSSE(server, msg, path, query) { + // 1. Setup Headers for Chunked SSE + msg.set_status(200, null); + const headers = msg.get_response_headers(); + headers.set_encoding(Soup.Encoding.CHUNKED); // Tells libsoup to manage the stream + headers.append("Content-Type", "text/event-stream"); + headers.append("Cache-Control", "no-cache"); + headers.append("Connection", "keep-alive"); + headers.append("Access-Control-Allow-Origin", "*"); + + // 2. Disable Accumulation (Critical for memory) + // We don't want to keep a history of every event sent in RAM + msg.get_response_body().set_accumulate(false); + + const sessionId = GLib.uuid_string_random(); + + // 3. Register Session + this.sessions.set(sessionId, msg); + + // 4. Cleanup on Disconnect + msg.connect('finished', () => { + this.sessions.delete(sessionId); + if (this.onClose) this.onClose(sessionId); + printerr(`[MCP-Http] Session closed: ${sessionId}\n`); + }); + + printerr(`[MCP-Http] New Session: ${sessionId}\n`); + + // 5. Send Initial Endpoint Event + // We do NOT manually pause. We just write the first chunk. + // LibSoup will send it and then auto-pause I/O until we write again. + const endpointUrl = `/message?sessionId=${sessionId}`; + this._writeToSession(sessionId, `event: endpoint\ndata: ${endpointUrl}\n\n`); + } + + _handlePost(server, msg, path, query) { + const q = query || {}; + const sessionId = q['sessionId']; + + if (!sessionId || !this.sessions.has(sessionId)) { + msg.set_status(400, 'Bad Request'); + return; + } + + let body = ""; + const reqBody = msg.get_request_body(); + if (reqBody) body = new TextDecoder().decode(reqBody.flatten().toArray()); + + try { + const json = JSON.parse(body); + if (this.onMessage) { + // Async dispatch - strictly 202 Accepted + this.onMessage(json, sessionId); + } + msg.set_status(202, 'Accepted'); + msg.set_response('text/plain', Soup.MemoryUse.COPY, new TextEncoder().encode("Accepted")); + } catch (e) { + msg.set_status(400, 'Bad Request'); + } + } + + send(data, context) { + const sessionId = context; + if (!this.sessions.has(sessionId)) return; + + const eventData = JSON.stringify(data); + const payload = `event: message\ndata: ${eventData}\n\n`; + + this._writeToSession(sessionId, payload); + } + + _writeToSession(sessionId, payload) { + const msg = this.sessions.get(sessionId); + if (!msg) return; + + try { + // 1. Append bytes to body + msg.get_response_body().append_bytes(new GLib.Bytes(new TextEncoder().encode(payload))); + + // 2. The Critical Fix: + // Use server.unpause_message() to trigger the write. + // LibSoup will automatically pause again after writing this chunk. + this.server.unpause_message(msg); + + } catch (e) { + printerr(`[MCP-Http] Write Error: ${e.message}\n`); + this.sessions.delete(sessionId); + } + } + + _broadcastHeartbeat() { + const keepAlive = `: keepalive\n\n`; + for (const id of this.sessions.keys()) { + this._writeToSession(id, keepAlive); + } + } +} + +// ==================================================== +// Section 4: MCP Server Core +// ==================================================== + +export class MCPServer { + constructor(name, version, options = {}) { + this.serverInfo = { name, version }; + this.logLevel = LogLevel.INFO; // Default to INFO + + // Capabilities Definition + this.capabilities = { + tools: { listChanged: true }, + resources: { listChanged: true, subscribe: false }, + prompts: { listChanged: true }, + logging: {} // Declare logging support + }; + + this.registry = { + tools: new Map(), + resources: new Map(), + prompts: new Map() + }; + + this.sessionStates = new Map(); + this.mainLoop = new GLib.MainLoop(null, false); + + // Select Transport + if (options.transport === 'http') { + this.transport = new HttpTransport(options.port || 8080); + } else { + this.transport = new StdioTransport(); + } + + this.transport.onMessage = (msg, ctx) => this._processMessage(msg, ctx); + this.transport.onClose = (ctx) => { + const c = ctx || 'default'; + this.sessionStates.delete(c); + }; + } + + /** + * Registers a tool. + */ + tool(name, description, inputSchema, handler) { + this.registry.tools.set(name, { + definition: { name, description, inputSchema }, + handler + }); + } + + /** + * Sends a log message to the client (Spec Section 5.2) + */ + log(level, message, context = 'default') { + // 1. Filter: Don't send if below the current set level + if (level < this.logLevel) return; + + // 2. Prepare Protocol Level Name + const levelName = LogLevelNames[level] || 'info'; + + // 3. Send Notification + // Only send if we have an active transport + if (this.transport) { + this.transport.send({ + jsonrpc: "2.0", + method: 'logging/message', + params: { + level: levelName, + data: message, + logger: this.serverInfo.name // Optional: helps client identify source + } + }, context); + } + + // 4. Always print to Stderr for local debugging (optional but recommended) + // You might want to skip Debug logs in stderr if not in verbose mode + printerr(`[LOG-${levelName.toUpperCase()}] ${message}\n`); + } + + start() { + // Graceful Shutdown on SIGINT (Ctrl+C) + // Unix signal handlers are provided by GLibUnix on modern + // platforms. + GLibUnix.signal_add_full(GLib.PRIORITY_DEFAULT, 2, () => { + printerr("\n[MCP] Caught SIGINT, shutting down...\n"); + this.mainLoop.quit(); + return GLib.SOURCE_REMOVE; + }); + + if (this.transport.start) this.transport.start(this.mainLoop); + + printerr(`[MCP] Server '${this.serverInfo.name}' v${this.serverInfo.version} running (Proto: ${PROTOCOL_VERSION})\n`); + this.mainLoop.run(); + } + + async _processMessage(msg, context) { + const sessionKey = context || 'default'; + if (!msg || typeof msg !== 'object') return; + + const isRequest = msg.id !== undefined; + + try { + // 1. Handshake: Initialize + if (msg.method === 'initialize') { + this.sessionStates.set(sessionKey, 'initializing'); + + const response = { + protocolVersion: PROTOCOL_VERSION, + capabilities: this.capabilities, + serverInfo: this.serverInfo + }; + + if (isRequest) this._sendResponse(msg.id, response, context); + return; + } + + // 2. Handshake: Initialized (Notification) + if (msg.method === 'notifications/initialized') { + this.sessionStates.set(sessionKey, 'ready'); + this.log(LogLevel.INFO, "Client handshake complete", context); + return; + } + + // 3. Enforce Lifecycle + const state = this.sessionStates.get(sessionKey); + if (state !== 'ready' && state !== 'initializing') { + if (isRequest) { + throw new RPCError(JSONRPC_ERRORS.SERVER_NOT_INITIALIZED, "Server not initialized."); + } + return; + } + + // 4. Method Router + switch (msg.method) { + case 'ping': + if (isRequest) this._sendResponse(msg.id, {}, context); + break; + + case 'tools/list': + const tools = Array.from(this.registry.tools.values()).map(t => t.definition); + // Spec allows 'cursor' for pagination. We return all for now. + if (isRequest) this._sendResponse(msg.id, { tools }, context); + break; + + case 'tools/call': + if (!isRequest) return; + await this._handleToolCall(msg.id, msg.params, context); + break; + + case 'resources/list': + // Stub implementation to satisfy 'capabilities' + if (isRequest) this._sendResponse(msg.id, { resources: [] }, context); + break; + + case 'prompts/list': + // Stub implementation + if (isRequest) this._sendResponse(msg.id, { prompts: [] }, context); + break; + case 'logging/setLevel': + // Validate params + if (!msg.params || typeof msg.params.level !== 'string') { + throw new RPCError(JSONRPC_ERRORS.INVALID_PARAMS, "Level required"); + } + + // Parse level string to integer + const newLevelStr = msg.params.level.toLowerCase(); + const newLevelIdx = LogLevelNames.indexOf(newLevelStr); + + if (newLevelIdx !== -1) { + this.logLevel = newLevelIdx; + this.log(LogLevel.INFO, `Log level set to ${newLevelStr}`, context); + } + // If the level is unknown (e.g. 'critical'), spec suggests defaulting + // or clamping. We'll ignore or set to Error. + + // Acknowledge the request + if (isRequest) this._sendResponse(msg.id, {}, context); + break; + default: + if (isRequest) { + throw new RPCError(JSONRPC_ERRORS.METHOD_NOT_FOUND, `Method ${msg.method} not found`); + } + } + + } catch (error) { + if (isRequest) { + const code = error instanceof RPCError ? error.code : JSONRPC_ERRORS.INTERNAL_ERROR; + this._sendError(msg.id, code, error.message, error.data, context); + } + printerr(`[MCP-Error] ${error.message}\n`); + } + } + + async _handleToolCall(requestId, params, context) { + if (!params || !params.name) { + throw new RPCError(JSONRPC_ERRORS.INVALID_PARAMS, "Missing tool name"); + } + + const tool = this.registry.tools.get(params.name); + if (!tool) { + throw new RPCError(JSONRPC_ERRORS.INVALID_PARAMS, `Tool '${params.name}' not found`); + } + + // Validate Schema + if (tool.definition.inputSchema) { + try { + Validator.validate(tool.definition.inputSchema, params.arguments || {}); + } catch (e) { + throw new RPCError(JSONRPC_ERRORS.INVALID_PARAMS, `Validation Error: ${e.message}`); + } + } + + // Execute Tool + try { + const result = await tool.handler(params.arguments || {}); + + // Handle CallToolResult format: { content: [...] } + let callToolResult; + if (result && typeof result === 'object') { + // If result has a 'content' field, it's already in CallToolResult format + if (Array.isArray(result.content)) { + callToolResult = result; + } + // If result is an array of ContentItems, wrap it + else if (Array.isArray(result)) { + callToolResult = { content: result }; + } + // Otherwise treat as content to be stringified + else { + callToolResult = { + content: [{ type: 'text', text: JSON.stringify(result) }] + }; + } + } else if (typeof result === 'string') { + // String result -> wrap as text content + callToolResult = { + content: [{ type: 'text', text: result }] + }; + } else { + // Any other type -> stringify it + callToolResult = { + content: [{ type: 'text', text: String(result) }] + }; + } + + this._sendResponse(requestId, callToolResult, context); + } catch (e) { + printerr(`[MCP-Error] Tool '${params.name}' failed: ${e.message}\n`); + if (e.stack) printerr(e.stack + '\n'); + + this._sendResponse(requestId, { + content: [{ type: 'text', text: `Tool execution failed: ${e.message}` }], + isError: true + }, context); + } + } + + _sendResponse(id, result, context) { + this.transport.send({ jsonrpc: JSONRPC_VERSION, id, result }, context); + } + + _sendError(id, code, message, data, context) { + this.transport.send({ + jsonrpc: JSONRPC_VERSION, + id, + error: { code, message, data } + }, context); + } +} \ No newline at end of file diff --git a/src/mcp-server/mcp-server.js b/src/mcp-server/mcp-server.js new file mode 100644 index 0000000..4a070d2 --- /dev/null +++ b/src/mcp-server/mcp-server.js @@ -0,0 +1,361 @@ +#!/usr/bin/env -S gjs -m +import { programArgs, programInvocationName } from "system"; +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; +import {MCPServer} from './mcp-framework.js'; +import {score, hasMatch} from '../sidebar/fzy.js'; + +// ==================================================== +// Utils & Logic +// ==================================================== + +function getDocIndexPath() { + if (GLib.getenv("BIBLIOTECA_DOC_INDEX")) { + return GLib.getenv("BIBLIOTECA_DOC_INDEX"); + } + + const searchPaths = [ + GLib.getenv("BIBLIOTECA_PKGDATADIR"), + "/app/share/biblioteca", + "/app/share/app.drey.Biblioteca.Devel", + "/app/share/app.drey.Biblioteca", + "/usr/share/biblioteca", + "/usr/local/share/biblioteca" + ]; + + for (const path of searchPaths) { + if (!path) continue; + const fullPath = GLib.build_filenamev([path, "doc-index.json"]); + if (Gio.File.new_for_path(fullPath).query_exists(null)) { + return fullPath; + } + } + + // Fallback to default if not found + return "/app/share/biblioteca/doc-index.json"; +} + +const DOC_INDEX_PATH = getDocIndexPath(); + +let doc_index = null; +let flattened_docs = []; + +function loadDocs() { + const file = Gio.File.new_for_path(DOC_INDEX_PATH); + if (!file.query_exists(null)) { + console.warn(`doc-index.json not found. Checked locations including: ${DOC_INDEX_PATH}`); + return; + } + + try { + const [success, contents] = file.load_contents(null); + if (success) { + const json = new TextDecoder().decode(contents); + doc_index = JSON.parse(json); + flattened_docs = []; + + const flatten = (items) => { + for (const item of items) { + if (item.search_name) { + flattened_docs.push(item); + } + if (item.children) { + flatten(item.children); + } + } + }; + + if (doc_index.docs) { + flatten(doc_index.docs); + } + console.log(`Loaded ${flattened_docs.length} docs from index.`); + } + } catch (e) { + console.error("Failed to load doc-index.json", e); + } +} + +function readFile(path) { + const file = Gio.File.new_for_path(path); + if (!file.query_exists(null)) return null; + + try { + const [success, contents] = file.load_contents(null); + if (!success) return null; + return new TextDecoder().decode(contents); + } catch (e) { + console.error(`Failed to read file ${path}: ${e.message}`); + return null; + } +} + +// Decode HTML entities comprehensively +function decodeHTMLEntities(text) { + const entities = { + ' ': ' ', '&': '&', '<': '<', '>': '>', + '"': '"', ''': "'", '©': '©', '®': '®', + '“': '"', '”': '"', '‘': "'", '’': "'", + '—': '—', '–': '–', '…': '…' + }; + // Replace named entities + let result = text.replace(/&[a-zA-Z]+;/g, match => entities[match] || match); + // Replace numeric entities + result = result.replace(/&#(\d+);/g, (match, code) => String.fromCharCode(parseInt(code))); + result = result.replace(/&#x([0-9a-f]+);/gi, (match, code) => String.fromCharCode(parseInt(code, 16))); + return result; +} + +// Standard response formatter +function createTextResponse(text) { + return { + content: [{ type: 'text', text }] + }; +} + +function htmlToMarkdown(html) { + // 1. Remove scripts, styles, head, meta + let text = html.replace(/]*>([\s\S]*?)<\/script>/gim, "") + .replace(/]*>([\s\S]*?)<\/style>/gim, "") + .replace(/]*>([\s\S]*?)<\/head>/gim, "") + .replace(/]*>/gim, ""); + + // 2. Extract body if present + const bodyMatch = text.match(/]*>([\s\S]*?)<\/body>/im); + if (bodyMatch) text = bodyMatch[1]; + + // 3. Handle code blocks first to preserve content (don't decode here - will decode after restoration) + const codeBlocks = []; + const saveCode = (content, isBlock) => { + const placeholder = `__CODEBLOCK_${codeBlocks.length}__`; + codeBlocks.push(isBlock ? "```\n" + content + "\n```" : "`" + content + "`"); + return placeholder; + }; + + // Handle
...
+ text = text.replace(/]*>\s*]*>([\s\S]*?)<\/code>\s*<\/pre>/gim, (match, content) => saveCode(content, true)); + // Handle
...
+ text = text.replace(/]*>([\s\S]*?)<\/pre>/gim, (match, content) => saveCode(content, true)); + // Handle ... + text = text.replace(/]*>([\s\S]*?)<\/code>/gim, (match, content) => saveCode(content, false)); + + // 4. Convert headers + text = text.replace(/]*>([\s\S]*?)<\/h\1>/gim, (match, level, content) => { + return "\n\n" + "#".repeat(parseInt(level)) + " " + content.trim() + "\n\n"; + }); + + // 5. Convert links + text = text.replace(/]*?\s+)?href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gim, (match, url, content) => { + return `[${content.trim()}](${url})`; + }); + + // 6. Convert lists + text = text.replace(/]*>/gim, "\n- "); + text = text.replace(/<\/li>/gim, ""); + text = text.replace(/<\/?ul[^>]*>/gim, ""); + text = text.replace(/<\/?ol[^>]*>/gim, ""); + + // 7. Convert paragraphs and line breaks + text = text.replace(/]*>/gim, "\n\n"); + text = text.replace(/<\/p>/gim, ""); + text = text.replace(//gim, "\n"); + text = text.replace(//gim, "\n---\n"); + text = text.replace(/<\/?div[^>]*>/gim, "\n"); + + // 8. Basic formatting + text = text.replace(/<(?:b|strong)[^>]*>([\s\S]*?)<\/(?:b|strong)>/gim, "**$1**"); + text = text.replace(/<(?:i|em)[^>]*>([\s\S]*?)<\/(?:i|em)>/gim, "*$1*"); + + // 9. Strip remaining tags + text = text.replace(/<[^>]+>/g, ""); + + // 10. Restore code blocks with decoded entities + text = text.replace(/__CODEBLOCK_(\d+)__/g, (match, index) => { + return codeBlocks[parseInt(index)]; + }); + + // 11. Decode entities (once, comprehensively) + text = decodeHTMLEntities(text); + + // 12. Normalize whitespace (but not aggressively - preserve intentional formatting) + text = text.replace(/[ \t]+/g, " "); + text = text.replace(/\n\s*\n\s*\n/g, "\n\n"); + + return text.trim(); +} + +// Initialize docs +loadDocs(); + +// ==================================================== +// Server Instantiation +// ==================================================== + +export function runServer(args) { + // Check command line args for mode + // Usage: ./biblioteca-server.js --http + const mode = args.includes('--http') ? 'http' : 'stdio'; + + const server = new MCPServer("biblioteca-docs", "1.0.0", { transport: mode, port: 8080 }); + + // 1. List Docs + server.tool( + "list_docs", + "Lists available documentation from the global index.", + { type: "object", properties: {}, required: [] }, + async () => { + if (!doc_index) throw new Error("Documentation index not loaded."); + + // LOGGING: Check the size before sending (visible in MCP logs) + const count = doc_index.docs.length; + server.log(1, `Found ${count} top-level entries.`); + + // OPTIMIZATION: Map to a lighter structure + // We strip out 'search_name' (full text) and 'children' (if deep/heavy) + // or just pick the UI-relevant fields. + const lightDocs = doc_index.docs.map(doc => ({ + name: doc.name, + tag: doc.tag, + uri: doc.uri, + // Include children only if they are just headers, + // but if they contain text, you might want to strip them or map them recursively. + children: doc.children ? doc.children.map(c => ({ + name: c.name, + uri: c.uri + })) : [] + })); + + // Log the approximate size of the payload to confirm we are not sending too much data + const payloadSize = JSON.stringify(lightDocs).length; + server.log(1, `Payload size: ~${Math.round(payloadSize/1024)}KB`); + + return createTextResponse(JSON.stringify({ docs: lightDocs })); + } + ); + + // 2. Search Docs + server.tool( + "search_docs", + "Search documentation using fuzzy search.", + { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"] + }, + ({ query }) => { + if (!flattened_docs.length) { + return createTextResponse("Documentation index empty or not loaded."); + } + + // Validate query type + if (typeof query !== 'string') { + return createTextResponse(JSON.stringify({ + error: "Invalid query: must be a string", + results: [] + })); + } + + // Normalize query: remove whitespace and handle case sensitivity + // (matching the behavior in SearchView.js) + const needle = query.replace(/\s+/g, ""); + if (!needle) { + return createTextResponse(JSON.stringify({ results: [] })); + } + + const isCaseSensitive = needle.toLowerCase() !== needle; + const actualNeedle = isCaseSensitive ? needle : needle.toLowerCase(); + + const filteredResults = flattened_docs + .filter(item => { + // Ensure search_name exists and is a string + if (!item.search_name || typeof item.search_name !== 'string') { + return false; + } + const haystack = isCaseSensitive + ? item.search_name + : item.search_name.toLowerCase(); + return hasMatch(actualNeedle, haystack); + }) + .map(item => { + const haystack = isCaseSensitive + ? item.search_name + : item.search_name.toLowerCase(); + const s = score(actualNeedle, haystack); + return { item, score: s }; + }) + .filter(r => r.score > -Infinity) + .sort((a, b) => b.score - a.score) + .slice(0, 20) + .map(r => ({ + name: r.item.name, + tag: r.item.tag, + uri: r.item.uri, + score: r.score + })); + + return createTextResponse(JSON.stringify({ results: filteredResults })); + } + ); + + // 3. Read Resource + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + server.tool( + "read_resource", + "Read a documentation resource by URI.", + { + type: "object", + properties: { uri: { type: "string" } }, + required: ["uri"] + }, + async ({ uri }) => { + let path = uri; + if (uri.startsWith("file://")) { + path = GLib.filename_from_uri(uri)[0]; + } + + const rawContent = readFile(path); + if (!rawContent) { + return createTextResponse("File not found"); + } + + // Validate file size before processing + const fileSizeBytes = rawContent.length; + if (fileSizeBytes > MAX_FILE_SIZE) { + const fileSizeMB = (fileSizeBytes / 1024 / 1024).toFixed(2); + const maxSizeMB = (MAX_FILE_SIZE / 1024 / 1024).toFixed(2); + return createTextResponse( + `File too large (${fileSizeMB}MB). Maximum file size: ${maxSizeMB}MB` + ); + } + + const content = htmlToMarkdown(rawContent); + return createTextResponse(content); + } + ); + + // 4. Server Status + server.tool( + "server_status", + "Get the status of the documentation index and server health.", + { type: "object", properties: {}, required: [] }, + async () => { + if (!doc_index) { + return createTextResponse(JSON.stringify({ + status: "unhealthy", + message: "Documentation index failed to load", + docs_available: 0 + })); + } + return createTextResponse(JSON.stringify({ + status: "healthy", + message: "Documentation index loaded successfully", + docs_available: flattened_docs.length + })); + } + ); + + server.start(); +} + +if (programInvocationName.endsWith('mcp-server.js')) { + runServer(programArgs); +}