Coverage for src/lilbee/mcp_server.py: 100%

714 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-08 09:20 +0000

1"""MCP server exposing lilbee as tools for AI agents.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import concurrent.futures 

7import functools 

8import inspect 

9import json 

10import logging 

11import os 

12import re 

13import threading 

14import uuid 

15from collections.abc import Callable, Iterator 

16from contextlib import contextmanager 

17from contextvars import ContextVar 

18from copy import deepcopy 

19from dataclasses import asdict 

20from pathlib import Path 

21from typing import TYPE_CHECKING, Any, TypeVar, cast 

22from weakref import WeakKeyDictionary 

23 

24import anyio 

25from mcp.server.mcpserver import Context, MCPServer 

26from mcp.types import Tool as MCPTool 

27 

28from lilbee.app.memory import ( 

29 MEMORY_DISABLED_HINT, 

30 forget, 

31 list_memories, 

32 memory_enabled, 

33 recall, 

34 remember, 

35) 

36from lilbee.app.placement import ( 

37 PlacementView, 

38 get_placement, 

39 placement_refused_message, 

40 preview_placement, 

41 set_placement, 

42) 

43from lilbee.app.search import clean_result 

44from lilbee.app.services import get_services, reset_services, reset_store 

45from lilbee.app.settings import ( 

46 SettingInfo, 

47 apply_settings_update, 

48 config_write_failure_message, 

49 get_setting, 

50 list_settings, 

51 provider_reset_refused_message, 

52 requires_services_reset, 

53 reset_settings, 

54) 

55from lilbee.catalog.types import ModelSource 

56from lilbee.core.config import cfg 

57from lilbee.core.config.enums import CrawlRenderMode 

58from lilbee.core.settings import overlay_persisted_settings 

59from lilbee.core.system import LOCAL_ROOT_DIRNAME, canonical_data_root 

60from lilbee.crawler import crawler_available, is_url, require_valid_crawl_url 

61from lilbee.crawler.task import get_task, start_crawl 

62from lilbee.data.store import ( 

63 EmbeddingModelMismatchError, 

64 MemoryKind, 

65 MemorySource, 

66 SearchScope, 

67 agent_owner, 

68 scope_to_chunk_type, 

69) 

70from lilbee.runtime.cancellation import TaskCancelledError 

71from lilbee.sessions import ( 

72 AGENT_SESSIONS_DISABLED_HINT, 

73 MessageRole, 

74 Session, 

75 SessionMessage, 

76 SessionNotFoundError, 

77 SessionOrigin, 

78 SessionOwnershipError, 

79 TitleSource, 

80 agent_sessions_enabled, 

81) 

82from lilbee.wiki.shared import ( 

83 INVALID_DRAFT_SLUG_ERROR, 

84 WIKI_DISABLED_ERROR, 

85 WikiSubdir, 

86 total_wiki_pages, 

87) 

88 

89if TYPE_CHECKING: 

90 from lilbee.providers.fleet.placement_spec import PlacementSpec 

91 

92log = logging.getLogger(__name__) 

93 

94_INSTRUCTIONS = ( 

95 "Local search engine over the user's files, code, and crawled pages. " 

96 "For any question about the user's own documents or codebase -- a lookup, a " 

97 "find-in-docs, 'where is X', 'how does Y work here' -- call lilbee_search first " 

98 "and answer from its cited chunks. Prefer it over web-fetch or file-read tools: " 

99 "those cannot see the indexed corpus." 

100) 

101 

102 

103class _TransportState: 

104 """Process-level MCP transport facts. 

105 

106 ``http_mounted`` is True only when MCP is exposed over the shared 

107 streamable-http daemon (set by build_mcp_mount). On that transport multiple 

108 agents share one process and one global cfg/Services singleton, so 

109 vault-switching (init) and factory reset are refused: switching or tearing 

110 down the store under concurrent in-flight handlers is a use-after-close / 

111 identity race. stdio (one agent per process) keeps both. 

112 """ 

113 

114 http_mounted: bool = False 

115 

116 

117_transport = _TransportState() 

118 

119 

120def set_http_mounted(value: bool) -> None: 

121 """Mark whether this process serves MCP over the shared HTTP daemon.""" 

122 _transport.http_mounted = value 

123 

124 

125_F = TypeVar("_F", bound=Callable[..., Any]) 

126 

127# Set by the offload for the duration of one sync handler and read by the long 

128# ones. A parameter would reach the generated tool schema and invite an agent to 

129# pass it; the handlers keep the plain signature that in-process callers use. 

130_CANCEL: ContextVar[threading.Event | None] = ContextVar("lilbee_mcp_cancel", default=None) 

131 

132 

133def _caller_cancelled() -> threading.Event | None: 

134 """The stop token for the handler running on this thread, if the offload set one.""" 

135 return _CANCEL.get() 

136 

137 

138def _offload_sync(fn: _F) -> _F: 

139 """Run a sync tool handler off the event loop; async handlers pass through. 

140 

141 The bundled mcp SDK calls sync tool handlers directly on the event loop, so 

142 under the shared streamable-http daemon one slow handler would stall every 

143 connected agent. ``functools.wraps`` preserves the wrapped signature so the 

144 generated tool schema is unchanged. 

145 

146 ``abandon_on_cancel`` releases the caller the moment it cancels. A worker 

147 thread cannot be interrupted, so the handler still runs to completion; the 

148 default would additionally hold the cancelling task until it did, which on 

149 the shared mount keeps a connection open for a request nobody is waiting 

150 for. Work that must actually stop takes a cancel token instead, as the 

151 ingest tools do. 

152 """ 

153 if inspect.iscoroutinefunction(fn): 

154 return fn 

155 

156 @functools.wraps(fn) 

157 async def _runner(*args: Any, **kwargs: Any) -> Any: 

158 with _cancel_token() as token: 

159 _CANCEL.set(token) 

160 return await anyio.to_thread.run_sync( 

161 functools.partial(fn, *args, **kwargs), abandon_on_cancel=True 

162 ) 

163 

164 return cast("_F", _runner) 

165 

166 

167# (handler, wire name override, gate) applied by build_mcp_server per instance. 

168_REGISTRATIONS: list[tuple[Callable[..., Any], str | None, Callable[[], bool] | None]] = [] 

169 

170 

171def _tool(fn: _F) -> _F: 

172 """Register *fn* as an MCP tool with sync handlers offloaded off the loop. 

173 

174 Returns the original callable so in-process callers (tests, the stdio 

175 fallback) keep the synchronous API while the schema sees the offloaded form. 

176 """ 

177 _REGISTRATIONS.append((_offload_sync(fn), None, None)) 

178 return fn 

179 

180 

181def _tool_named(name: str) -> Callable[[_F], _F]: 

182 """Register an MCP tool under an explicit wire *name* (sync handlers offloaded).""" 

183 

184 def deco(fn: _F) -> _F: 

185 _REGISTRATIONS.append((_offload_sync(fn), name, None)) 

186 return fn 

187 

188 return deco 

189 

190 

191def _tool_if(when: Callable[[], bool]) -> Callable[[_F], _F]: 

192 """Register an MCP tool gated on *when*, evaluated at server-build time. 

193 

194 The function stays importable so direct callers (tests, in-process 

195 fallback) can still reach it. A server built after a config change 

196 carries the current tool surface; live servers keep theirs. 

197 """ 

198 if not callable(when): 

199 raise TypeError("_tool_if takes a zero-arg callable, evaluated per build") 

200 

201 def deco(fn: _F) -> _F: 

202 _REGISTRATIONS.append((_offload_sync(fn), None, when)) 

203 return fn 

204 

205 return deco 

206 

207 

208# Settings read once per server build to decide which tools register. Writing 

209# one over ``settings_set`` persists it, but the tool list only changes on the 

210# next connection: MCP sends no ``tools/list_changed`` from here. The settings 

211# reference documents that, and generates the list from this set. 

212TOOL_GATE_SETTINGS: frozenset[str] = frozenset({"wiki", "memory_enabled", "mcp_sessions_enabled"}) 

213 

214 

215def _wiki_enabled() -> bool: 

216 return cfg.wiki 

217 

218 

219def _error(msg: str) -> dict[str, Any]: 

220 """Uniform error envelope MCP tool handlers return on a failure path. 

221 

222 Typed as ``dict[str, Any]`` rather than a TypedDict so it composes 

223 with the success-side returns under the existing handler signatures 

224 without forcing every caller to widen its return type. 

