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

705 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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 get_setting, 

49 list_settings, 

50 provider_reset_refused_message, 

51 requires_services_reset, 

52 reset_settings, 

53) 

54from lilbee.catalog.types import ModelSource 

55from lilbee.core.config import cfg 

56from lilbee.core.config.enums import CrawlRenderMode 

57from lilbee.core.settings import overlay_persisted_settings 

58from lilbee.core.system import LOCAL_ROOT_DIRNAME, canonical_data_root 

59from lilbee.crawler import crawler_available, is_url, require_valid_crawl_url 

60from lilbee.crawler.task import get_task, start_crawl 

61from lilbee.data.store import ( 

62 EmbeddingModelMismatchError, 

63 MemoryKind, 

64 MemorySource, 

65 SearchScope, 

66 agent_owner, 

67 scope_to_chunk_type, 

68) 

69from lilbee.runtime.cancellation import TaskCancelledError 

70from lilbee.sessions import ( 

71 AGENT_SESSIONS_DISABLED_HINT, 

72 MessageRole, 

73 Session, 

74 SessionMessage, 

75 SessionNotFoundError, 

76 SessionOrigin, 

77 SessionOwnershipError, 

78 TitleSource, 

79 agent_sessions_enabled, 

80) 

81from lilbee.wiki.shared import ( 

82 INVALID_DRAFT_SLUG_ERROR, 

83 WIKI_DISABLED_ERROR, 

84 WikiSubdir, 

85 total_wiki_pages, 

86) 

87 

88if TYPE_CHECKING: 

89 from lilbee.providers.fleet.placement_spec import PlacementSpec 

90 

91log = logging.getLogger(__name__) 

92 

93_INSTRUCTIONS = ( 

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

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

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

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

98 "those cannot see the indexed corpus." 

99) 

100 

101 

102class _TransportState: 

103 """Process-level MCP transport facts. 

104 

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

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

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

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

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

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

111 """ 

112 

113 http_mounted: bool = False 

114 

115 

116_transport = _TransportState() 

117 

118 

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

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

121 _transport.http_mounted = value 

122 

123 

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

125 

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

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

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

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

130 

131 

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

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

134 return _CANCEL.get() 

135 

136 

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

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

139 

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

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

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

143 generated tool schema is unchanged. 

144 

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

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

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

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

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

150 ingest tools do. 

151 """ 

152 if inspect.iscoroutinefunction(fn): 

153 return fn 

154 

155 @functools.wraps(fn) 

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

157 with _cancel_token() as token: 

158 _CANCEL.set(token) 

159 return await anyio.to_thread.run_sync( 

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

161 ) 

162 

163 return cast("_F", _runner) 

164 

165 

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

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

168 

169 

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

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

172 

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

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

175 """ 

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

177 return fn 

178 

179 

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

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

182 

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

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

185 return fn 

186 

187 return deco 

188 

189 

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

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

192 

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

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

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

196 """ 

197 if not callable(when): 

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

199 

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

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

202 return fn 

203 

204 return deco 

205 

206 

207def _wiki_enabled() -> bool: 

208 return cfg.wiki 

209 

210 

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

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

213 

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

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

216 without forcing every caller to widen its return type. 

217 """ 

218 return {"error": msg} 

219 

220 

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

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

223 

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

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

