forked from balisujohn/localwriter
-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy path__init__.py
More file actions
499 lines (418 loc) · 20 KB
/
Copy path__init__.py
File metadata and controls
499 lines (418 loc) · 20 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
# WriterAgent - AI Writing Assistant for LibreOffice
# Copyright (c) 2024 John Balis
# Copyright (c) 2026 KeithCu (modifications and relicensing)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""HTTP server module — owns the HTTP server lifecycle."""
import logging
import threading
from typing import Any
from plugin.framework.module_base import ModuleBase
from plugin.mcp.cors import reload_cors_policy_from_config
from plugin.mcp.server import mcp_endpoint_url, format_mcp_start_failure, is_port_in_use_error
log = logging.getLogger("writeragent.http")
# LibreOffice may call bootstrap() more than once (e.g. sidebar vs menu UNO contexts). Each run
# constructs a new McpModule(), which would otherwise create a second registry and try to
# bind the same port. The first instance is canonical; later instances reuse its registry/server.
_primary_http_module: "McpModule | None" = None
_shared_registry: Any = None
_shared_http_server: Any = None
_http_peer_lock = threading.Lock()
# Last failed start — module-level so peer McpModule instances (second bootstrap) see the same
# reason when Toggle/Status run on a non-primary instance. Cleared on successful start.
_last_start_error: BaseException | None = None
_last_start_host: str = "localhost"
_last_start_port: Any = None
class McpModule(ModuleBase):
"""Manages the shared HTTP server and route registry.
Other modules (chatbot, doc) register routes via the
``http_routes`` service during their initialize() phase.
This module also handles the MCP (Model Context Protocol)
JSON-RPC routes if enabled.
This module starts the server in start_background() (phase 2b).
"""
def initialize(self, services):
global _primary_http_module, _shared_registry, _shared_http_server
from plugin.mcp.routes import HttpRouteRegistry
with _http_peer_lock:
if _primary_http_module is not None:
# Second (or later) bootstrap in this process: share registry and server state.
prim = _primary_http_module
self._registry = _shared_registry
self._server = _shared_http_server
self._services = services
self._mcp_protocol = prim._mcp_protocol
self._mcp_routes_registered = prim._mcp_routes_registered
self._srv_lock = prim._srv_lock
services.register("http_routes", self._registry)
log.info("McpModule initialize: reusing primary HTTP/MCP (mcp_enabled=%s, server=%s)", services.config.proxy_for(self.name).get("mcp_enabled"), "running" if (_shared_http_server and _shared_http_server.is_running()) else "stopped")
return
self._registry = HttpRouteRegistry()
_shared_registry = self._registry
services.register("http_routes", self._registry)
self._server = None
self._services = services
self._mcp_protocol = None
self._mcp_routes_registered = False
self._srv_lock = threading.Lock()
# Built-in endpoints
self._registry.add("GET", "/health", self._handle_health)
self._registry.add("GET", "/", self._handle_info)
self._registry.add("GET", "/api/config", self._handle_config_get)
self._registry.add("POST", "/api/config", self._handle_config_set)
# MCP endpoints
mcp_enabled = services.config.proxy_for(self.name).get("mcp_enabled")
log.info("McpModule initialize: mcp_enabled=%s", mcp_enabled)
if mcp_enabled:
self._register_mcp_routes(services)
reload_cors_policy_from_config(services)
if hasattr(services, "events"):
services.events.subscribe("config:changed", self._on_config_changed)
_primary_http_module = self
def _bound_http_server(self):
"""Server instance for this process: shared copy after primary starts, else this instance."""
global _shared_http_server
if _shared_http_server is not None:
return _shared_http_server
return self._server
def start_background(self, services):
# We start automatically if MCP is enabled.
if services.config.proxy_for(self.name).get("mcp_enabled"):
self._start_server(services)
def _on_config_changed(self, **data):
key = data.get("key", "")
prefix = f"{self.name}."
# Ignore keys owned by other modules; empty key = bulk save (e.g. Settings OK).
if key and not key.startswith(prefix):
return
toggle_key = f"{prefix}mcp_enabled"
cors_list_key = f"{prefix}cors_allowed_origins"
cors_private_key = f"{prefix}cors_allow_private_origins"
# MCP lifecycle: toggle, CORS policy keys, or bulk apply (Settings OK).
if key and key not in (toggle_key, cors_list_key, cors_private_key, ""):
return
reload_cors_policy_from_config(self._services)
cfg = self._services.config.proxy_for(self.name)
enabled = cfg.get("mcp_enabled")
log.info("HTTP/MCP config sync (key=%r): mcp_enabled=%s", key or "(bulk)", enabled)
if enabled and not self._mcp_routes_registered:
self._register_mcp_routes(self._services)
elif not enabled and self._mcp_routes_registered:
self._unregister_mcp_routes(self._services)
bound = self._bound_http_server()
if enabled and not (bound and bound.is_running()):
ok = self._start_server(self._services)
# Settings OK emits bulk config:changed with an empty key. Show the failure there
# (user-initiated). Toggle uses mcp_enabled key then shows its own dialog — skip
# that key here to avoid a double msgbox. Bootstrap never goes through this path.
if not ok and not key:
self._show_start_failure_dialog(data.get("ctx"))
elif not enabled and bound:
self._stop_server()
def _clear_start_failure(self) -> None:
global _last_start_error, _last_start_host, _last_start_port
_last_start_error = None
_last_start_host = "localhost"
_last_start_port = None
def _record_start_failure(self, host: str, port: Any, exc: BaseException) -> None:
global _last_start_error, _last_start_host, _last_start_port
_last_start_error = exc
_last_start_host = host or "localhost"
_last_start_port = port
def _formatted_start_failure(self) -> str:
if _last_start_error is None:
return ""
port = _last_start_port if _last_start_port is not None else "?"
return format_mcp_start_failure(_last_start_host, port, _last_start_error)
def _start_failure_reportable(self) -> bool:
# Port conflicts are local config, not product bugs — don't nudge a GitHub report.
if _last_start_error is None:
return True
return not is_port_in_use_error(_last_start_error)
def _show_start_failure_dialog(self, ctx=None) -> None:
from plugin.chatbot.dialogs import msgbox_with_report
from plugin.framework.i18n import _
from plugin.framework.uno_context import get_ctx
if ctx is None:
ctx = get_ctx()
detail = self._formatted_start_failure()
if detail:
message = _("MCP server failed to start") + "\n" + detail
else:
message = _("MCP server failed to start") + "\n" + _("Check writeragent_debug.log in your LibreOffice user config folder")
msgbox_with_report(
ctx,
"WriterAgent",
message,
box_type=3,
reportable=self._start_failure_reportable(),
report_title="MCP server failed to start",
report_extra=detail,
)
def _start_server(self, services) -> bool:
import os
if os.environ.get("WRITERAGENT_TESTING"):
return True
global _shared_http_server
from plugin.mcp.server import HttpServer
reload_cors_policy_from_config(services)
with self._srv_lock:
bound = self._bound_http_server()
if bound is not None and bound.is_running():
self._clear_start_failure()
return True
cfg = services.config.proxy_for(self.name)
event_bus = getattr(services, "events", None)
host = cfg.get("host") or "localhost"
port = cfg.get("mcp_port")
# Schema default is mcp/module.yaml mcp_port; ConfigService supplies it when unset.
srv = HttpServer(
route_registry=self._registry,
port=port,
host=host,
use_ssl=cfg.get("use_ssl") or False,
ssl_cert=cfg.get("ssl_cert") or "",
ssl_key=cfg.get("ssl_key") or "",
)
try:
srv.start()
if event_bus:
status = srv.get_status()
event_bus.emit("http:server_started", port=status["port"], host=status["host"], url=status["url"])
if event_bus:
event_bus.emit("menu:update")
self._server = srv
_shared_http_server = srv
self._clear_start_failure()
return True
except Exception as e:
# Stash for Toggle/Status/Settings UI — previously only log.exception left a trail
# and the dialog said "check the debug log" with no host/port or bind reason (#379).
log.exception("Failed to start HTTP server")
self._record_start_failure(host, port, e)
try:
srv.stop()
except Exception:
log.debug("HttpServer.stop after failed start", exc_info=True)
return False
def _stop_server(self):
global _shared_http_server
with self._srv_lock:
srv = self._bound_http_server()
if not srv:
return
srv.stop()
self._server = None
_shared_http_server = None
if _primary_http_module is not None:
_primary_http_module._server = None
event_bus = getattr(self._services, "events", None)
if event_bus:
event_bus.emit("http:server_stopped", reason="shutdown")
event_bus.emit("menu:update")
def shutdown(self):
self._stop_server()
if self._mcp_routes_registered:
self._unregister_mcp_routes(self._services)
def _register_mcp_routes(self, services):
log.info("Registering MCP routes (SSE, /mcp, /debug)...")
from plugin.mcp.mcp_protocol import MCPProtocolHandler
self._mcp_protocol = MCPProtocolHandler(services)
p = self._mcp_protocol
# MCP streamable-http (raw — JSON-RPC + custom headers + SSE)
self._registry.add("POST", "/mcp", p.handle_mcp_post, raw=True)
self._registry.add("GET", "/mcp", p.handle_mcp_sse, raw=True)
self._registry.add("DELETE", "/mcp", p.handle_mcp_delete, raw=True)
# Legacy SSE transport (raw — streaming)
self._registry.add("POST", "/sse", p.handle_sse_post, raw=True)
self._registry.add("POST", "/messages", p.handle_sse_post, raw=True)
self._registry.add("GET", "/sse", p.handle_sse_stream, raw=True)
# Debug (simple — returns dict, server handles JSON)
self._registry.add("GET", "/debug", p.handle_debug_info)
# Debug POST (raw — complex response handling)
self._registry.add("POST", "/debug", p.handle_debug_post, raw=True)
self._mcp_routes_registered = True
log.info("MCP routes registered on HTTP server")
def _unregister_mcp_routes(self, services):
for method, path in [("POST", "/mcp"), ("GET", "/mcp"), ("DELETE", "/mcp"), ("POST", "/sse"), ("POST", "/messages"), ("GET", "/sse"), ("GET", "/debug"), ("POST", "/debug")]:
try:
self._registry.remove(method, path)
except Exception:
pass
self._mcp_routes_registered = False
self._mcp_protocol = None
log.info("MCP routes unregistered from HTTP server")
# ── Action dispatch ──────────────────────────────────────────────
def on_action(self, action):
if action == "toggle_server":
self._action_toggle_server()
elif action == "server_status":
self._action_server_status()
else:
super().on_action(action)
def get_menu_text(self, action):
from plugin.framework.i18n import _
if action == "toggle_server":
b = self._bound_http_server()
if b and b.is_running():
return _("Stop MCP Server")
return _("Start MCP Server")
return None
def get_menu_icon(self, action):
b = self._bound_http_server()
running = b and b.is_running()
if action == "toggle_server":
# Show target state: "start" icon when stopped, "stop" icon when running
return "stopped" if running else "running"
if action == "server_status":
return "running" if running else "stopped"
return None
def _action_toggle_server(self):
from plugin.chatbot.dialogs import msgbox
from plugin.framework.uno_context import get_ctx
from plugin.framework.i18n import _
ctx = get_ctx()
b = self._bound_http_server()
if b and b.is_running():
log.info("Stopping MCP server via toggle")
self._stop_server()
msgbox(ctx, "WriterAgent", _("MCP server stopped"))
else:
log.info("Starting MCP server via toggle")
cfg = self._services.config.proxy_for(self.name)
if not cfg.get("mcp_enabled"):
cfg.set("mcp_enabled", True)
elif not self._mcp_routes_registered:
self._register_mcp_routes(self._services)
self._start_server(self._services)
else:
self._start_server(self._services)
b2 = self._bound_http_server()
if b2 and b2.is_running():
status = b2.get_status()
mcp_url = status.get("mcp_url", status.get("url", ""))
msgbox(ctx, "WriterAgent", _("MCP server started") + "\n{0}".format(mcp_url))
else:
self._show_start_failure_dialog(ctx)
def _not_running_status_message(self) -> str:
from plugin.framework.i18n import _
msg = _("MCP server is not running")
detail = self._formatted_start_failure()
if detail:
# One short reason: first line is host:port + exception; enough for Status.
first = detail.split("\n", 1)[0]
msg = msg + "\n" + first
return msg
def _action_server_status(self):
import unohelper
from com.sun.star.awt import XActionListener
from plugin.chatbot.dialogs import msgbox, load_writeragent_dialog
from plugin.framework.uno_context import get_ctx
from plugin.framework.i18n import _
ctx = get_ctx()
b = self._bound_http_server()
if not b:
msgbox(ctx, "WriterAgent", self._not_running_status_message())
return
status = b.get_status()
running = status.get("running", False)
if not running:
msgbox(ctx, "WriterAgent", self._not_running_status_message())
return
url = status.get("mcp_url", status.get("url", "?"))
routes = status.get("routes", 0)
msg = _("MCP server running") + "\n" + _("Routes: {0}").format(routes)
try:
assert ctx is not None
dlg = load_writeragent_dialog("ServerStatusDialog", ctx)
if dlg is None:
msgbox(ctx, "WriterAgent", msg + "\n" + _("URL: {0}").format(url))
return
msg_ctrl = dlg.getControl("Msg")
if msg_ctrl is not None:
msg_ctrl.getModel().Label = msg
url_ctrl = dlg.getControl("UrlField")
if url_ctrl is not None:
url_ctrl.setText(url)
class _OkListener(unohelper.Base, XActionListener):
def actionPerformed(self, rEvent):
dlg.endDialog(1)
def disposing(self, Source):
pass
ok_btn = dlg.getControl("OKBtn")
if ok_btn is not None:
ok_btn.addActionListener(_OkListener())
dlg.execute()
dlg.dispose()
except Exception:
log.exception("Status dialog error")
msgbox(ctx, "WriterAgent", msg + "\n" + _("URL: {0}").format(url))
# ---- Built-in route handlers ----
def _handle_health(self, body, headers, query):
from plugin.version import EXTENSION_VERSION
return (200, {"status": "healthy", "server": "WriterAgent", "version": EXTENSION_VERSION})
def _mcp_endpoint_from_config(self):
cfg = self._services.config.proxy_for(self.name)
return mcp_endpoint_url(cfg.get("host") or "localhost", cfg.get("mcp_port"), bool(cfg.get("use_ssl")))
def _handle_info(self, body, headers, query):
log.info("Request: GET / (info) from %s", headers.get("User-Agent"))
from plugin.version import EXTENSION_VERSION
routes = self._registry.list_routes()
info = {"name": "WriterAgent", "version": EXTENSION_VERSION, "description": "WriterAgent HTTP server", "routes": ["%s %s" % (m, p) for m, p in sorted(routes)]}
if self._mcp_routes_registered:
info["mcp_endpoint"] = self._mcp_endpoint_from_config()
return (200, info)
def _handle_config_get(self, body, headers, query):
"""GET /api/config — read config values.
Query params:
?key=ai_ollama.instances → single key
?prefix=ai_ollama → all keys with prefix
(none) → all config
"""
cfg = self._services.config
key = (query.get("key") or [None])[0]
if key:
val = cfg.get(key)
return (200, {"key": key, "value": val})
module = (query.get("module") or [None])[0]
prefix = (query.get("prefix") or [None])[0]
all_config = cfg.get_dict()
if module:
p = module if module.endswith(".") else module + "."
filtered = {k: v for k, v in all_config.items() if k.startswith(p)}
return (200, {"config": filtered})
if prefix:
filtered = {k: v for k, v in all_config.items() if k.startswith(prefix)}
return (200, {"config": filtered})
return (200, {"config": all_config})
def _handle_config_set(self, body, headers, query):
"""POST /api/config — write config values.
Body: {"key": "value", ...}
"""
if not body or not isinstance(body, dict):
return (400, {"error": "Body must be a JSON object of key-value pairs"})
cfg = self._services.config
errors = []
written = []
for key, value in body.items():
try:
cfg.set(key, value)
written.append(key)
except Exception as e:
errors.append({"key": key, "error": str(e)})
result = {"written": written}
if errors:
result["errors"] = errors
return (207, result)
return (200, result)