225 """ 

226 return {"error": msg} 

227 

228 

229def _wiki_tool(fn: _F) -> _F: 

230 """Register a wiki MCP tool: gated at build time, re-checked per call. 

231 

232 A live server keeps the tool surface it was built with, so every handler 

233 re-reads ``cfg.wiki`` and refuses once the setting is turned off at runtime. 

234 """ 

235 

236 @functools.wraps(fn) 

237 def guarded(*args: Any, **kwargs: Any) -> Any: 

238 if not cfg.wiki: 

239 return _error(WIKI_DISABLED_ERROR) 

240 return fn(*args, **kwargs) 

241 

242 checked = cast("_F", guarded) 

243 _tool_if(_wiki_enabled)(checked) 

244 return checked 

245 

246 

247@_tool 

248def search( 

249 query: str, top_k: int | None = None, scope: str = SearchScope.BOTH.value 

250) -> list[dict[str, Any]] | dict[str, Any]: 

251 """Search the user's indexed documents, code, and crawled pages; prefer it over web-fetch or 

252 file-read tools. Returns chunks with citations. ``scope``: "both" (default) / "raw" / "wiki".""" 

253 if not query or not query.strip(): 

254 return _error("query must not be empty") 

255 try: 

256 chunk_type = scope_to_chunk_type(scope) 

257 except ValueError: 

258 # Smaller models routinely echo prose like "indexed docs" or "all" 

259 # back as the scope value. Treat unrecognised scopes as the default 

260 # "both" rather than a hard failure so the request still does work. 

261 log.warning("lilbee_search: unknown scope %r, falling back to %r", scope, SearchScope.BOTH) 

262 chunk_type = scope_to_chunk_type(SearchScope.BOTH.value) 

263 if top_k is not None and top_k < 1: 

264 # Same lenient stance as the scope fallback above: do the search with 

265 # the configured default rather than hard-failing the agent's call. 

266 log.warning("lilbee_search: top_k %d is not positive, using the default", top_k) 

267 top_k = None 

268 effective_top_k = top_k if top_k is not None else cfg.top_k 

269 try: 

270 results = get_services().searcher.search( 

271 query, top_k=effective_top_k, chunk_type=chunk_type 

272 ) 

273 return [clean_result(r) for r in results] 

274 except EmbeddingModelMismatchError as exc: 

275 # Structured so an agent can offer to adopt the index's embedder 

276 # rather than parse prose out of a generic error. Names the embedder, 

277 # which the HTTP search route keeps out of its generic 503. 

278 return { 

279 "error": str(exc), 

280 "code": "INDEX_EMBEDDER_MISMATCH", 

281 "persisted_model": exc.persisted_model, 

282 "persisted_dim": exc.persisted_dim, 

283 "adoptable": exc.dims_match, 

284 } 

285 except Exception as exc: 

286 return _error(str(exc)) 

287 

288 

289@_tool 

290def status() -> dict[str, Any]: 

291 """Show indexed documents, configuration, and chunk counts.""" 

292 sources = get_services().store.get_sources() 

293 return { 

294 "config": { 

295 "documents_dir": str(cfg.documents_dir), 

296 "data_dir": str(cfg.data_dir), 

297 "chat_model": cfg.chat_model, 

298 "embedding_model": cfg.embedding_model, 

299 "vision_model": cfg.vision_model, 

300 "reranker_model": cfg.reranker_model, 

301 "enable_ocr": cfg.enable_ocr, 

302 "num_ctx": cfg.num_ctx, 

303 "num_ctx_max": cfg.num_ctx_max, 

304 "chat_n_ctx_target": cfg.chat_n_ctx_target, 

305 "flash_attention": cfg.flash_attention, 

306 "kv_cache_type": cfg.kv_cache_type.value, 

307 "n_gpu_layers": cfg.n_gpu_layers, 

308 "cpu_moe": cfg.cpu_moe, 

309 "n_cpu_moe": cfg.n_cpu_moe, 

310 "main_gpu": cfg.main_gpu, 

311 "gpu_devices": cfg.gpu_devices, 

312 }, 

313 "sources": [ 

314 {"filename": s["filename"], "chunk_count": s["chunk_count"]} 

315 for s in sorted(sources, key=lambda x: x["filename"]) 

316 ], 

317 "total_chunks": sum(s["chunk_count"] for s in sources), 

318 "entities": _entity_status_dict(), 

319 } 

320 

321 

322def _entity_status_dict() -> dict[str, Any] | None: 

323 """Entity types + extracted rows, mirroring the HTTP status section.""" 

324 from lilbee.app.status import entity_status 

325 

326 section = entity_status() 

327 return section.model_dump() if section is not None else None 

328 

329 

330@contextmanager 

331def _cancel_token() -> Iterator[threading.Event]: 

332 """A stop token for a long operation, set on any abnormal exit. 

333 

334 An agent cancelling a tool call gets what the HTTP surface gets on a client 

335 disconnect: the work stops at its next boundary and keeps what it finished, 

336 rather than running the rest of a corpus, download or wiki build for a 

337 request that has gone away. The work runs on a thread asyncio cannot 

338 interrupt, which is why cancellation alone does not reach it and the token 

339 has to be polled. 

340 

341 Set on every exception, not only cancellation: a pass that raised is over 

342 either way, and a caller vanishing does not always surface as a cancel. 

343 """ 

344 token = threading.Event() 

345 try: 

346 yield token 

347 except BaseException: 

348 token.set() 

349 raise 

350 

351 

352@_tool 

353async def sync( 

354 force_rebuild: bool = False, retry_skipped: bool = False, prune_ignored: bool = False 

355) -> dict[str, Any]: 

356 """Sync the documents directory into the vector store. 

357 

358 ``force_rebuild`` drops every table and re-ingests. ``retry_skipped`` 

359 clears failed-file skip markers without dropping the store. 

360 ``prune_ignored`` drops sources a ``.lilbeeignore`` now excludes; without it, 

361 the patterns only govern what sync takes in. 

362 """ 

363 from lilbee.data.ingest import sync as run_sync 

364 

365 with _cancel_token() as cancel: 

366 return ( 

367 await run_sync( 

368 quiet=True, 

369 force_rebuild=force_rebuild, 

370 retry_skipped=retry_skipped, 

371 prune_ignored=prune_ignored, 

372 cancel=cancel, 

373 ) 

374 ).model_dump() 

375 

376 

377@_tool 

378async def add( 

379 paths: list[str], 

380 force: bool = False, 

381 enable_ocr: bool | None = None, 

382 ocr_timeout: float | None = None, 

383 render_mode: CrawlRenderMode | None = None, 

384) -> dict[str, Any]: 

385 """Add files, directories, or URLs to the knowledge base, then sync. 

386 Paths are absolute and resolve on the machine running lilbee, which is not 

387 the caller's machine when the server is remote. URLs are fetched as single 

388 pages; use ``crawl`` for sites.""" 

389 from lilbee.app.ingest import register_sources 

390 from lilbee.data.ingest import sync as run_sync 

391 

392 errors: list[str] = [] 

393 valid: list[Path] = [] 

394 urls: list[str] = [] 

395 for p_str in paths: 

396 if is_url(p_str): 

397 urls.append(p_str) 

398 else: 

399 p = Path(p_str) 

400 if not p.exists(): 

401 # Name the machine and the root. A remote caller sends paths 

402 # from its own filesystem, and a bare path in `errors` reads as 

403 # a transient hiccup: agents took it as success and moved on. 

404 errors.append( 

405 f"{p_str}: no such file or directory on the lilbee server. " 

406 f"Paths resolve on the server, whose documents root is " 

407 f"{cfg.documents_dir}." 

408 ) 

409 else: 

410 valid.append(p) 

411 

412 # Crawl URLs 

413 crawled_count = 0 

414 if urls: 

415 from lilbee.crawler import crawler_available 

416 

417 if not crawler_available(): 

418 return _error("Web crawling requires: pip install 'lilbee[crawler]'") 

419 from lilbee.crawler import crawl_and_save 

420 

421 for url in urls: 

422 try: 

423 # URL validation resolves the host (blocking DNS); run it off the 

424 # event loop like the sibling crawl tool does. 

425 await anyio.to_thread.run_sync(require_valid_crawl_url, url) 

426 except ValueError as exc: 

427 errors.append(f"{url}: {exc}") 

428 continue 

429 # add fetches single pages (depth=0); site crawls go through the crawl tool 

430 crawled_paths = await crawl_and_save(url, depth=0, render_mode=render_mode) 

431 crawled_count += len(crawled_paths) 

432 

433 # Registration touches config.toml (a locked read-modify-write); keep the 

434 # blocking disk I/O off the event loop. 

435 reg_result = await anyio.to_thread.run_sync( 

436 functools.partial(register_sources, valid, force=force) 

437 ) 

438 errors.extend(reg_result.refused) 

439 

440 from lilbee.app.ingest import temporary_ocr_config 

441 

442 with temporary_ocr_config(enable_ocr, ocr_timeout), _cancel_token() as cancel: 

443 sync_result = (await run_sync(quiet=True, cancel=cancel)).model_dump() 

444 

445 result: dict[str, Any] = { 

446 "command": "add", 

447 "copied": reg_result.registered, 

448 "skipped": reg_result.skipped, 

449 "tracked": reg_result.tracked, 

450 "crawled": crawled_count, 

451 "errors": errors, 

452 "sync": sync_result, 

453 } 

454 indexed = len(reg_result.registered) + len(reg_result.tracked) + crawled_count 

455 if errors and not indexed: 

456 # Nothing was added. Returning the success shape with a warning let a 

457 # caller report the add as done over an untouched index. 

458 return _error("add indexed nothing. " + " ".join(errors)) 

459 if errors or sync_result.get("failed"): 

460 result["warning"] = "some files could not be processed" 

461 return result 

462 

463 

464@_tool_if(crawler_available) 

465async def crawl( 

466 url: str, 

467 depth: int | None = 0, 

468 max_pages: int | None = None, 

469 render_mode: CrawlRenderMode | None = None, 

470 include_subdomains: bool = False, 

471) -> dict[str, Any]: 

472 """Start a non-blocking crawl; poll via ``crawl_status(task_id)``. 