226 """ 

227 

228 @functools.wraps(fn) 

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

230 if not cfg.wiki: 

231 return _error(WIKI_DISABLED_ERROR) 

232 return fn(*args, **kwargs) 

233 

234 checked = cast("_F", guarded) 

235 _tool_if(_wiki_enabled)(checked) 

236 return checked 

237 

238 

239@_tool 

240def search( 

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

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

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

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

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

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

247 try: 

248 chunk_type = scope_to_chunk_type(scope) 

249 except ValueError: 

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

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

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

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

254 chunk_type = scope_to_chunk_type(SearchScope.BOTH.value) 

255 if top_k is not None and top_k < 1: 

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

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

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

259 top_k = None 

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

261 try: 

262 results = get_services().searcher.search( 

263 query, top_k=effective_top_k, chunk_type=chunk_type 

264 ) 

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

266 except EmbeddingModelMismatchError as exc: 

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

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

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

270 return { 

271 "error": str(exc), 

272 "code": "INDEX_EMBEDDER_MISMATCH", 

273 "persisted_model": exc.persisted_model, 

274 "persisted_dim": exc.persisted_dim, 

275 "adoptable": exc.dims_match, 

276 } 

277 except Exception as exc: 

278 return _error(str(exc)) 

279 

280 

281@_tool 

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

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

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

285 return { 

286 "config": { 

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

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

289 "chat_model": cfg.chat_model, 

290 "embedding_model": cfg.embedding_model, 

291 "vision_model": cfg.vision_model, 

292 "reranker_model": cfg.reranker_model, 

293 "enable_ocr": cfg.enable_ocr, 

294 "num_ctx": cfg.num_ctx, 

295 "num_ctx_max": cfg.num_ctx_max, 

296 "chat_n_ctx_target": cfg.chat_n_ctx_target, 

297 "flash_attention": cfg.flash_attention, 

298 "kv_cache_type": cfg.kv_cache_type.value, 

299 "n_gpu_layers": cfg.n_gpu_layers, 

300 "cpu_moe": cfg.cpu_moe, 

301 "n_cpu_moe": cfg.n_cpu_moe, 

302 "main_gpu": cfg.main_gpu, 

303 "gpu_devices": cfg.gpu_devices, 

304 }, 

305 "sources": [ 

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

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

308 ], 

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

310 "entities": _entity_status_dict(), 

311 } 

312 

313 

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

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

316 from lilbee.app.status import entity_status 

317 

318 section = entity_status() 

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

320 

321 

322@contextmanager 

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

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

325 

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

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

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

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

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

331 has to be polled. 

332 

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

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

335 """ 

336 token = threading.Event() 

337 try: 

338 yield token 

339 except BaseException: 

340 token.set() 

341 raise 

342 

343 

344@_tool 

345async def sync(force_rebuild: bool = False, retry_skipped: bool = False) -> dict[str, Any]: 

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

347 

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

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

350 """ 

351 from lilbee.data.ingest import sync as run_sync 

352 

353 with _cancel_token() as cancel: 

354 return ( 

355 await run_sync( 

356 quiet=True, 

357 force_rebuild=force_rebuild, 

358 retry_skipped=retry_skipped, 

359 cancel=cancel, 

360 ) 

361 ).model_dump() 

362 

363 

364@_tool 

365async def add( 

366 paths: list[str], 

367 force: bool = False, 

368 enable_ocr: bool | None = None, 

369 ocr_timeout: float | None = None, 

370 render_mode: CrawlRenderMode | None = None, 

371) -> dict[str, Any]: 

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

373 Paths must be absolute; URLs are crawled as markdown.""" 

374 from lilbee.app.ingest import register_sources 

375 from lilbee.data.ingest import sync as run_sync 

376 

377 errors: list[str] = [] 

378 valid: list[Path] = [] 

379 urls: list[str] = [] 

380 for p_str in paths: 

381 if is_url(p_str): 

382 urls.append(p_str) 

383 else: 

384 p = Path(p_str) 

385 if not p.exists(): 

386 errors.append(p_str) 

387 else: 

388 valid.append(p) 

389 

390 # Crawl URLs 

391 crawled_count = 0 

392 if urls: 

393 from lilbee.crawler import crawler_available 

394 

395 if not crawler_available(): 

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

397 from lilbee.crawler import crawl_and_save 

398 

399 for url in urls: 

400 try: 

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

402 # event loop like the sibling crawl tool does. 

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

404 except ValueError as exc: 

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

406 continue 

407 crawled_paths = await crawl_and_save(url, render_mode=render_mode) 

408 crawled_count += len(crawled_paths) 

409 

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

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

412 reg_result = await anyio.to_thread.run_sync( 

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

414 ) 

415 

416 from lilbee.app.ingest import temporary_ocr_config 

417 

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

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

420 

421 result: dict[str, Any] = { 

422 "command": "add", 

423 "copied": reg_result.registered, 

424 "skipped": reg_result.skipped, 

425 "tracked": reg_result.tracked, 

426 "crawled": crawled_count, 

427 "errors": errors, 

428 "sync": sync_result, 

429 } 

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

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

432 return result 

433 

434 

435@_tool_if(crawler_available) 

436async def crawl( 

437 url: str, 

438 depth: int | None = None, 

439 max_pages: int | None = None, 

440 render_mode: CrawlRenderMode | None = None, 

441 include_subdomains: bool = False, 

442) -> dict[str, Any]: 

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