473 ``depth=0`` (default) = single URL, ``N`` = follow links N levels, 

474 ``null`` = whole site. ``render_mode``: "http"/"browser".""" 

475 from lilbee.crawler import crawler_available 

476 

477 if not crawler_available(): 

478 return _error("Web crawling requires: pip install 'lilbee[crawler]'") 

479 # Mirror the REST CrawlRequest bounds so a negative value is a clean error, 

480 # not an unbounded crawl. 

481 if depth is not None and depth < 0: 

482 return _error("depth must be 0 or greater (pass depth=null to crawl the whole site)") 

483 if max_pages is not None and max_pages < 0: 

484 return _error("max_pages must be 0 or greater (0 = unlimited, omit for the safety cap)") 

485 try: 

486 # URL validation resolves the host (blocking DNS), so it runs off the loop. 

487 # The crawl itself must be scheduled ON the loop: start_crawl uses 

488 # asyncio.create_task, which requires a running event loop. 

489 await anyio.to_thread.run_sync(require_valid_crawl_url, url) 

490 except ValueError as exc: 

491 return _error(str(exc)) 

492 

493 task_id = start_crawl( 

494 url, 

495 depth=depth, 

496 max_pages=max_pages, 

497 render_mode=render_mode, 

498 include_subdomains=include_subdomains, 

499 ) 

500 return {"status": "started", "task_id": task_id, "url": url} 

501 

502 

503@_tool_if(crawler_available) 

504def crawl_status(task_id: str) -> dict[str, Any]: 

505 """Poll a crawl task by id; returns ``{status, pages, error}``.""" 

506 task = get_task(task_id) 

507 if task is None: 

508 return _error(f"No task found with id: {task_id}") 

509 return { 

510 "task_id": task.task_id, 

511 "url": task.url, 

512 "status": task.status.value, 

513 "pages_crawled": task.pages_crawled, 

514 "pages_total": task.pages_total, 

515 "pages_failed": task.pages_failed, 

516 "failure_reasons": task.failure_reasons, 

517 "error": task.error, 

518 "started_at": task.started_at, 

519 "finished_at": task.finished_at, 

520 } 

521 

522 

523@_tool 

524def crawl_cancel(task_id: str) -> dict[str, Any]: 

525 """Stop a running crawl started by ``crawl``. Pages already saved are kept.""" 

526 from lilbee.crawler.task import cancel_crawl 

527 

528 if get_task(task_id) is None: 

529 return _error(f"No task found with id: {task_id}") 

530 return {"command": "crawl_cancel", "task_id": task_id, "cancelling": cancel_crawl(task_id)} 

531 

532 

533@_tool 

534def init(path: str = "") -> dict[str, Any]: 

535 """Initialize a local ``.lilbee/`` knowledge base; empty path = cwd. 

536 

537 Switches the MCP session to use it for subsequent calls. 

538 """ 

539 if _transport.http_mounted: 

540 return _error( 

541 "init is unavailable on the HTTP server: it is bound to one vault and " 

542 "shared by every connected client. Start a separate server for another vault." 

543 ) 

544 # Canonical so this vault keys the same lock paths a CLI or server process 

545 # would derive for the same directory. 

546 base = canonical_data_root(path) if path else canonical_data_root(Path.cwd()) 

547 root = base / LOCAL_ROOT_DIRNAME 

548 

549 created = False 

550 if not root.is_dir(): 

551 (root / "documents").mkdir(parents=True) 

552 (root / "data").mkdir(parents=True) 

553 (root / ".gitignore").write_text("data/\n", encoding="utf-8") 

554 created = True 

555 

556 # Switch MCP session to this project's KB. Overlay any persisted 

557 # config.toml so per-vault model / generation settings take effect, 

558 # matching the CLI's --data-dir behaviour. Env export mirrors 

559 # cli/app.py::_apply_data_root for worker-log parity. 

560 cfg.data_root = base 

561 cfg.documents_dir = root / "documents" 

562 cfg.data_dir = root / "data" 

563 cfg.lancedb_dir = root / "data" / "lancedb" 

564 os.environ["LILBEE_DATA"] = str(base) 

565 overlay_persisted_settings(base) 

566 reset_services() 

567 

568 return {"command": "init", "path": str(root), "created": created} 

569 

570 

571@_tool 

572def remove(names: list[str]) -> dict[str, Any]: 

573 """Remove documents from the index by source name, folder, or glob pattern. 

574 

575 Source files are never deleted. A folder name removes every document indexed 

576 beneath it; a glob (``*``/``?``/``[]``) removes every matching source.""" 

577 from lilbee.app.ingest import remove_documents_durably 

578 

579 result = remove_documents_durably(names) 

580 return {"command": "remove", "removed": result.removed, "not_found": result.not_found} 

581 

582 

583@_tool 

584def list_documents() -> dict[str, Any]: 

585 """List all indexed documents with their chunk counts.""" 

586 sources = get_services().store.get_sources() 

587 return { 

588 "documents": [ 

589 {"filename": s["filename"], "chunk_count": s.get("chunk_count", 0)} for s in sources 

590 ], 

591 "total": len(sources), 

592 } 

593 

594 

595_AGENT_ORIGINS = frozenset({SessionOrigin.MCP}) 

596 

597 

598def _require_agent_session(session_id: str) -> Session: 

599 """Human conversations are private: foreign ids answer not-found, so an 

600 agent cannot even probe which ones exist.""" 

601 session = get_services().session_store.get(session_id) 

602 if session.meta.origin is not SessionOrigin.MCP: 

603 raise SessionNotFoundError(session_id) 

604 return session 

605 

606 

607@_tool_if(agent_sessions_enabled) 

608def sessions_list() -> dict[str, Any]: 

609 """List the agent's sessions, newest first.""" 

610 if not agent_sessions_enabled(): 

611 return _error(AGENT_SESSIONS_DISABLED_HINT) 

612 metas = get_services().session_store.list(origins=_AGENT_ORIGINS) 

613 return {"sessions": [asdict(meta) for meta in metas], "total": len(metas)} 

614 

615 

616@_tool_if(agent_sessions_enabled) 

617def session_get(session_id: str) -> dict[str, Any]: 

618 """Return one agent session: metadata, transcript, summary.""" 

619 if not agent_sessions_enabled(): 

620 return _error(AGENT_SESSIONS_DISABLED_HINT) 

621 try: 

622 session = _require_agent_session(session_id) 

623 except SessionNotFoundError as exc: 

624 return _error(str(exc)) 

625 return { 

626 "meta": asdict(session.meta), 

627 "messages": [ 

628 { 

629 "role": message.role.value, 

630 "content": message.content, 

631 "sources": list(message.sources), 

632 "ts": message.ts, 

633 } 

634 for message in session.messages 

635 ], 

636 # What compaction folded the older turns into (empty if never compacted). 

637 # An agent that resumes and continues the conversation needs it, or it 

638 # rebuilds history without what was already condensed. 

639 "summary": session.summary, 

640 } 

641 

642 

643@_tool_if(agent_sessions_enabled) 

644def session_create(model_ref: str, scope: str = "both") -> dict[str, Any]: 

645 """Start a saved chat session; returns its id.""" 

646 if not agent_sessions_enabled(): 

647 return _error(AGENT_SESSIONS_DISABLED_HINT) 

648 session_id = get_services().session_store.create( 

649 model_ref=model_ref, scope=scope, origin=SessionOrigin.MCP 

650 ) 

651 return {"id": session_id, "model_ref": model_ref, "scope": scope} 

652 

653 

654@_tool_if(agent_sessions_enabled) 

655def session_add_message( 

656 session_id: str, 

657 role: MessageRole, 

658 content: str, 

659 sources: list[str] | None = None, 

660 claim: bool = False, 

661) -> dict[str, Any]: 

662 """Append one turn; a foreign session errors unless claim=True (ask first).""" 

663 if not agent_sessions_enabled(): 

664 return _error(AGENT_SESSIONS_DISABLED_HINT) 

665 store = get_services().session_store 

666 try: 

667 # Re-coerce: the MCP layer passes the enum, but a raw string still 

668 # arrives via direct library calls, and a bad one must error cleanly. 

669 message = SessionMessage( 

670 role=MessageRole(role), content=content, sources=tuple(sources or ()) 

671 ) 

672 if claim: 

673 store.transfer(session_id, SessionOrigin.MCP) 

674 store.add_message(session_id, message, surface=SessionOrigin.MCP) 

675 except (SessionNotFoundError, SessionOwnershipError) as exc: 

676 return _error(str(exc)) 

677 except ValueError as exc: 

678 return _error(f"invalid role {role!r}: {exc}") 

679 return {"id": session_id, "added": True} 

680 

681 

682@_tool_if(agent_sessions_enabled) 

683def session_set_summary(session_id: str, summary: str) -> dict[str, Any]: 

684 """Replace an agent session's compaction summary.""" 

685 if not agent_sessions_enabled(): 

686 return _error(AGENT_SESSIONS_DISABLED_HINT) 

687 try: 

688 _require_agent_session(session_id) 

689 get_services().session_store.set_summary(session_id, summary) 

690 except SessionNotFoundError as exc: 

691 return _error(str(exc)) 

692 return {"id": session_id, "summary": summary} 

693 

694 

695@_tool_if(agent_sessions_enabled) 

696def session_rename(session_id: str, title: str) -> dict[str, Any]: 

697 """Rename an agent session.""" 

698 if not agent_sessions_enabled(): 

699 return _error(AGENT_SESSIONS_DISABLED_HINT) 

700 try: 

701 _require_agent_session(session_id) 

702 get_services().session_store.set_title(session_id, title, TitleSource.CUSTOM) 

703 except SessionNotFoundError as exc: 

704 return _error(str(exc)) 

705 return {"id": session_id, "title": title} 

706 

707 

708@_tool_if(agent_sessions_enabled) 

709def session_delete(session_id: str) -> dict[str, Any]: 

710 """Delete an agent session.""" 

711 if not agent_sessions_enabled(): 

712 return _error(AGENT_SESSIONS_DISABLED_HINT) 

713 try: 

714 _require_agent_session(session_id) 

715 get_services().session_store.delete(session_id) 

716 except SessionNotFoundError as exc: 

717 return _error(str(exc)) 

718 return {"id": session_id, "deleted": True} 

719 

720 

721@_tool 

722def export_dataset(output: str, fmt: str = "", source: str = "") -> dict[str, Any]: 

723 """Write the per-page {source, page, text} dataset to a file (no vectors). 

724 

725 ``fmt`` is parquet/jsonl (empty infers from the suffix); ``source`` limits to one file. 

726 """ 

727 from lilbee.app.dataset import DatasetError, export_to_path 

728 

729 try: 

730 summary = export_to_path(Path(output), fmt, source or None, cancel=_caller_cancelled()) 

731 except DatasetError as exc: 

732 return _error(str(exc)) 

733 return summary.model_dump() 

734 

735 

736@_tool 

737async def import_dataset(dataset: str, fmt: str = "", ctx: Context | None = None) -> dict[str, Any]: 

738 """Import a per-page text dataset, re-embedding under the current model. 

739 

740 Replaces existing copies; imported sources are detached so sync won't delete them. 

741 """ 

742 from lilbee.app.dataset import DatasetError, import_from_path 

743 from lilbee.runtime.progress import EmbedEvent, EventType, ProgressEvent 

744 

745 loop = asyncio.get_running_loop() 

746 

747 with _cancel_token() as cancel: 

748 

749 def on_progress(event_type: EventType, data: ProgressEvent) -> None: 

750 # Raise rather than return: the embed work runs off the loop, so a 

751 # quiet return would re-embed the whole dataset for a caller that 

752 # has gone. Checked before the isinstance filter so the stop lands 

753 # on every event, not only the ones that map to a percent. 

754 if cancel.is_set(): 

755 raise TaskCancelledError 

756 # EMBED events carry chunk/total_chunks; other event types don't map to a percent. 

757 if ctx is None or not isinstance(data, EmbedEvent): 

758 return 

759 future = asyncio.run_coroutine_threadsafe( 

760 ctx.report_progress( 

761 progress=float(data.chunk), total=float(data.total_chunks), message=data.file 

762 ), 

763 loop, 

764 ) 

765 future.add_done_callback(_log_progress_failure) 

766 

767 try: 

768 summary = await import_from_path(Path(dataset), fmt, on_progress=on_progress) 

769 except DatasetError as exc: 

770 return _error(str(exc)) 

771 return summary.model_dump() 

772 

773 

774@_tool 

775def reset(confirm: bool = False) -> dict[str, Any]: 

776 """Factory reset: delete all documents and indexed data. Requires ``confirm=true``.""" 

777 if _transport.http_mounted: 

778 return _error( 

779 "reset is unavailable on the HTTP server: it would wipe the shared index for " 

780 "every connected client. Run it from the CLI or the stdio MCP server." 

781 ) 

782 if not confirm: 

783 return _error("pass confirm=true to confirm deletion") 

784 from lilbee.app.reset import perform_reset 

785 

786 result = perform_reset().model_dump() 

787 # Reopen LanceDB against the empty data dir; keep providers loaded. 

788 reset_store() 

789 return result 

790 

791 

792@_wiki_tool 

793def wiki_lint(wiki_source: str = "") -> dict[str, Any]: 

794 """Lint wiki pages; empty ``wiki_source`` lints all.""" 

795 from lilbee.wiki.lint import LintReport, lint_all, lint_wiki_page 

796 

797 store = get_services().store 

798 report = ( 

799 LintReport(issues=lint_wiki_page(wiki_source, store)) 

800 if wiki_source 

801 else lint_all(store, cancel=_caller_cancelled()) 

802 ) 

803 return { 

804 "command": "wiki_lint", 

805 "issues": [i.to_dict() for i in report.issues], 

806 "total": len(report.issues), 

807 "errors": report.error_count, 

808 "warnings": report.warning_count, 

809 } 

810 

811 

812@_wiki_tool 

813def wiki_citations(wiki_source: str = "", source: str = "") -> dict[str, Any]: 

814 """List a wiki page's citations, or with ``source``, the pages citing that document. 

815 

816 Pass exactly one of ``wiki_source`` (forward) or ``source`` (reverse). 

817 """ 

818 if bool(wiki_source) == bool(source): 

819 return _error("pass either wiki_source or source, not both") 

820 store = get_services().store 

821 if source: 

822 records = store.get_citations_for_source(source) 

823 return { 

824 "command": "wiki_citations", 

825 "source": source, 

826 "citations": [dict(r) for r in records], 

827 "total": len(records), 

828 } 

829 records = store.get_citations_for_wiki(wiki_source) 

830 return { 

831 "command": "wiki_citations", 

832 "wiki_source": wiki_source, 

833 "citations": [dict(r) for r in records], 

834 "total": len(records), 

835 } 

836 

837 

838@_tool 

839def wiki_status() -> dict[str, Any]: 

840 """Show wiki layer status: page counts, recent lint issues. 

841 

842 Registered even when the wiki is disabled, like the HTTP status route, so a 

843 caller can read the disabled state instead of finding no tool at all. 

844 """ 

845 from lilbee.wiki.lint import lint_all 

846 

847 wiki_root = cfg.data_root / cfg.wiki_dir 

848 if not cfg.wiki or not wiki_root.exists(): 