444 ``depth=None`` = whole site, ``0`` = single URL. ``render_mode``: "http"/"browser".""" 

445 from lilbee.crawler import crawler_available 

446 

447 if not crawler_available(): 

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

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

450 # not an unbounded crawl. 

451 if depth is not None and depth < 0: 

452 return _error("depth must be 0 or greater (omit it to crawl the whole site)") 

453 if max_pages is not None and max_pages < 0: 

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

455 try: 

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

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

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

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

460 except ValueError as exc: 

461 return _error(str(exc)) 

462 

463 task_id = start_crawl( 

464 url, 

465 depth=depth, 

466 max_pages=max_pages, 

467 render_mode=render_mode, 

468 include_subdomains=include_subdomains, 

469 ) 

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

471 

472 

473@_tool_if(crawler_available) 

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

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

476 task = get_task(task_id) 

477 if task is None: 

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

479 return { 

480 "task_id": task.task_id, 

481 "url": task.url, 

482 "status": task.status.value, 

483 "pages_crawled": task.pages_crawled, 

484 "pages_total": task.pages_total, 

485 "error": task.error, 

486 "started_at": task.started_at, 

487 "finished_at": task.finished_at, 

488 } 

489 

490 

491@_tool 

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

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

494 from lilbee.crawler.task import cancel_crawl 

495 

496 if get_task(task_id) is None: 

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

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

499 

500 

501@_tool 

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

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

504 

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

506 """ 

507 if _transport.http_mounted: 

508 return _error( 

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

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

511 ) 

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

513 # would derive for the same directory. 

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

515 root = base / LOCAL_ROOT_DIRNAME 

516 

517 created = False 

518 if not root.is_dir(): 

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

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

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

522 created = True 

523 

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

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

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

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

528 cfg.data_root = base 

529 cfg.documents_dir = root / "documents" 

530 cfg.data_dir = root / "data" 

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

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

533 overlay_persisted_settings(base) 

534 reset_services() 

535 

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

537 

538 

539@_tool 

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

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

542 

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

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

545 from lilbee.app.ingest import remove_documents_durably 

546 

547 result = remove_documents_durably(names) 

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

549 

550 

551@_tool 

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

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

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

555 return { 

556 "documents": [ 

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

558 ], 

559 "total": len(sources), 

560 } 

561 

562 

563_AGENT_ORIGINS = frozenset({SessionOrigin.MCP}) 

564 

565 

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

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

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

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

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

571 raise SessionNotFoundError(session_id) 

572 return session 

573 

574 

575@_tool_if(agent_sessions_enabled) 

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

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

578 if not agent_sessions_enabled(): 

579 return _error(AGENT_SESSIONS_DISABLED_HINT) 

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

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

582 

583 

584@_tool_if(agent_sessions_enabled) 

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

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

587 if not agent_sessions_enabled(): 

588 return _error(AGENT_SESSIONS_DISABLED_HINT) 

589 try: 

590 session = _require_agent_session(session_id) 

591 except SessionNotFoundError as exc: 

592 return _error(str(exc)) 

593 return { 

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

595 "messages": [ 

596 { 

597 "role": message.role.value, 

598 "content": message.content, 

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

600 "ts": message.ts, 

601 } 

602 for message in session.messages 

603 ], 

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

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

606 # rebuilds history without what was already condensed. 

607 "summary": session.summary, 

608 } 

609 

610 

611@_tool_if(agent_sessions_enabled) 

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

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

614 if not agent_sessions_enabled(): 

615 return _error(AGENT_SESSIONS_DISABLED_HINT) 

616 session_id = get_services().session_store.create( 

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

618 ) 

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

620 

621 

622@_tool_if(agent_sessions_enabled) 

623def session_add_message( 

624 session_id: str, 

625 role: MessageRole, 

626 content: str, 

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

628 claim: bool = False, 

629) -> dict[str, Any]: 

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

631 if not agent_sessions_enabled(): 

632 return _error(AGENT_SESSIONS_DISABLED_HINT) 

633 store = get_services().session_store 

634 try: 

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

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

637 message = SessionMessage( 

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

639 ) 

640 if claim: 

641 store.transfer(session_id, SessionOrigin.MCP) 

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

643 except (SessionNotFoundError, SessionOwnershipError) as exc: 

644 return _error(str(exc)) 

645 except ValueError as exc: 

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

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

648 

649 

650@_tool_if(agent_sessions_enabled) 

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

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