849 # Same keys as the enabled arm so a client can key on lint_errors in 

850 # either state; a disabled wiki reports zeros rather than being linted. 

851 return { 

852 "wiki_enabled": cfg.wiki, 

853 WikiSubdir.SUMMARIES: 0, 

854 WikiSubdir.DRAFTS: 0, 

855 "pages": 0, 

856 "lint_errors": 0, 

857 "lint_warnings": 0, 

858 } 

859 

860 summaries_dir = wiki_root / WikiSubdir.SUMMARIES 

861 drafts_dir = wiki_root / WikiSubdir.DRAFTS 

862 summaries = list(summaries_dir.rglob("*.md")) if summaries_dir.exists() else [] 

863 drafts = list(drafts_dir.rglob("*.md")) if drafts_dir.exists() else [] 

864 

865 # Read-only status: lint for counts without appending to the audit log. 

866 report = lint_all(get_services().store, record_log=False, cancel=_caller_cancelled()) 

867 return { 

868 "wiki_enabled": cfg.wiki, 

869 WikiSubdir.SUMMARIES: len(summaries), 

870 WikiSubdir.DRAFTS: len(drafts), 

871 "pages": total_wiki_pages(wiki_root), 

872 "lint_errors": report.error_count, 

873 "lint_warnings": report.warning_count, 

874 } 

875 

876 

877@_wiki_tool 

878def wiki_list() -> dict[str, Any]: 

879 """List wiki pages with metadata.""" 

880 from dataclasses import asdict 

881 

882 from lilbee.wiki.browse import list_pages 

883 

884 wiki_root = cfg.data_root / cfg.wiki_dir 

885 pages = list_pages(wiki_root) 

886 return { 

887 "command": "wiki_list", 

888 "pages": [asdict(p) for p in pages], 

889 "total": len(pages), 

890 } 

891 

892 

893@_wiki_tool 

894def wiki_read(slug: str) -> dict[str, Any]: 

895 """Read a wiki page's content + frontmatter by slug.""" 

896 from dataclasses import asdict 

897 

898 from lilbee.wiki.browse import read_page 

899 

900 wiki_root = cfg.data_root / cfg.wiki_dir 

901 result = read_page(wiki_root, slug) 

902 if result is None: 

903 return _error(f"wiki page not found: {slug}") 

904 return {"command": "wiki_read", **asdict(result)} 

905 

906 

907@_wiki_tool 

908def wiki_build(dry_run: bool = False) -> dict[str, Any]: 

909 """Build the concept and entity wiki across all ingested sources. Blocks until done. 

910 

911 ``dry_run=True`` returns the NER entity candidates a build would cover and 

912 makes no LLM call. 

913 """ 

914 from lilbee.wiki import run_full_build 

915 from lilbee.wiki.generation import DRY_RUN_CONCEPT_NOTE, preview_build_entities 

916 

917 if dry_run: 

918 rows = preview_build_entities(cfg) 

919 return { 

920 "command": "wiki_build", 

921 "dry_run": True, 

922 "entities": rows, 

923 "count": len(rows), 

924 "note": DRY_RUN_CONCEPT_NOTE, 

925 } 

926 return {"command": "wiki_build", **run_full_build(cfg, cancel=_caller_cancelled())} 

927 

928 

929@_wiki_tool 

930def wiki_update() -> dict[str, Any]: 

931 """Refresh the concept and entity wiki after an ingest. A full rebuild; blocks until done.""" 

932 from lilbee.wiki import run_full_build 

933 

934 return {"command": "wiki_update", **run_full_build(cfg, cancel=_caller_cancelled())} 

935 

936 

937@_wiki_tool 

938def wiki_synthesize() -> dict[str, Any]: 

939 """Generate synthesis pages for concept clusters with three or more sources.""" 

940 from lilbee.wiki import run_full_synthesize 

941 

942 return {"command": "wiki_synthesize", **run_full_synthesize(cfg, cancel=_caller_cancelled())} 

943 

944 

945@_wiki_tool 

946def wiki_prune() -> dict[str, Any]: 

947 """Prune stale and orphaned wiki pages.""" 

948 from lilbee.wiki.prune import prune_wiki 

949 

950 report = prune_wiki(get_services().store, cancel=_caller_cancelled()) 

951 return { 

952 "command": "wiki_prune", 

953 "records": [r.to_dict() for r in report.records], 

954 "archived": report.archived_count, 

955 "flagged": report.flagged_count, 

956 "reconciled": report.reconciled_count, 

957 } 

958 

959 

960@_wiki_tool 

961def wiki_index() -> dict[str, Any]: 

962 """Rebuild the browse index of pages the corpus could have. No LLM call.""" 

963 from lilbee.wiki.stubs import refresh_stub_index 

964 

965 stubs = refresh_stub_index(get_services().store) 

966 return {"command": "wiki_index", "entries": len(stubs)} 

967 

968 

969@_wiki_tool 

970def wiki_generate(slug: str) -> dict[str, Any]: 

971 """Generate one indexed wiki page. Costs a single LLM call and is GPU-heavy.""" 

972 from lilbee.wiki.browse import page_slug 

973 from lilbee.wiki.lazy import UnknownStubError, generate_stub_page 

974 

975 try: 

976 path = generate_stub_page(slug, get_services().store) 

977 except UnknownStubError as exc: 

978 return _error(str(exc)) 

979 if path is None: 

980 return _error(f"index entry for {slug} is stale; its sources are gone") 

981 # The read surfaces address pages by section, so answer with that slug. 

982 read_slug = page_slug(path, cfg.data_root / cfg.wiki_dir) 

983 return {"command": "wiki_generate", "slug": read_slug, "path": path.as_posix()} 

984 

985 

986@_tool 

987def wiki_wipe(confirm: bool = False) -> dict[str, Any]: 

988 """Delete every generated wiki page and its indexed rows. Pass ``confirm=true``. 

989 

990 Registered even with the wiki disabled, because turning the setting off 

991 leaves the pages generated earlier in place. 

992 """ 

993 if not confirm: 

994 return _error("pass confirm=true to delete the wiki; this cannot be undone") 

995 from lilbee.wiki.wipe import wipe_wiki 

996 

997 report = wipe_wiki(get_services().store) 

998 if not report.rows_deleted: 

999 return _error(report.summary()) 

1000 return { 

1001 "command": "wiki_wipe", 

1002 "pages_removed": report.pages_removed, 

1003 "sources_cleared": report.sources_cleared, 

1004 } 

1005 

1006 

1007def _setting_info_to_dict(info: SettingInfo) -> dict[str, Any]: 

1008 """Render a SettingInfo as a JSON-safe dict for the MCP wire format.""" 

1009 return { 

1010 "key": info.key, 

1011 "value": _json_safe(info.value), 

1012 "default": _json_safe(info.default), 

1013 "type": info.type, 

1014 "nullable": info.nullable, 

1015 "group": info.group.value, 

1016 "help": info.help_text, 

1017 "choices": list(info.choices) if info.choices else None, 

1018 "reindex_required": info.reindex_required, 

1019 } 

1020 

1021 

1022def _json_safe(value: Any) -> Any: 

1023 """Coerce Path / frozenset / tuple to JSON-friendly primitives.""" 

1024 if isinstance(value, str | int | float | bool | list | type(None)): 

1025 return value 

1026 return str(value) 

1027 

1028 

1029@_tool 

1030def settings_list(group: str = "") -> dict[str, Any]: 

1031 """List writable lilbee settings (each with value, default, type, help, choices). 

1032 

1033 ``group`` filters by group name (case-insensitive); empty returns all. 

1034 """ 

1035 

1036 try: 

1037 infos = list_settings(group or None) 

1038 except ValueError as exc: 

1039 return _error(str(exc)) 

1040 return { 

1041 "command": "settings_list", 

1042 "settings": [_setting_info_to_dict(info) for info in infos], 

1043 "total": len(infos), 

1044 } 

1045 

1046 

1047@_tool 

1048def settings_get(key: str) -> dict[str, Any]: 

1049 """Get a single setting's current value + metadata.""" 

1050 

1051 try: 

1052 info = get_setting(key) 

1053 except KeyError as exc: 

1054 return _error(str(exc)) 

1055 return {"command": "settings_get", "setting": _setting_info_to_dict(info)} 

1056 

1057 

1058@_tool 

1059def settings_set(updates: dict[str, Any]) -> dict[str, Any]: 

1060 """Atomically update writable settings; rolls back on validation error. 

1061 Persists to config.toml; returns ``{updated, reindex_required}``.""" 

1062 if _transport.http_mounted and requires_services_reset(updates): 

1063 return _error(provider_reset_refused_message("Switching")) 

1064 try: 

1065 result = apply_settings_update(updates) 

1066 except (ValueError, TypeError) as exc: 

1067 return _error(str(exc)) 

1068 except OSError as exc: 

1069 return _error(config_write_failure_message(exc)) 

1070 return { 

1071 "command": "settings_set", 

1072 "updated": result.updated, 

1073 "reindex_required": result.reindex_required, 

1074 } 

1075 

1076 

1077@_tool 

1078def settings_reset(keys: list[str]) -> dict[str, Any]: 

1079 """Reset writable settings to their built-in defaults.""" 

1080 if _transport.http_mounted and requires_services_reset(dict.fromkeys(keys)): 

1081 return _error(provider_reset_refused_message("Resetting")) 

1082 try: 

1083 result = reset_settings(keys) 

1084 except (ValueError, TypeError) as exc: 

1085 return _error(str(exc)) 

1086 except OSError as exc: 

1087 return _error(config_write_failure_message(exc)) 

1088 return { 

1089 "command": "settings_reset", 

1090 "updated": result.updated, 

1091 "reindex_required": result.reindex_required, 

1092 } 

1093 

1094 

1095@_tool 

1096def model_list(source: str = "", task: str = "") -> dict[str, Any]: 

1097 """List installed models. ``source`` is ``native`` / ``remote``; ``task`` filters by role.""" 

1098 from lilbee.app.models import list_models_data 

1099 from lilbee.catalog.types import ModelTask 

1100 

1101 try: 

1102 src = ModelSource.parse(source) 

1103 except ValueError as exc: 

1104 return _error(str(exc)) 

1105 try: 

1106 parsed_task = ModelTask(task) if task else None 

1107 except ValueError as exc: 

1108 return _error(str(exc)) 

1109 return list_models_data(source=src, task=parsed_task).model_dump() 

1110 

1111 

1112@_tool 

1113def catalog_browse( 

1114 task: str = "", 

1115 search: str = "", 

1116 size: str = "", 

1117 installed: bool | None = None, 

1118 featured: bool | None = None, 

1119 sort: str = "featured", 

1120 limit: int = 20, 

1121 offset: int = 0, 

1122) -> dict[str, Any]: 

1123 """Browse the lilbee model catalog. ``task``: chat/embedding/vision/rerank. 

1124 ``size``: small/medium/large/huge, by parameter count. 

1125 ``sort``: featured/downloads/name/size_asc/size_desc.""" 

1126 from lilbee.catalog.query import get_catalog 

1127 from lilbee.catalog.types import CatalogSize, CatalogSort, ModelTask 

1128 

1129 try: 

1130 parsed_task = ModelTask(task) if task else None 

1131 parsed_size = CatalogSize(size) if size else None 

1132 parsed_sort = CatalogSort(sort) 

1133 except ValueError as exc: 

1134 return _error(str(exc)) 

1135 try: 

1136 result = get_catalog( 

1137 task=parsed_task, 

1138 search=search, 

1139 size=parsed_size, 

1140 installed=installed, 

1141 featured=featured, 

1142 sort=parsed_sort, 

1143 limit=limit, 

1144 offset=offset, 

1145 model_manager=get_services().model_manager, 

1146 ) 

1147 except ValueError as exc: 

1148 return _error(str(exc)) 

1149 return { 

1150 "command": "catalog_browse", 

1151 "total": result.total, 

1152 "limit": result.limit, 

1153 "offset": result.offset, 

1154 "has_more": result.has_more, 

1155 "models": [ 

1156 { 

1157 "ref": m.hf_repo, 

1158 "display_name": m.display_name, 

1159 "task": m.task.value, 

1160 "size_gb": m.size_gb, 

1161 "min_ram_gb": m.min_ram_gb, 

1162 "downloads": m.downloads, 

1163 "featured": m.featured, 

1164 "description": m.description, 

1165 "architecture": m.architecture, 

1166 "compat": m.compat.value, 

1167 } 

1168 for m in result.models 

1169 ], 

1170 } 

1171 

1172 

1173@_tool 

1174def model_show(model: str) -> dict[str, Any]: 

1175 """Show catalog and installed metadata for a model ref.""" 

1176 from lilbee.app.models import show_model_data 

1177 from lilbee.modelhub.model_manager import ModelNotFoundError 

1178 

1179 try: 

1180 return show_model_data(model).model_dump() 

1181 except ModelNotFoundError as exc: 

1182 return _error(str(exc)) 

1183 

1184 

1185def _log_progress_failure(future: concurrent.futures.Future[None]) -> None: 

1186 """Log report_progress failures without raising. 

1187 

1188 Progress notifications are best-effort: a failure should not abort 

1189 an in-flight pull. 

1190 """ 

1191 try: 

1192 future.result() 

1193 except Exception: 

1194 log.warning("MCP report_progress failed", exc_info=True) 

1195 

1196 

1197@_tool 

1198async def model_pull( 

1199 model: str, 

1200 source: str = ModelSource.NATIVE.value, 

1201 allow_unsupported: bool = False, 

1202 ctx: Context | None = None, 

1203) -> dict[str, Any]: 

1204 """Download a model and stream progress. 

1205 

1206 ``source`` is ``native`` (GGUF) or ``remote`` (SDK). 

1207 ``allow_unsupported`` overrides the supported-architecture refusal. 

1208 """ 

1209 from lilbee.app.models import pull_model_data 

1210 from lilbee.catalog import DownloadProgress 

1211 from lilbee.catalog.compat import SUPPORTED_ARCHS, UnsupportedArchError 

1212 

1213 try: 

1214 src = ModelSource.parse(source) or ModelSource.NATIVE 

1215 except ValueError as exc: 

1216 return _error(str(exc)) 

1217 

1218 loop = asyncio.get_running_loop() 

1219 

1220 with _cancel_token() as cancel: 

1221 

1222 def on_update(p: DownloadProgress) -> None: 

1223 # Raise rather than return: the download runs on a thread asyncio 

1224 # cannot interrupt, so returning would leave a multi-GB pull going 

1225 # for a caller that has gone. Same idiom the HTTP pull uses. 

1226 if cancel.is_set(): 

1227 raise TaskCancelledError 

1228 if ctx is None: 

1229 return 

1230 future = asyncio.run_coroutine_threadsafe( 

1231 ctx.report_progress(progress=float(p.percent), total=100.0, message=p.detail), 

1232 loop, 

1233 ) 

1234 future.add_done_callback(_log_progress_failure) 

1235 

1236 try: 

1237 result = await asyncio.to_thread( 

1238 pull_model_data, 

1239 model, 

1240 src, 

1241 on_update=on_update, 

1242 allow_unsupported=allow_unsupported, 

1243 cancel=cancel, 

1244 ) 

1245 except UnsupportedArchError as exc: 

1246 return { 

1247 "ok": False, 

1248 "command": "model_pull", 

1249 "error": { 

1250 "code": "unsupported_arch", 

1251 "arch": exc.architecture, 

1252 "ref": exc.ref, 

1253 "supported_examples": sorted(SUPPORTED_ARCHS)[:5], 

1254 "total_supported": len(SUPPORTED_ARCHS), 

1255 }, 

1256 } 

1257 except (RuntimeError, PermissionError) as exc: 

1258 return _error(str(exc)) 

1259 return result.model_dump() 

1260 

1261 

1262@_tool 

1263def model_rm(model: str, source: str = "") -> dict[str, Any]: 

1264 """Remove an installed model. Only native GGUF models lilbee downloaded; 

1265 Ollama/LM Studio are read-only.""" 

1266 from lilbee.app.models import remove_model_data 

1267 

1268 try: 

1269 src = ModelSource.parse(source) 

1270 return remove_model_data(model, source=src).model_dump() 

1271 except ValueError as exc: 

1272 return _error(str(exc)) 

1273 

1274 

1275@_wiki_tool 

1276def wiki_drafts_list() -> dict[str, Any]: 

1277 """List pending wiki drafts. Read-only: promotion is reserved for the human 

1278 surfaces (CLI, TUI, and the authenticated HTTP API).""" 

1279 from lilbee.wiki.drafts import list_drafts 

1280 

1281 wiki_root = cfg.data_root / cfg.wiki_dir 

1282 drafts = list_drafts(wiki_root) 

1283 return { 

1284 "command": "wiki_drafts_list", 

1285 "drafts": [d.to_dict() for d in drafts], 

1286 "total": len(drafts), 

1287 } 

1288 