653 if not agent_sessions_enabled(): 

654 return _error(AGENT_SESSIONS_DISABLED_HINT) 

655 try: 

656 _require_agent_session(session_id) 

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

658 except SessionNotFoundError as exc: 

659 return _error(str(exc)) 

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

661 

662 

663@_tool_if(agent_sessions_enabled) 

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

665 """Rename an agent session.""" 

666 if not agent_sessions_enabled(): 

667 return _error(AGENT_SESSIONS_DISABLED_HINT) 

668 try: 

669 _require_agent_session(session_id) 

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

671 except SessionNotFoundError as exc: 

672 return _error(str(exc)) 

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

674 

675 

676@_tool_if(agent_sessions_enabled) 

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

678 """Delete an agent session.""" 

679 if not agent_sessions_enabled(): 

680 return _error(AGENT_SESSIONS_DISABLED_HINT) 

681 try: 

682 _require_agent_session(session_id) 

683 get_services().session_store.delete(session_id) 

684 except SessionNotFoundError as exc: 

685 return _error(str(exc)) 

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

687 

688 

689@_tool 

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

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

692 

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

694 """ 

695 from lilbee.app.dataset import DatasetError, export_to_path 

696 

697 try: 

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

699 except DatasetError as exc: 

700 return _error(str(exc)) 

701 return summary.model_dump() 

702 

703 

704@_tool 

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

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

707 

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

709 """ 

710 from lilbee.app.dataset import DatasetError, import_from_path 

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

712 

713 loop = asyncio.get_running_loop() 

714 

715 with _cancel_token() as cancel: 

716 

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

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

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

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

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

722 if cancel.is_set(): 

723 raise TaskCancelledError 

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

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

726 return 

727 future = asyncio.run_coroutine_threadsafe( 

728 ctx.report_progress( 

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

730 ), 

731 loop, 

732 ) 

733 future.add_done_callback(_log_progress_failure) 

734 

735 try: 

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

737 except DatasetError as exc: 

738 return _error(str(exc)) 

739 return summary.model_dump() 

740 

741 

742@_tool 

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

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

745 if _transport.http_mounted: 

746 return _error( 

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

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

749 ) 

750 if not confirm: 

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

752 from lilbee.app.reset import perform_reset 

753 

754 result = perform_reset().model_dump() 

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

756 reset_store() 

757 return result 

758 

759 

760@_wiki_tool 

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

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

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

764 

765 store = get_services().store 

766 report = ( 

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

768 if wiki_source 

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

770 ) 

771 return { 

772 "command": "wiki_lint", 

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

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

775 "errors": report.error_count, 

776 "warnings": report.warning_count, 

777 } 

778 

779 

780@_wiki_tool 

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

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

783 

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

785 """ 

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

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

788 store = get_services().store 

789 if source: 

790 records = store.get_citations_for_source(source) 

791 return { 

792 "command": "wiki_citations", 

793 "source": source, 

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

795 "total": len(records), 

796 } 

797 records = store.get_citations_for_wiki(wiki_source) 

798 return { 

799 "command": "wiki_citations", 

800 "wiki_source": wiki_source, 

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

802 "total": len(records), 

803 } 

804 

805 

806@_tool 

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

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

809 

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

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

812 """ 

813 from lilbee.wiki.lint import lint_all 

814 

815 wiki_root = cfg.data_root / cfg.wiki_dir 

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

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

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

819 return { 

820 "wiki_enabled": cfg.wiki, 

821 WikiSubdir.SUMMARIES: 0, 

822 WikiSubdir.DRAFTS: 0, 

823 "pages": 0, 

824 "lint_errors": 0, 

825 "lint_warnings": 0, 

826 } 

827 

828 summaries_dir = wiki_root / WikiSubdir.SUMMARIES 

829 drafts_dir = wiki_root / WikiSubdir.DRAFTS 

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

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

832 

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

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

835 return { 

836 "wiki_enabled": cfg.wiki, 

837 WikiSubdir.SUMMARIES: len(summaries), 

838 WikiSubdir.DRAFTS: len(drafts), 

839 "pages": total_wiki_pages(wiki_root), 

840 "lint_errors": report.error_count, 

841 "lint_warnings": report.warning_count, 

842 } 

843 

844 

845@_wiki_tool 

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

847 """List wiki pages with metadata.""" 

848 from dataclasses import asdict 

849 

850 from lilbee.wiki.browse import list_pages 

851 

852 wiki_root = cfg.data_root / cfg.wiki_dir 

853 pages = list_pages(wiki_root) 

854 return { 

855 "command": "wiki_list", 

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

857 "total": len(pages), 

858 } 

859 

860 

861@_wiki_tool 

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

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

864 from dataclasses import asdict 

865 

866 from lilbee.wiki.browse import read_page 

867 

868 wiki_root = cfg.data_root / cfg.wiki_dir 

869 result = read_page(wiki_root, slug) 

870 if result is None: 

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

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

873 

874 

875@_wiki_tool 

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

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

878 

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

880 makes no LLM call. 

881 """ 

882 from lilbee.wiki import run_full_build 

883 from lilbee.wiki.generation import DRY_RUN_CONCEPT_NOTE, preview_build_entities 

884 

885 if dry_run: 

886 rows = preview_build_entities(cfg) 

887 return { 

888 "command": "wiki_build", 

889 "dry_run": True, 

890 "entities": rows, 

891 "count": len(rows), 

892 "note": DRY_RUN_CONCEPT_NOTE, 

893 } 

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

895 

896 

897@_wiki_tool 

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

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

900 from lilbee.wiki import run_full_build 

901 

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

903 

904 

905@_wiki_tool 

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

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

908 from lilbee.wiki import run_full_synthesize 

909 

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

911 

912 

913@_wiki_tool 

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

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

916 from lilbee.wiki.prune import prune_wiki 

917 

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

919 return { 

920 "command": "wiki_prune", 

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

922 "archived": report.archived_count, 

923 "flagged": report.flagged_count, 

924 "reconciled": report.reconciled_count, 

925 } 

926 

927 

928@_wiki_tool 

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

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

931 from lilbee.wiki.stubs import refresh_stub_index 

932 

933 stubs = refresh_stub_index(get_services().store) 

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

935 

936 

937@_wiki_tool 

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

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

940 from lilbee.wiki.browse import page_slug 

941 from lilbee.wiki.lazy import UnknownStubError, generate_stub_page 

942 

943 try: 

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

945 except UnknownStubError as exc: 

946 return _error(str(exc)) 

947 if path is None: 

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

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

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

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

952 

953 

954@_tool 

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

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

957 

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

959 leaves the pages generated earlier in place. 

960 """ 

961 if not confirm: 

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

963 from lilbee.wiki.wipe import wipe_wiki 

964 

965 report = wipe_wiki(get_services().store) 

966 if not report.rows_deleted: 

967 return _error(report.summary()) 

968 return { 

969 "command": "wiki_wipe", 

970 "pages_removed": report.pages_removed, 

971 "sources_cleared": report.sources_cleared, 

972 } 

973 

974 

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

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

977 return { 

978 "key": info.key, 

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

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

981 "type": info.type, 

982 "nullable": info.nullable, 

983 "group": info.group.value, 

984 "help": info.help_text, 

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

986 "reindex_required": info.reindex_required, 

987 } 

988 

989 

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

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

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

993 return value 

994 return str(value) 

995 

996 

997@_tool 

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

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

1000 

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