1289 

1290@_wiki_tool 

1291def wiki_drafts_diff(slug: str) -> dict[str, Any]: 

1292 """Unified diff of a draft against its published counterpart.""" 

1293 from lilbee.core.security import PathTraversalError 

1294 from lilbee.wiki.drafts import diff_draft 

1295 

1296 wiki_root = cfg.data_root / cfg.wiki_dir 

1297 try: 

1298 diff = diff_draft(slug, wiki_root) 

1299 except FileNotFoundError as exc: 

1300 return _error(str(exc)) 

1301 except PathTraversalError: 

1302 return _error(INVALID_DRAFT_SLUG_ERROR) 

1303 return {"command": "wiki_drafts_diff", "slug": slug, "diff": diff} 

1304 

1305 

1306def _collapse_nullable_anyof(prop: dict[str, Any]) -> None: 

1307 """Collapse ``anyOf: [{type: X}, {type: null}]`` to ``{type: X}`` in place. 

1308 

1309 Pydantic emits ``T | None`` parameters as a two-arm anyOf with a null 

1310 branch. The null branch carries no information the model needs to pick 

1311 or shape its call, but it costs tokens at every dispatch. Drop it. 

1312 """ 

1313 arms = prop.get("anyOf") 

1314 if not isinstance(arms, list): 

1315 return 

1316 non_null = [a for a in arms if isinstance(a, dict) and a.get("type") != "null"] 

1317 if len(non_null) == 1 and len(non_null) < len(arms): 

1318 prop.pop("anyOf", None) 

1319 for key, value in non_null[0].items(): 

1320 prop.setdefault(key, value) 

1321 

1322 

1323def _strip_property_noise(prop: dict[str, Any]) -> None: 

1324 """Drop tokens that don't change the model's behavior.""" 

1325 prop.pop("title", None) 

1326 prop.pop("default", None) 

1327 _collapse_nullable_anyof(prop) 

1328 if prop.get("additionalProperties") is True: 

1329 prop.pop("additionalProperties", None) 

1330 

1331 

1332def _flatten_tool_description(text: str) -> str: 

1333 """Flatten a triple-quoted tool docstring for the tools wire. 

1334 

1335 The summary line carries no indent while continuation lines are indented to 

1336 the source, so ``textwrap.dedent`` alone is a no-op (the common prefix is the 

1337 empty string) and leaves source indentation on every body line -- including 

1338 deeper-indented Args lines. Strip each line so the model sees flat text; 

1339 blank lines are kept so paragraph breaks survive. 

1340 """ 

1341 return "\n".join(line.strip() for line in text.strip().splitlines()) 

1342 

1343 

1344def _strip_schema(schema: dict[str, Any]) -> dict[str, Any]: 

1345 """Trim auto-generated noise from a tool's input schema, on a copy. 

1346 

1347 Drops: 

1348 - SDK/Pydantic ``title`` keys (per-schema + per-property). Tools the 

1349 model picks by name don't need a separate display title. 

1350 - ``default`` values on properties: clients send what they want and 

1351 omitted fields fall back server-side. 

1352 - ``additionalProperties: true`` on dict params: Pydantic emits it for 

1353 every ``dict[str, Any]`` but it's the JSON Schema default behavior. 

1354 - The ``null`` arm of ``anyOf: [{type: X}, {type: null}]`` unions for 

1355 ``T | None`` defaults; the null branch is implicit. 

1356 

1357 A roughly 25-35% reduction in the serialized tools payload, which matters 

1358 most for small-context (16K) chat models where the tools surface was 

1359 previously eating ~60% of the budget. 

1360 """ 

1361 schema = deepcopy(schema) 

1362 schema.pop("title", None) 

1363 properties = schema.get("properties") 

1364 if isinstance(properties, dict): 

1365 for prop in properties.values(): 

1366 if isinstance(prop, dict): 

1367 _strip_property_noise(prop) 

1368 return schema 

1369 

1370 

1371_NO_WIKI_SCOPE_HINT = ' No wiki layer here: use scope "raw" or "both".' 

1372 

1373 

1374class LilbeeMCP(MCPServer): 

1375 """MCP server that trims its tools wire and keeps it current with config.""" 

1376 

1377 async def list_tools(self) -> list[MCPTool]: 

1378 """The registered tools with schema noise stripped and flat descriptions. 

1379 

1380 The transforms run on the wire representation per request, never on the 

1381 stored registrations, so they cannot drift out of sync with config. The 

1382 ``search`` description advertises only the scopes this corpus has: when 

1383 wiki generation is off, a model that guesses ``scope="wiki"`` gets a 

1384 silent fallback to the full pool, so raw/both only. 

1385 """ 

1386 tools = await super().list_tools() 

1387 for tool in tools: 

1388 tool.input_schema = _strip_schema(tool.input_schema) 

1389 if isinstance(tool.description, str): 

1390 description = _flatten_tool_description(tool.description) 

1391 if tool.name == "search" and not cfg.wiki: 

1392 description += _NO_WIKI_SCOPE_HINT 

1393 tool.description = description 

1394 return tools 

1395 

1396 

1397def _client_name(ctx: Context | None) -> str: 

1398 """The MCP client's self-reported name from the initialize handshake, or empty.""" 

1399 if ctx is None: 

1400 return "" 

1401 params = ctx.session.client_params 

1402 return params.clientInfo.name if params is not None else "" 

1403 

1404 

1405def _slug(value: str) -> str: 

1406 """Lowercase, hyphenated id fragment; falls back to ``generic`` when empty.""" 

1407 slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") 

1408 return slug or "generic" 

1409 

1410 

1411# Per-connection fallback ids for agents that report no identity. Keyed by the 

1412# live MCP session so each connection gets a distinct, stable namespace instead 

1413# of every unidentified agent colliding on a shared one. WeakKeyDictionary drops 

1414# entries once the session is collected, so this does not grow unbounded. The 

1415# lock guards the get-or-create because sync tool handlers run on the offload 

1416# threadpool, so concurrent connections (and weakref-removal callbacks) can 

1417# touch the mapping from different threads. 

1418_ANON_OWNER_IDS: WeakKeyDictionary[object, str] = WeakKeyDictionary() 

1419_ANON_OWNER_LOCK = threading.Lock() 

1420 

1421 

1422def _anon_owner_id(ctx: Context | None) -> str: 

1423 """A stable per-connection id for an agent that reported no identity. 

1424 

1425 Without this, two unidentified agents would both slug to ``generic`` and 

1426 share a memory namespace; keying on the session keeps them isolated. 

1427 """ 

1428 if ctx is None: 

1429 return "anonymous" 

1430 session = ctx.session 

1431 with _ANON_OWNER_LOCK: 

1432 existing = _ANON_OWNER_IDS.get(session) 

1433 if existing is None: 

1434 existing = f"anon-{uuid.uuid4().hex[:12]}" 

1435 _ANON_OWNER_IDS[session] = existing 

1436 return existing 

1437 

1438 

1439def _derive_owner(agent_id: str, ctx: Context | None) -> str: 

1440 """Resolve the calling agent's stable owner namespace. 

1441 

1442 Precedence: explicit ``agent_id`` argument, then the ``LILBEE_AGENT_ID`` env var 

1443 (pinned in the client's MCP config), then the MCP client name, then a stable 

1444 per-connection fallback so unidentified agents never share a namespace. 

1445 """ 

1446 explicit = agent_id or os.environ.get("LILBEE_AGENT_ID", "") 

1447 resolved = explicit or _client_name(ctx) 

1448 if resolved: 

1449 return agent_owner(_slug(resolved)) 

1450 return agent_owner(_slug(_anon_owner_id(ctx))) 

1451 

1452 

1453@_tool_if(memory_enabled) 

1454def memory_remember( 

1455 text: str, 

1456 kind: MemoryKind = MemoryKind.FACT, 

1457 shared: bool = False, 

1458 agent_id: str = "", 

1459 ctx: Context | None = None, 

1460) -> dict[str, Any]: 

1461 """Store a durable memory. ``kind``: "fact" (similarity-recalled) or "preference" (always on). 

1462 ``shared`` exposes it to the human's TUI/CLI.""" 

1463 if not memory_enabled(): 

1464 return _error(MEMORY_DISABLED_HINT) 

1465 owner = _derive_owner(agent_id, ctx) 

1466 memory_id = remember(text, owner=owner, kind=kind, source=MemorySource.AGENT, shared=shared) 

1467 return {"ok": True, "id": memory_id, "owner": owner} 

1468 

1469 

1470@_tool_if(memory_enabled) 

1471def memory_recall( 

1472 query: str, limit: int = 0, agent_id: str = "", ctx: Context | None = None 

1473) -> dict[str, Any]: 

1474 """Recall this agent's memories (plus any the human shared) relevant to *query*.""" 

1475 if not memory_enabled(): 

1476 return _error(MEMORY_DISABLED_HINT) 

1477 owner = _derive_owner(agent_id, ctx) 

1478 memories = recall(query, owner, top_k=limit if limit > 0 else None) 

1479 return { 

1480 "memories": [ 

1481 {"id": m.id, "text": m.text, "kind": m.kind.value, "owner": m.owner} for m in memories 

1482 ] 

1483 } 

1484 

1485 

1486@_tool_if(memory_enabled) 

1487def memory_list(agent_id: str = "", ctx: Context | None = None) -> dict[str, Any]: 

1488 """List every memory in this agent's namespace (any kind, newest first).""" 

1489 if not memory_enabled(): 

1490 return _error(MEMORY_DISABLED_HINT) 

1491 owner = _derive_owner(agent_id, ctx) 

1492 memories = list_memories(owner) 

1493 return { 

1494 "memories": [ 

1495 {"id": m.id, "text": m.text, "kind": m.kind.value, "shared": m.shared} for m in memories 

1496 ] 

1497 } 

1498 

1499 

1500@_tool_if(memory_enabled) 

1501def memory_forget(memory_id: str, agent_id: str = "", ctx: Context | None = None) -> dict[str, Any]: 

1502 """Delete one of this agent's own memories by id (agent_id scopes the namespace).""" 

1503 if not memory_enabled(): 

1504 return _error(MEMORY_DISABLED_HINT) 

1505 owner = _derive_owner(agent_id, ctx) 

1506 if not forget(memory_id, owner=owner): 

1507 return _error(f"No memory '{memory_id}' in this agent's namespace.") 

1508 return {"ok": True, "id": memory_id} 

1509 

1510 

1511def _placement_dict(view: PlacementView) -> dict[str, Any]: 

1512 from lilbee.server.models import PlacementResponse 

1513 

1514 return PlacementResponse.from_view(view).model_dump(mode="json") 

1515 

1516 

1517def _placement_guard(serialize: Callable[[], dict[str, Any]]) -> dict[str, Any]: 

1518 """Run a placement query and serialize it, returning a structured error on failure.""" 

1519 from lilbee.providers.base import ProviderError 

1520 from lilbee.providers.fleet.placement_spec import PlacementError 

1521 

1522 try: 

1523 return serialize() 

1524 except (PlacementError, ProviderError) as exc: 

1525 return _error(str(exc)) 

1526 

1527 

1528def _placement_result(action: Callable[[], PlacementView]) -> dict[str, Any]: 

1529 """Run a placement action and serialize its view, returning a structured error on failure.""" 

1530 return _placement_guard(lambda: _placement_dict(action())) 

1531 

1532 

1533def _parse_spec(spec: dict[str, Any] | None) -> PlacementSpec | None: 

1534 from lilbee.providers.fleet.placement_spec import PlacementSpec 

1535 

1536 return PlacementSpec.from_json(json.dumps(spec)) if spec else None 

1537 

1538 

1539@_tool_named("get_gpus") 

1540def get_gpus_tool() -> dict[str, Any]: 

1541 """List detected GPUs with free/total VRAM (the placement HTTP /api/gpus equivalent).""" 

1542 

1543 def _body() -> dict[str, Any]: 

1544 from lilbee.cli.tui import messages as msg 

1545 from lilbee.providers.fleet.gpu_stats import probe_intel_util_hint 

1546 

1547 view = get_placement() 

1548 hint = probe_intel_util_hint(view.gpus) 

1549 return { 

1550 "gpus": _placement_dict(view)["gpus"], 

1551 "notice": msg.intel_util_hint_text(hint) if hint else None, 

1552 } 

1553 

1554 return _placement_guard(_body) 

1555 

1556 

1557@_tool_named("get_placement") 

1558def get_placement_tool() -> dict[str, Any]: 

1559 """Show the current effective multi-GPU model placement.""" 

1560 return _placement_result(get_placement) 

1561 

1562 

1563@_tool_named("preview_placement") 

1564def preview_placement_tool(spec: dict[str, Any] | None = None) -> dict[str, Any]: 

1565 """Preview what a placement spec (or auto, when omitted) would place. No changes made.""" 

1566 return _placement_result(lambda: preview_placement(_parse_spec(spec))) 

1567 

1568 

1569@_tool_named("set_placement") 

1570def set_placement_tool(spec: dict[str, Any]) -> dict[str, Any]: 

1571 """Set and apply a manual multi-GPU placement spec (persists to config). 

1572 

1573 The spec maps a role ("chat"/"embed"/"rerank"/"vision") to a placement, e.g. 

1574 ``{"chat": {"devices": [0, 1], "tensor_split": [1, 1]}}``. ``devices`` is the 

1575 GPU indices (get_gpus lists them); ``tensor_split`` is optional per-device 

1576 weights (omit for an even split). Omit a role to leave it auto-placed. 

1577 """ 

1578 from lilbee.providers.fleet.placement_spec import PlacementSpec 

1579 

1580 # set_placement restarts the shared fleet's moved roles: gate it on the 

1581 # shared HTTP transport exactly like the REST PUT/DELETE placement routes. 

1582 if _transport.http_mounted and not cfg.allow_http_placement: 

1583 return _error(placement_refused_message()) 

1584 # Always build a spec (even {}) so an empty/invalid one is rejected, not cleared. 

1585 return _placement_result(lambda: set_placement(PlacementSpec.from_json(json.dumps(spec)))) 

1586 

1587 

1588@_tool_named("clear_placement") 

1589def clear_placement_tool() -> dict[str, Any]: 

1590 """Clear the manual placement and return to automatic placement.""" 

1591 if _transport.http_mounted and not cfg.allow_http_placement: 

1592 return _error(placement_refused_message()) 

1593 return _placement_result(lambda: set_placement(None)) 

1594 

1595 

1596def build_mcp_server() -> LilbeeMCP: 

1597 """Build an MCP server carrying every tool registered in this module. 

1598 

1599 Each transport builds its own instance: the SDK caches one 

1600 ``StreamableHTTPSessionManager`` per server and its ``run()`` is single-use, 

1601 so a shared server cannot back two apps in one process. Gates registered 

1602 via ``_tool_if`` are evaluated here, against current config. 

1603 """ 

1604 server = LilbeeMCP("lilbee", instructions=_INSTRUCTIONS) 

1605 for fn, name, gate in _REGISTRATIONS: 

1606 if gate is None or gate(): 

1607 server.add_tool(fn, name=name) 

1608 return server 

1609 

1610 

1611_PARENT_DEATH_CLEANUP_S = 5.0 

1612 

1613 

1614def _exit_on_parent_death() -> None: 

1615 """Release engine membership best-effort, then hard-exit promptly. 

1616 

1617 ``os._exit`` skips atexit, so an explicit release stops this process's engine 

1618 when it was the last user and keeps the machine clean. But this watchdog's one 

1619 contract is to exit promptly on parent death, so the release runs on a daemon 

1620 thread joined with a short deadline: a peer holding an engine build lock can 

1621 never keep the orphaned process alive with its models resident. The kernel 

1622 releases this process's user lock on exit regardless, so a skipped release only 

1623 defers the engine stop to the peers' reap and the idle TTL. 

1624 """ 

1625 cleanup = threading.Thread(target=reset_services, name="parent-death-cleanup", daemon=True) 

1626 cleanup.start() 

1627 cleanup.join(timeout=_PARENT_DEATH_CLEANUP_S) 

1628 os._exit(0) 

1629 

1630 

1631def main() -> None: 

1632 """Entry point for the stdio MCP server.""" 

1633 # Preload so the first tool call doesn't pay the cold-start cost 

1634 # of provider/embedder/store init. Failures (missing model, bad 

1635 # config) still surface on the first tool call rather than crashing 

1636 # the server before it attaches to stdio. 

1637 try: 

1638 get_services() 

1639 except Exception: 

1640 log.debug("MCP pre-warm failed; services will init on first call", exc_info=True) 

1641 

1642 from lilbee.parent_monitor import parse_parent_pid, watch_parent_thread 

1643 

1644 parent_pid = parse_parent_pid() 

1645 if parent_pid is not None: 

1646 watch_parent_thread(parent_pid, _exit_on_parent_death) 

1647 

1648 build_mcp_server().run()