1002 """ 

1003 

1004 try: 

1005 infos = list_settings(group or None) 

1006 except ValueError as exc: 

1007 return _error(str(exc)) 

1008 return { 

1009 "command": "settings_list", 

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

1011 "total": len(infos), 

1012 } 

1013 

1014 

1015@_tool 

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

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

1018 

1019 try: 

1020 info = get_setting(key) 

1021 except KeyError as exc: 

1022 return _error(str(exc)) 

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

1024 

1025 

1026@_tool 

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

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

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

1030 if _transport.http_mounted and requires_services_reset(updates): 

1031 return _error(provider_reset_refused_message("Switching")) 

1032 try: 

1033 result = apply_settings_update(updates) 

1034 except (ValueError, TypeError) as exc: 

1035 return _error(str(exc)) 

1036 return { 

1037 "command": "settings_set", 

1038 "updated": result.updated, 

1039 "reindex_required": result.reindex_required, 

1040 } 

1041 

1042 

1043@_tool 

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

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

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

1047 return _error(provider_reset_refused_message("Resetting")) 

1048 try: 

1049 result = reset_settings(keys) 

1050 except (ValueError, TypeError) as exc: 

1051 return _error(str(exc)) 

1052 return { 

1053 "command": "settings_reset", 

1054 "updated": result.updated, 

1055 "reindex_required": result.reindex_required, 

1056 } 

1057 

1058 

1059@_tool 

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

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

1062 from lilbee.app.models import list_models_data 

1063 from lilbee.catalog.types import ModelTask 

1064 

1065 try: 

1066 src = ModelSource.parse(source) 

1067 except ValueError as exc: 

1068 return _error(str(exc)) 

1069 try: 

1070 parsed_task = ModelTask(task) if task else None 

1071 except ValueError as exc: 

1072 return _error(str(exc)) 

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

1074 

1075 

1076@_tool 

1077def catalog_browse( 

1078 task: str = "", 

1079 search: str = "", 

1080 size: str = "", 

1081 installed: bool | None = None, 

1082 featured: bool | None = None, 

1083 sort: str = "featured", 

1084 limit: int = 20, 

1085 offset: int = 0, 

1086) -> dict[str, Any]: 

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

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

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

1090 from lilbee.catalog.query import get_catalog 

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

1092 

1093 try: 

1094 parsed_task = ModelTask(task) if task else None 

1095 parsed_size = CatalogSize(size) if size else None 

1096 parsed_sort = CatalogSort(sort) 

1097 except ValueError as exc: 

1098 return _error(str(exc)) 

1099 try: 

1100 result = get_catalog( 

1101 task=parsed_task, 

1102 search=search, 

1103 size=parsed_size, 

1104 installed=installed, 

1105 featured=featured, 

1106 sort=parsed_sort, 

1107 limit=limit, 

1108 offset=offset, 

1109 model_manager=get_services().model_manager, 

1110 ) 

1111 except ValueError as exc: 

1112 return _error(str(exc)) 

1113 return { 

1114 "command": "catalog_browse", 

1115 "total": result.total, 

1116 "limit": result.limit, 

1117 "offset": result.offset, 

1118 "has_more": result.has_more, 

1119 "models": [ 

1120 { 

1121 "ref": m.hf_repo, 

1122 "display_name": m.display_name, 

1123 "task": m.task.value, 

1124 "size_gb": m.size_gb, 

1125 "min_ram_gb": m.min_ram_gb, 

1126 "downloads": m.downloads, 

1127 "featured": m.featured, 

1128 "description": m.description, 

1129 "architecture": m.architecture, 

1130 "compat": m.compat.value, 

1131 } 

1132 for m in result.models 

1133 ], 

1134 } 

1135 

1136 

1137@_tool 

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

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

1140 from lilbee.app.models import show_model_data 

1141 from lilbee.modelhub.model_manager import ModelNotFoundError 

1142 

1143 try: 

1144 return show_model_data(model).model_dump() 

1145 except ModelNotFoundError as exc: 

1146 return _error(str(exc)) 

1147 

1148 

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

1150 """Log report_progress failures without raising. 

1151 

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

1153 an in-flight pull. 

1154 """ 

1155 try: 

1156 future.result() 

1157 except Exception: 

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

1159 

1160 

1161@_tool 

1162async def model_pull( 

1163 model: str, 

1164 source: str = ModelSource.NATIVE.value, 

1165 allow_unsupported: bool = False, 

1166 ctx: Context | None = None, 

1167) -> dict[str, Any]: 

1168 """Download a model and stream progress. 

1169 

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

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

1172 """ 

1173 from lilbee.app.models import pull_model_data 

1174 from lilbee.catalog import DownloadProgress 

1175 from lilbee.catalog.compat import SUPPORTED_ARCHS, UnsupportedArchError 

1176 

1177 try: 

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

1179 except ValueError as exc: 

1180 return _error(str(exc)) 

1181 

1182 loop = asyncio.get_running_loop() 

1183 

1184 with _cancel_token() as cancel: 

1185 

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

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

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

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

1190 if cancel.is_set(): 

1191 raise TaskCancelledError 

1192 if ctx is None: 

1193 return 

1194 future = asyncio.run_coroutine_threadsafe( 

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

1196 loop, 

1197 ) 

1198 future.add_done_callback(_log_progress_failure) 

1199 

1200 try: 

1201 result = await asyncio.to_thread( 

1202 pull_model_data, 

1203 model, 

1204 src, 

1205 on_update=on_update, 

1206 allow_unsupported=allow_unsupported, 

1207 ) 

1208 except UnsupportedArchError as exc: 

1209 return { 

1210 "ok": False, 

1211 "command": "model_pull", 

1212 "error": { 

1213 "code": "unsupported_arch", 

1214 "arch": exc.architecture, 

1215 "ref": exc.ref, 

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

1217 "total_supported": len(SUPPORTED_ARCHS), 

1218 }, 

1219 } 

1220 except (RuntimeError, PermissionError) as exc: 

1221 return _error(str(exc)) 

1222 return result.model_dump() 

1223 

1224 

1225@_tool 

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

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

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

1229 from lilbee.app.models import remove_model_data 

1230 

1231 try: 

1232 src = ModelSource.parse(source) 

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

1234 except ValueError as exc: 

1235 return _error(str(exc)) 

1236 

1237 

1238@_wiki_tool 

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

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

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

1242 from lilbee.wiki.drafts import list_drafts 

1243 

1244 wiki_root = cfg.data_root / cfg.wiki_dir 

1245 drafts = list_drafts(wiki_root) 

1246 return { 

1247 "command": "wiki_drafts_list", 

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

1249 "total": len(drafts), 

1250 } 

1251 

1252 

1253@_wiki_tool 

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

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

1256 from lilbee.core.security import PathTraversalError 

1257 from lilbee.wiki.drafts import diff_draft 

1258 

1259 wiki_root = cfg.data_root / cfg.wiki_dir 

1260 try: 

1261 diff = diff_draft(slug, wiki_root) 

1262 except FileNotFoundError as exc: 

1263 return _error(str(exc)) 

1264 except PathTraversalError: 

1265 return _error(INVALID_DRAFT_SLUG_ERROR) 

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

1267 

1268 

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

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

1271 

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

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

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

1275 """ 

1276 arms = prop.get("anyOf") 

1277 if not isinstance(arms, list): 

1278 return 

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

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

1281 prop.pop("anyOf", None) 

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

1283 prop.setdefault(key, value) 

1284 

1285 

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

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

1288 prop.pop("title", None) 

1289 prop.pop("default", None) 

1290 _collapse_nullable_anyof(prop) 

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

1292 prop.pop("additionalProperties", None) 

1293 

1294 

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

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

1297 

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

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

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

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

1302 blank lines are kept so paragraph breaks survive. 

1303 """ 

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

1305 

1306 

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

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

1309 

1310 Drops: 

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

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

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

1314 omitted fields fall back server-side. 

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

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

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

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

1319 

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

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

1322 previously eating ~60% of the budget. 

1323 """ 

1324 schema = deepcopy(schema) 

1325 schema.pop("title", None) 

1326 properties = schema.get("properties") 

1327 if isinstance(properties, dict): 

1328 for prop in properties.values(): 

1329 if isinstance(prop, dict): 

1330 _strip_property_noise(prop) 

1331 return schema 

1332 

1333 

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

1335 

1336 

1337class LilbeeMCP(MCPServer): 

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

1339 

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

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

1342 

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

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

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

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

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

1348 """ 

1349 tools = await super().list_tools() 

1350 for tool in tools: 

1351 tool.input_schema = _strip_schema(tool.input_schema) 

1352 if isinstance(tool.description, str): 

1353 description = _flatten_tool_description(tool.description) 

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

1355 description += _NO_WIKI_SCOPE_HINT 

1356 tool.description = description 

1357 return tools 

1358 

1359 

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

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

1362 if ctx is None: 

1363 return "" 

1364 params = ctx.session.client_params 

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

1366 

1367 

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

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

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

1371 return slug or "generic" 

1372 

1373 

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

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

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

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

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

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

1380# touch the mapping from different threads. 

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

1382_ANON_OWNER_LOCK = threading.Lock() 

1383 

1384 

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

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

1387 

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

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

1390 """ 

1391 if ctx is None: 

1392 return "anonymous" 

1393 session = ctx.session 

1394 with _ANON_OWNER_LOCK: 

1395 existing = _ANON_OWNER_IDS.get(session) 

1396 if existing is None: 

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

1398 _ANON_OWNER_IDS[session] = existing 

1399 return existing 

1400 

1401 

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

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

1404 

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

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

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

1408 """ 

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

1410 resolved = explicit or _client_name(ctx) 

1411 if resolved: 

1412 return agent_owner(_slug(resolved)) 

1413 return agent_owner(_slug(_anon_owner_id(ctx))) 

1414 

1415 

1416@_tool_if(memory_enabled) 

1417def memory_remember( 

1418 text: str, 

1419 kind: MemoryKind = MemoryKind.FACT, 

1420 shared: bool = False, 

1421 agent_id: str = "", 

1422 ctx: Context | None = None, 

1423) -> dict[str, Any]: 

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

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

1426 if not memory_enabled(): 

1427 return _error(MEMORY_DISABLED_HINT) 

1428 owner = _derive_owner(agent_id, ctx) 

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

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

1431 

1432 

1433@_tool_if(memory_enabled) 

1434def memory_recall( 

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

1436) -> dict[str, Any]: 

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

1438 if not memory_enabled(): 

1439 return _error(MEMORY_DISABLED_HINT) 

1440 owner = _derive_owner(agent_id, ctx) 

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

1442 return { 

1443 "memories": [ 

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

1445 ] 

1446 } 

1447 

1448 

1449@_tool_if(memory_enabled) 

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

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

1452 if not memory_enabled(): 

1453 return _error(MEMORY_DISABLED_HINT) 

1454 owner = _derive_owner(agent_id, ctx) 

1455 memories = list_memories(owner) 

1456 return { 

1457 "memories": [ 

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

1459 ] 

1460 } 

1461 

1462 

1463@_tool_if(memory_enabled) 

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

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

1466 if not memory_enabled(): 

1467 return _error(MEMORY_DISABLED_HINT) 

1468 owner = _derive_owner(agent_id, ctx) 

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

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

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

1472 

1473 

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

1475 from lilbee.server.models import PlacementResponse 

1476 

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

1478 

1479 

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

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

1482 from lilbee.providers.base import ProviderError 

1483 from lilbee.providers.fleet.placement_spec import PlacementError 

1484 

1485 try: 

1486 return serialize() 

1487 except (PlacementError, ProviderError) as exc: 

1488 return _error(str(exc)) 

1489 

1490 

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

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

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

1494 

1495 

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

1497 from lilbee.providers.fleet.placement_spec import PlacementSpec 

1498 

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

1500 

1501 

1502@_tool_named("get_gpus") 

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

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

1505 

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

1507 from lilbee.cli.tui import messages as msg 

1508 from lilbee.providers.fleet.gpu_stats import probe_intel_util_hint 

1509 

1510 view = get_placement() 

1511 hint = probe_intel_util_hint(view.gpus) 

1512 return { 

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

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

1515 } 

1516 

1517 return _placement_guard(_body) 

1518 

1519 

1520@_tool_named("get_placement") 

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

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

1523 return _placement_result(get_placement) 

1524 

1525 

1526@_tool_named("preview_placement") 

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

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

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

1530 

1531 

1532@_tool_named("set_placement") 

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

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

1535 

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

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

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

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

1540 """ 

1541 from lilbee.providers.fleet.placement_spec import PlacementSpec 

1542 

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

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

1545 if _transport.http_mounted and not cfg.allow_http_placement: 

1546 return _error(placement_refused_message()) 

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

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

1549 

1550 

1551@_tool_named("clear_placement") 

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

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

1554 if _transport.http_mounted and not cfg.allow_http_placement: 

1555 return _error(placement_refused_message()) 

1556 return _placement_result(lambda: set_placement(None)) 

1557 

1558 

1559def build_mcp_server() -> LilbeeMCP: 

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

1561 

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

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

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

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

1566 """ 

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

1568 for fn, name, gate in _REGISTRATIONS: 

1569 if gate is None or gate(): 

1570 server.add_tool(fn, name=name) 

1571 return server 

1572 

1573 

1574_PARENT_DEATH_CLEANUP_S = 5.0 

1575 

1576 

1577def _exit_on_parent_death() -> None: 

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

1579 

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

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

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

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

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

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

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

1587 """ 

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

1589 cleanup.start() 

1590 cleanup.join(timeout=_PARENT_DEATH_CLEANUP_S) 

1591 os._exit(0) 

1592 

1593 

1594def main() -> None: 

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

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

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

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

1599 # the server before it attaches to stdio. 

1600 try: 

1601 get_services() 

1602 except Exception: 

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

1604 

1605 from lilbee.parent_monitor import parse_parent_pid, watch_parent_thread 

1606 

1607 parent_pid = parse_parent_pid() 

1608 if parent_pid is not None: 

1609 watch_parent_thread(parent_pid, _exit_on_parent_death) 

1610 

1611 build_mcp_server().run()