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

959 statements  

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

20from enum import StrEnum 

21from pathlib import Path 

22from typing import TYPE_CHECKING, Annotated, Any, TypeVar, cast 

23from weakref import WeakKeyDictionary 

24 

25import anyio 

26from mcp.server.mcpserver import Context, MCPServer 

27from mcp.types import Tool as MCPTool 

28from pydantic import Field 

29 

30from lilbee.app import profiles 

31from lilbee.app.analyze import ( 

32 AnalyzeRequest, 

33 hide_tip, 

34 run_analysis, 

35 server_directory, 

36 tip_state, 

37) 

38from lilbee.app.config_follow import ConfigFileFollower 

39from lilbee.app.memory import ( 

40 MEMORY_DISABLED_HINT, 

41 forget, 

42 list_memories, 

43 memory_enabled, 

44 recall, 

45 remember, 

46) 

47from lilbee.app.placement import ( 

48 PlacementView, 

49 get_placement, 

50 placement_refused_message, 

51 preview_placement, 

52 set_placement, 

53) 

54from lilbee.app.search import clean_results 

55from lilbee.app.services import ( 

56 exit_on_signals_in_loop, 

57 get_services, 

58 reset_services, 

59 reset_store, 

60 set_config_refresh, 

61) 

62from lilbee.app.session_sources import ( 

63 LocatorError, 

64 SourceRefInput, 

65 message_payload, 

66 posted_message, 

67) 

68from lilbee.app.settings import ( 

69 SettingInfo, 

70 apply_settings_update, 

71 config_write_failure_message, 

72 get_setting, 

73 list_settings, 

74 provider_reset_refused_message, 

75 requires_services_reset, 

76 reset_settings, 

77) 

78from lilbee.catalog.types import ModelSource 

79from lilbee.core.config import cfg, validate_ocr_timeout 

80from lilbee.core.config.enums import CrawlRenderMode, OcrMode 

81from lilbee.core.profile_files import ProfileFolder, ProfileStore, ProfileValidation 

82from lilbee.core.settings import overlay_persisted_settings 

83from lilbee.core.system import LOCAL_ROOT_DIRNAME, canonical_data_root 

84from lilbee.crawler import ( 

85 CRAWLER_MISSING_MESSAGE, 

86 crawler_available, 

87 is_url, 

88 require_valid_crawl_url, 

89) 

90from lilbee.crawler.bootstrap import CrawlEngineRefusedError 

91from lilbee.crawler.models import CrawlLogin, crawl_limit_error 

92from lilbee.crawler.stored_login import STORED_LOGIN_NOT_SENT, crawl_login, stored_login_withheld 

93from lilbee.crawler.task import get_task, start_crawl 

94from lilbee.crawler.url_filter import without_userinfo 

95from lilbee.data.store import ( 

96 DOCUMENTS_PAGE_DEFAULT, 

97 DOCUMENTS_PAGE_MAX, 

98 EmbeddingModelMismatchError, 

99 MemoryKind, 

100 MemorySource, 

101 SearchScope, 

102 agent_owner, 

103 scope_to_chunk_type, 

104) 

105from lilbee.runtime.cancellation import TaskCancelledError 

106from lilbee.runtime.hardware import FitLevel, available_memory_for_fit, make_fit_filter 

107from lilbee.runtime.lock import ResetRefusedError 

108from lilbee.runtime.progress import ( 

109 UPGRADE_HEADLINE, 

110 CrawlPageFailedEvent, 

111 EmbedEvent, 

112 EventType, 

113 ProgressEvent, 

114) 

115from lilbee.sessions import ( 

116 AGENT_SESSIONS_DISABLED_HINT, 

117 MessageRole, 

118 Session, 

119 SessionForkRangeError, 

120 SessionNotFoundError, 

121 SessionOrigin, 

122 SessionOwnershipError, 

123 StoredSourceRef, 

124 TitleSource, 

125 agent_sessions_enabled, 

126) 

127from lilbee.wiki.shared import ( 

128 INVALID_DRAFT_SLUG_ERROR, 

129 WIKI_DISABLED_ERROR, 

130 WikiSubdir, 

131 total_wiki_pages, 

132) 

133 

134if TYPE_CHECKING: 

135 from lilbee.providers.fleet.placement_spec import PlacementSpec 

136 

137log = logging.getLogger(__name__) 

138# What a sync of this server is called in the notice of a sync that waits for it. 

139_SYNC_STARTER = "lilbee mcp" 

140 

141_INSTRUCTIONS = ( 

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

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

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

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

146 "those cannot see the indexed corpus." 

147) 

148 

149 

150class _TransportState: 

151 """Process-level MCP transport facts. 

152 

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

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

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

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

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

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

159 """ 

160 

161 http_mounted: bool = False 

162 

163 

164_transport = _TransportState() 

165 

166 

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

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

169 _transport.http_mounted = value 

170 

171 

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

173 

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

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

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

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

178 

179 

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

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

182 return _CANCEL.get() 

183 

184 

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

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

187 

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

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

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

191 generated tool schema is unchanged. 

192 

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

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

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

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

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

198 ingest tools do. 

199 """ 

200 if inspect.iscoroutinefunction(fn): 

201 return fn 

202 

203 @functools.wraps(fn) 

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

205 with _cancel_token() as token: 

206 _CANCEL.set(token) 

207 return await anyio.to_thread.run_sync( 

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

209 ) 

210 

211 return cast("_F", _runner) 

212 

213 

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

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

216 

217 

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

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

220 

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

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

223 """ 

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

225 return fn 

226 

227 

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

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

230 

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

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

233 return fn 

234 

235 return deco 

236 

237 

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

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

240 

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

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

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

244 """ 

245 if not callable(when): 

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

247 

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

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

250 return fn 

251 

252 return deco 

253 

254 

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

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

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

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

259TOOL_GATE_SETTINGS: frozenset[str] = frozenset( 

260 {"wiki", "memory_enabled", "mcp_sessions_enabled", "mcp_profiles_enabled"} 

261) 

262 

263 

264def _wiki_enabled() -> bool: 

265 return cfg.wiki 

266 

267 

268def _profiles_enabled() -> bool: 

269 return cfg.mcp_profiles_enabled 

270 

271 

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

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

274 

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

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

277 without forcing every caller to widen its return type. 

278 """ 

279 return {"error": msg} 

280 

281 

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

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

284 

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

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

287 A write that cannot lock the library returns the refusal as its error. 

288 """ 

289 

290 @functools.wraps(fn) 

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

292 from lilbee.app.ingest import LIBRARY_REFUSALS 

293 

294 if not cfg.wiki: 

295 return _error(WIKI_DISABLED_ERROR) 

296 try: 

297 return fn(*args, **kwargs) 

298 except LIBRARY_REFUSALS as exc: 

299 return _error(str(exc)) 

300 

301 checked = cast("_F", guarded) 

302 _tool_if(_wiki_enabled)(checked) 

303 return checked 

304 

305 

306@_tool 

307def search( 

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

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

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

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

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

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

314 try: 

315 chunk_type = scope_to_chunk_type(scope) 

316 except ValueError: 

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

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

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

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

321 chunk_type = scope_to_chunk_type(SearchScope.BOTH.value) 

322 if top_k is not None and top_k < 1: 

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

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

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

326 top_k = None 

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

328 try: 

329 results = get_services().searcher.search( 

330 query, top_k=effective_top_k, chunk_type=chunk_type 

331 ) 

332 return clean_results(results) 

333 except EmbeddingModelMismatchError as exc: 

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

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

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

337 return { 

338 "error": str(exc), 

339 "code": "INDEX_EMBEDDER_MISMATCH", 

340 "persisted_model": exc.persisted_model, 

341 "persisted_dim": exc.persisted_dim, 

342 "adoptable": exc.dims_match, 

343 } 

344 except Exception as exc: 

345 return _error(str(exc)) 

346 

347 

348@_tool 

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

350 """Show documents, config and chunk counts, or the running upgrade.""" 

351 from lilbee.app.status import gather_status 

352 

353 upgrade = get_services().store.upgrade_progress() 

354 if upgrade is not None: 

355 return {"detail": UPGRADE_HEADLINE, "upgrade": upgrade.model_dump(mode="json")} 

356 return gather_status().model_dump() 

357 

358 

359@contextmanager 

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

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

362 

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

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

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

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

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

368 has to be polled. 

369 

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

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

372 """ 

373 token = threading.Event() 

374 try: 

375 yield token 

376 except BaseException: 

377 token.set() 

378 raise 

379 

380 

381@_tool 

382async def sync( 

383 force_rebuild: bool = False, 

384 retry_skipped: bool = False, 

385 prune_ignored: bool = False, 

386 ocr: OcrMode | None = None, 

387 ocr_timeout: float | None = None, 

388) -> dict[str, Any]: 

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

390 

391 ``force_rebuild`` re-ingests everything. ``retry_skipped`` retries failed 

392 files. ``prune_ignored`` drops sources ``.lilbeeignore`` excludes. 

393 """ 

394 from lilbee.app.ingest import LIBRARY_REFUSALS, temporary_ocr_config 

395 from lilbee.data.ingest import sync as run_sync 

396 

397 try: 

398 validate_ocr_timeout(ocr_timeout) 

399 except ValueError as exc: 

400 return _error(str(exc)) 

401 with temporary_ocr_config(ocr, ocr_timeout), _cancel_token() as cancel: 

402 try: 

403 result = await run_sync( 

404 quiet=True, 

405 force_rebuild=force_rebuild, 

406 retry_skipped=retry_skipped, 

407 prune_ignored=prune_ignored, 

408 cancel=cancel, 

409 started_by=_SYNC_STARTER, 

410 ) 

411 except LIBRARY_REFUSALS as exc: 

412 return _error(str(exc)) 

413 return result.model_dump() 

414 

415 

416async def _sync_after_add( 

417 reached_corpus: bool, ocr: OcrMode | None, ocr_timeout: float | None 

418) -> dict[str, Any] | None: 

419 """The sync that follows an add, or None when nothing named reached the corpus.""" 

420 from lilbee.app.ingest import temporary_ocr_config 

421 from lilbee.data.ingest import sync as run_sync 

422 

423 if not reached_corpus: 

424 return None 

425 with temporary_ocr_config(ocr, ocr_timeout), _cancel_token() as cancel: 

426 return (await run_sync(quiet=True, cancel=cancel, started_by=_SYNC_STARTER)).model_dump() 

427 

428 

429async def _crawl_add_urls( 

430 urls: list[str], render_mode: CrawlRenderMode | None 

431) -> tuple[int, list[str]]: 

432 """Crawl each URL as a single page for ``add``; returns (crawled_count, url_errors).""" 

433 from lilbee.crawler import crawl_and_save 

434 

435 crawled_count = 0 

436 errors: list[str] = [] 

437 

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

439 # The callback receives every crawl event; only a failed page carries a reason. 

440 if isinstance(data, CrawlPageFailedEvent): 

441 errors.append(f"{data.url}: {data.reason}") 

442 

443 for url in urls: 

444 try: 

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

446 # event loop like the sibling crawl tool does. 

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

448 except ValueError as exc: 

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

450 continue 

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

452 try: 

453 crawled_paths = await crawl_and_save( 

454 url, 

455 depth=0, 

456 render_mode=render_mode, 

457 on_progress=on_progress, 

458 login=crawl_login([url]), 

459 ) 

460 except CrawlEngineRefusedError as exc: 

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

462 continue 

463 crawled_count += len(crawled_paths) 

464 return crawled_count, errors 

465 

466 

467def _login_note(urls: list[str]) -> dict[str, str]: 

468 """The note for ``add`` when a stored login is not sent to one of *urls*, else nothing.""" 

469 if any(stored_login_withheld([url]) for url in urls): 

470 return {"login_note": STORED_LOGIN_NOT_SENT} 

471 return {} 

472 

473 

474@_tool 

475async def add( 

476 paths: list[str], 

477 force: bool = False, 

478 ocr: OcrMode | None = None, 

479 ocr_timeout: float | None = None, 

480 render_mode: CrawlRenderMode | None = None, 

481) -> dict[str, Any]: 

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

483 Paths are absolute and resolve on the machine running lilbee, not the 

484 caller's when the server is remote. URLs are fetched as single pages; use 

485 ``crawl`` for sites.""" 

486 from lilbee.app.ingest import LIBRARY_REFUSALS, register_sources 

487 

488 try: 

489 validate_ocr_timeout(ocr_timeout) 

490 except ValueError as exc: 

491 return _error(str(exc)) 

492 

493 errors: list[str] = [] 

494 valid: list[Path] = [] 

495 urls: list[str] = [] 

496 for p_str in paths: 

497 if is_url(p_str): 

498 urls.append(p_str) 

499 else: 

500 p = Path(p_str) 

501 if not p.exists(): 

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

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

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

505 errors.append( 

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

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

508 f"{cfg.documents_dir}." 

509 ) 

510 else: 

511 valid.append(p) 

512 

513 # Crawl URLs 

514 crawled_count = 0 

515 if urls: 

516 if not crawler_available(): 

517 return _error(CRAWLER_MISSING_MESSAGE) 

518 crawled_count, url_errors = await _crawl_add_urls(urls, render_mode) 

519 errors.extend(url_errors) 

520 

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

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

523 try: 

524 reg_result = await anyio.to_thread.run_sync( 

525 functools.partial(register_sources, valid, force=force), abandon_on_cancel=True 

526 ) 

527 errors.extend(reg_result.refused) 

528 reached = reg_result.reached_corpus or bool(crawled_count) 

529 sync_result = await _sync_after_add(reached, ocr, ocr_timeout) 

530 except LIBRARY_REFUSALS as exc: 

531 return _error(str(exc)) 

532 result: dict[str, Any] = { 

533 "command": "add", 

534 "copied": reg_result.registered, 

535 "name_taken": reg_result.name_taken, 

536 "overlapping": reg_result.overlapping, 

537 "absorbed": reg_result.absorbed, 

538 "tracked": reg_result.tracked, 

539 "crawled": crawled_count, 

540 "errors": errors, 

541 "sync": sync_result, 

542 } 

543 if errors and not reached: 

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

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

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

547 if errors or (sync_result is not None and sync_result.get("failed")): 

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

549 return result | _login_note(urls) 

550 

551 

552@_tool_if(crawler_available) 

553async def crawl( 

554 url: str, 

555 depth: int | None = 0, 

556 max_pages: int | None = None, 

557 render_mode: CrawlRenderMode | None = None, 

558 include_subdomains: bool = False, 

559 authorization: str = "", 

560 username: str = "", 

561 password: str = "", 

562 cookie: str = "", 

563) -> dict[str, Any]: 

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

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

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

567 ``authorization`` (a whole Authorization header value) or ``username`` and 

568 ``password``, and ``cookie`` (a Cookie header value), log in to the seed host only.""" 

569 if not crawler_available(): 

570 return _error(CRAWLER_MISSING_MESSAGE) 

571 if (too_large := crawl_limit_error(depth, max_pages)) is not None: 

572 return _error(too_large) 

573 try: 

574 login = crawl_login([url], CrawlLogin(authorization, username, password, cookie)) 

575 except ValueError as exc: 

576 return _error(str(exc)) 

577 try: 

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

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

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

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

582 except ValueError as exc: 

583 return _error(str(exc)) 

584 

585 task_id = start_crawl( 

586 url, 

587 depth=depth, 

588 max_pages=max_pages, 

589 render_mode=render_mode, 

590 include_subdomains=include_subdomains, 

591 login=login, 

592 ) 

593 return {"status": "started", "task_id": task_id, "url": without_userinfo(url)} 

594 

595 

596@_tool_if(crawler_available) 

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

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

599 task = get_task(task_id) 

600 if task is None: 

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

602 return { 

603 "task_id": task.task_id, 

604 "url": task.shown_url, 

605 "status": task.status.value, 

606 "pages_crawled": task.pages_crawled, 

607 "pages_total": task.pages_total, 

608 "pages_failed": task.pages_failed, 

609 "failure_reasons": task.failure_reasons, 

610 "error": task.error, 

611 "started_at": task.started_at, 

612 "finished_at": task.finished_at, 

613 } 

614 

615 

616@_tool 

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

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

619 from lilbee.crawler.task import cancel_crawl 

620 

621 if get_task(task_id) is None: 

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

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

624 

625 

626@_tool 

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

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

629 

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

631 """ 

632 if _transport.http_mounted: 

633 return _error( 

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

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

636 ) 

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

638 # would derive for the same directory. 

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

640 root = base / LOCAL_ROOT_DIRNAME 

641 

642 created = False 

643 if not root.is_dir(): 

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

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

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

647 created = True 

648 

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

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

651 # matching the CLI's --data-dir behaviour. The environment stays as it 

652 # is: a request thread must not write it. 

653 cfg.data_root = base 

654 cfg.documents_dir = root / "documents" 

655 cfg.data_dir = root / "data" 

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

657 overlay_persisted_settings(base) 

658 reset_services() 

659 

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

661 

662 

663@_tool 

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

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

666 

667 Source files are never deleted. A folder removes everything indexed beneath 

668 it; a glob (``*``/``?``/``[]``) removes every match.""" 

669 from lilbee.app.ingest import LIBRARY_REFUSALS, remove_documents_durably 

670 

671 try: 

672 result = await anyio.to_thread.run_sync(remove_documents_durably, names) 

673 except LIBRARY_REFUSALS as exc: 

674 return _error(str(exc)) 

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

676 

677 

678@_tool 

679def list_documents(limit: int = DOCUMENTS_PAGE_DEFAULT, offset: int = 0) -> dict[str, Any]: 

680 """List documents and chunk counts, paged (limit 1-1000).""" 

681 if not 1 <= limit <= DOCUMENTS_PAGE_MAX: 

682 return _error(f"limit must be from 1 to {DOCUMENTS_PAGE_MAX}") 

683 if offset < 0: 

684 return _error("offset must be 0 or more") 

685 store = get_services().store 

686 page = store.get_sources(limit=limit, offset=offset) 

687 total = store.count_sources() 

688 return { 

689 "documents": [ 

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

691 ], 

692 "total": total, 

693 "has_more": len(page) > 0 and (offset + len(page)) < total, 

694 } 

695 

696 

697_AGENT_ORIGINS = frozenset({SessionOrigin.MCP}) 

698 

699 

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

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

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

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

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

705 raise SessionNotFoundError(session_id) 

706 return session 

707 

708 

709@_tool_if(agent_sessions_enabled) 

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

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

712 if not agent_sessions_enabled(): 

713 return _error(AGENT_SESSIONS_DISABLED_HINT) 

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

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

716 

717 

718@_tool_if(agent_sessions_enabled) 

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

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

721 if not agent_sessions_enabled(): 

722 return _error(AGENT_SESSIONS_DISABLED_HINT) 

723 try: 

724 session = _require_agent_session(session_id) 

725 except SessionNotFoundError as exc: 

726 return _error(str(exc)) 

727 return _session_payload(session) 

728 

729 

730def _session_payload(session: Session) -> dict[str, Any]: 

731 """An agent session as the session tools return it: meta, transcript, summary.""" 

732 return { 

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

734 "messages": [message_payload(message) for message in session.messages], 

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

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

737 # rebuilds history without what was already condensed. 

738 "summary": session.summary, 

739 } 

740 

741 

742@_tool_if(agent_sessions_enabled) 

743def session_fork(session_id: str, message_count: int | None = None) -> dict[str, Any]: 

744 """Copy the first message_count messages (all when omitted) into a new session.""" 

745 if not agent_sessions_enabled(): 

746 return _error(AGENT_SESSIONS_DISABLED_HINT) 

747 store = get_services().session_store 

748 try: 

749 _require_agent_session(session_id) 

750 fork_id = store.fork(session_id, message_count=message_count, origin=SessionOrigin.MCP) 

751 except (SessionNotFoundError, SessionForkRangeError) as exc: 

752 return _error(str(exc)) 

753 return _session_payload(store.get(fork_id)) 

754 

755 

756@_tool_if(agent_sessions_enabled) 

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

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

759 if not agent_sessions_enabled(): 

760 return _error(AGENT_SESSIONS_DISABLED_HINT) 

761 session_id = get_services().session_store.create( 

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

763 ) 

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

765 

766 

767@_tool_if(agent_sessions_enabled) 

768def session_add_message( 

769 session_id: str, 

770 role: MessageRole, 

771 content: str, 

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

773 claim: bool = False, 

774 source_refs: list[SourceRefInput] | None = None, 

775) -> dict[str, Any]: 

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

777 ``source_refs`` is the answer's Sources list, each ``{source, citation_number, locator}``; 

778 a locator is ``page N``, ``pages A-B``, ``line N`` or ``lines A-B``, or absent.""" 

779 if not agent_sessions_enabled(): 

780 return _error(AGENT_SESSIONS_DISABLED_HINT) 

781 store = get_services().session_store 

782 try: 

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

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

785 message = posted_message( 

786 MessageRole(role), 

787 content, 

788 sources or (), 

789 None 

790 if source_refs is None 

791 else [ 

792 StoredSourceRef(r["source"], r.get("citation_number"), r.get("locator", "")) 

793 for r in source_refs 

794 ], 

795 ) 

796 if claim: 

797 store.transfer(session_id, SessionOrigin.MCP) 

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

799 except (SessionNotFoundError, SessionOwnershipError, LocatorError) as exc: 

800 return _error(str(exc)) 

801 except ValueError as exc: 

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

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

804 

805 

806@_tool_if(agent_sessions_enabled) 

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

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

809 if not agent_sessions_enabled(): 

810 return _error(AGENT_SESSIONS_DISABLED_HINT) 

811 try: 

812 _require_agent_session(session_id) 

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

814 except SessionNotFoundError as exc: 

815 return _error(str(exc)) 

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

817 

818 

819@_tool_if(agent_sessions_enabled) 

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

821 """Rename an agent session.""" 

822 if not agent_sessions_enabled(): 

823 return _error(AGENT_SESSIONS_DISABLED_HINT) 

824 try: 

825 _require_agent_session(session_id) 

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

827 except SessionNotFoundError as exc: 

828 return _error(str(exc)) 

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

830 

831 

832@_tool_if(agent_sessions_enabled) 

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

834 """Delete an agent session.""" 

835 if not agent_sessions_enabled(): 

836 return _error(AGENT_SESSIONS_DISABLED_HINT) 

837 try: 

838 _require_agent_session(session_id) 

839 get_services().session_store.delete(session_id) 

840 except SessionNotFoundError as exc: 

841 return _error(str(exc)) 

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

843 

844 

845@_tool 

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

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

848 

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

850 """ 

851 from lilbee.app.dataset import DatasetError, export_to_path 

852 

853 try: 

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

855 except (DatasetError, OSError) as exc: # OSError: output path unwritable or library unreadable 

856 return _error(str(exc)) 

857 return summary.model_dump() 

858 

859 

860@_tool 

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

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

863 

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

865 """ 

866 from lilbee.app.dataset import DatasetError, import_from_path 

867 from lilbee.app.ingest import LIBRARY_REFUSALS 

868 

869 loop = asyncio.get_running_loop() 

870 

871 with _cancel_token() as cancel: 

872 

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

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

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

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

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

878 if cancel.is_set(): 

879 raise TaskCancelledError 

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

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

882 return 

883 future = asyncio.run_coroutine_threadsafe( 

884 ctx.report_progress( 

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

886 ), 

887 loop, 

888 ) 

889 future.add_done_callback(_log_progress_failure) 

890 

891 try: 

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

893 except (DatasetError, *LIBRARY_REFUSALS) as exc: 

894 return _error(str(exc)) 

895 return summary.model_dump() 

896 

897 

898@_tool 

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

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

901 if _transport.http_mounted: 

902 return _error( 

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

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

905 ) 

906 if not confirm: 

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

908 from lilbee.app.reset import perform_reset 

909 

910 try: 

911 result = perform_reset().model_dump() 

912 except ResetRefusedError as exc: 

913 return _error(str(exc)) 

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

915 reset_store() 

916 return result 

917 

918 

919@_wiki_tool 

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

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

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

923 

924 store = get_services().store 

925 report = ( 

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

927 if wiki_source 

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

929 ) 

930 return { 

931 "command": "wiki_lint", 

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

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

934 "errors": report.error_count, 

935 "warnings": report.warning_count, 

936 } 

937 

938 

939@_wiki_tool 

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

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

942 

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

944 """ 

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

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

947 store = get_services().store 

948 if source: 

949 records = store.get_citations_for_source(source) 

950 return { 

951 "command": "wiki_citations", 

952 "source": source, 

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

954 "total": len(records), 

955 } 

956 records = store.get_citations_for_wiki(wiki_source) 

957 return { 

958 "command": "wiki_citations", 

959 "wiki_source": wiki_source, 

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

961 "total": len(records), 

962 } 

963 

964 

965@_tool 

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

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

968 

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

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

971 """ 

972 from lilbee.wiki.lint import lint_all 

973 

974 wiki_root = cfg.data_root / cfg.wiki_dir 

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

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

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

978 return { 

979 "wiki_enabled": cfg.wiki, 

980 WikiSubdir.SUMMARIES: 0, 

981 WikiSubdir.DRAFTS: 0, 

982 "pages": 0, 

983 "lint_errors": 0, 

984 "lint_warnings": 0, 

985 } 

986 

987 summaries_dir = wiki_root / WikiSubdir.SUMMARIES 

988 drafts_dir = wiki_root / WikiSubdir.DRAFTS 

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

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

991 

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

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

994 return { 

995 "wiki_enabled": cfg.wiki, 

996 WikiSubdir.SUMMARIES: len(summaries), 

997 WikiSubdir.DRAFTS: len(drafts), 

998 "pages": total_wiki_pages(wiki_root), 

999 "lint_errors": report.error_count, 

1000 "lint_warnings": report.warning_count, 

1001 } 

1002 

1003 

1004@_wiki_tool 

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

1006 """List wiki pages with metadata.""" 

1007 from dataclasses import asdict 

1008 

1009 from lilbee.wiki.browse import list_pages 

1010 

1011 wiki_root = cfg.data_root / cfg.wiki_dir 

1012 pages = list_pages(wiki_root) 

1013 return { 

1014 "command": "wiki_list", 

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

1016 "total": len(pages), 

1017 } 

1018 

1019 

1020@_wiki_tool 

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

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

1023 from dataclasses import asdict 

1024 

1025 from lilbee.wiki.browse import read_page 

1026 

1027 wiki_root = cfg.data_root / cfg.wiki_dir 

1028 result = read_page(wiki_root, slug) 

1029 if result is None: 

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

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

1032 

1033 

1034@_wiki_tool 

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

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

1037 

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

1039 makes no LLM call. 

1040 """ 

1041 from lilbee.wiki import run_full_build 

1042 from lilbee.wiki.generation import DRY_RUN_CONCEPT_NOTE, preview_build_entities 

1043 

1044 if dry_run: 

1045 rows = preview_build_entities(cfg) 

1046 return { 

1047 "command": "wiki_build", 

1048 "dry_run": True, 

1049 "entities": rows, 

1050 "count": len(rows), 

1051 "note": DRY_RUN_CONCEPT_NOTE, 

1052 } 

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

1054 

1055 

1056@_wiki_tool 

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

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

1059 from lilbee.wiki import run_full_build 

1060 

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

1062 

1063 

1064@_wiki_tool 

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

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

1067 from lilbee.wiki import run_full_synthesize 

1068 

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

1070 

1071 

1072@_wiki_tool 

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

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

1075 from lilbee.wiki.prune import prune_wiki 

1076 

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

1078 return { 

1079 "command": "wiki_prune", 

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

1081 "archived": report.archived_count, 

1082 "flagged": report.flagged_count, 

1083 "reconciled": report.reconciled_count, 

1084 } 

1085 

1086 

1087@_wiki_tool 

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

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

1090 from lilbee.wiki.stubs import refresh_stub_index 

1091 

1092 stubs = refresh_stub_index(get_services().store) 

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

1094 

1095 

1096@_wiki_tool 

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

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

1099 from lilbee.wiki.browse import page_slug 

1100 from lilbee.wiki.lazy import UnknownStubError, generate_stub_page 

1101 

1102 try: 

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

1104 except UnknownStubError as exc: 

1105 return _error(str(exc)) 

1106 if path is None: 

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

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

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

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

1111 

1112 

1113@_tool 

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

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

1116 

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

1118 leaves the pages generated earlier in place. 

1119 """ 

1120 if not confirm: 

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

1122 from lilbee.wiki.wipe import wipe_wiki 

1123 

1124 report = wipe_wiki(get_services().store) 

1125 if not report.rows_deleted: 

1126 return _error(report.summary()) 

1127 return { 

1128 "command": "wiki_wipe", 

1129 "pages_removed": report.pages_removed, 

1130 "sources_cleared": report.sources_cleared, 

1131 } 

1132 

1133 

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

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

1136 return { 

1137 "key": info.key, 

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

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

1140 "type": info.type, 

1141 "nullable": info.nullable, 

1142 "group": info.group.value, 

1143 "help": info.help_text, 

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

1145 "reindex_required": info.reindex_required, 

1146 "source": info.source.value, 

1147 "advanced": info.advanced, 

1148 } 

1149 

1150 

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

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

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

1154 return value 

1155 return str(value) 

1156 

1157 

1158@_tool 

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

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

1161 

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

1163 """ 

1164 

1165 try: 

1166 infos = list_settings(group or None) 

1167 except ValueError as exc: 

1168 return _error(str(exc)) 

1169 return { 

1170 "command": "settings_list", 

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

1172 "total": len(infos), 

1173 } 

1174 

1175 

1176@_tool 

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

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

1179 

1180 try: 

1181 info = get_setting(key) 

1182 except (KeyError, ValueError) as exc: 

1183 return _error(str(exc)) 

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

1185 

1186 

1187@_tool 

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

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

1190 Persists to config.toml; returns updated, reindex_required, warnings.""" 

1191 if _transport.http_mounted and requires_services_reset(updates): 

1192 return _error(provider_reset_refused_message("Switching")) 

1193 try: 

1194 result = apply_settings_update(updates) 

1195 except (ValueError, TypeError) as exc: 

1196 return _error(str(exc)) 

1197 except OSError as exc: 

1198 return _error(config_write_failure_message(exc)) 

1199 return { 

1200 "command": "settings_set", 

1201 "updated": result.updated, 

1202 "reindex_required": result.reindex_required, 

1203 "warnings": list(result.warnings), 

1204 } 

1205 

1206 

1207@_tool 

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

1209 """Drop your values; env, profile or default applies.""" 

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

1211 return _error(provider_reset_refused_message("Resetting")) 

1212 try: 

1213 result = reset_settings(keys) 

1214 except (ValueError, TypeError) as exc: 

1215 return _error(str(exc)) 

1216 except OSError as exc: 

1217 return _error(config_write_failure_message(exc)) 

1218 return { 

1219 "command": "settings_reset", 

1220 "updated": result.updated, 

1221 "reindex_required": result.reindex_required, 

1222 "warnings": list(result.warnings), 

1223 } 

1224 

1225 

1226class ProfileAction(StrEnum): 

1227 """A profile file operation ``profile_manage`` runs.""" 

1228 

1229 NEW = "new" 

1230 SAVE = "save" 

1231 UPDATE = "update" 

1232 DISCARD = "discard" 

1233 DUPLICATE = "duplicate" 

1234 RENAME = "rename" 

1235 DELETE = "delete" 

1236 EXPORT = "export" 

1237 IMPORT = "import" 

1238 VALIDATE = "validate" 

1239 

1240 

1241# A plain string that lists the actions in the tool schema. A typed enum would make FastMCP 

1242# refuse an unknown action with a validation dump before the tool runs. 

1243_ProfileActionChoice = Annotated[ 

1244 str, Field(json_schema_extra={"enum": [a.value for a in ProfileAction]}) 

1245] 

1246 

1247 

1248@dataclass(frozen=True) 

1249class _ManageArgs: 

1250 """The arguments of one ``profile_manage`` call.""" 

1251 

1252 name: str 

1253 new_name: str 

1254 folder: ProfileFolder 

1255 content: str 

1256 filename: str 

1257 from_profile: str 

1258 overwrite: bool 

1259 

1260 

1261def _profile_call(operation: Callable[[], dict[str, Any]]) -> dict[str, Any]: 

1262 """Run a profile operation; a refusal returns its reason as the error.""" 

1263 if not _profiles_enabled(): 

1264 return _error(profiles.MCP_PROFILES_DISABLED_HINT) 

1265 try: 

1266 return operation() 

1267 except ValueError as exc: 

1268 return _error(str(exc)) 

1269 except OSError as exc: 

1270 return _error(profiles.file_failure_message(exc)) 

1271 

1272 

1273def _profile_store() -> ProfileStore: 

1274 return get_services().profile_store 

1275 

1276 

1277def _location_dict(location: profiles.ProfileLocation) -> dict[str, Any]: 

1278 from lilbee.server.models import ProfileLocationResponse 

1279 

1280 return ProfileLocationResponse.from_location(location).model_dump(mode="json") 

1281 

1282 

1283def _save_dict(result: profiles.SaveResult) -> dict[str, Any]: 

1284 from lilbee.server.models import ProfileSaveResponse 

1285 

1286 return ProfileSaveResponse.from_save(result).model_dump(mode="json") 

1287 

1288 

1289def _discard_dict(result: profiles.DiscardResult) -> dict[str, Any]: 

1290 from lilbee.server.models import ProfileDiscardResponse 

1291 

1292 return ProfileDiscardResponse.from_result(result).model_dump(mode="json") 

1293 

1294 

1295def _validation_dict(result: ProfileValidation) -> dict[str, Any]: 

1296 from lilbee.server.models import ProfileValidationResponse 

1297 

1298 return ProfileValidationResponse.from_validation(result).model_dump(mode="json") 

1299 

1300 

1301def _export_dict(store: ProfileStore, args: _ManageArgs) -> dict[str, Any]: 

1302 exported = profiles.export_text(store, args.name) 

1303 return { 

1304 "name": exported.profile.name, 

1305 "folder": exported.folder, 

1306 "filename": exported.filename, 

1307 "content": exported.text, 

1308 } 

1309 

1310 

1311_MANAGE_ACTIONS: dict[ProfileAction, Callable[[ProfileStore, _ManageArgs], dict[str, Any]]] = { 

1312 ProfileAction.NEW: lambda store, a: _location_dict( 

1313 profiles.new(store, a.name, a.folder, from_name=a.from_profile or None) 

1314 ), 

1315 ProfileAction.SAVE: lambda _store, a: _save_dict(profiles.save_as(a.name, a.folder)), 

1316 ProfileAction.UPDATE: lambda store, a: _save_dict(profiles.update(store, a.name or None)), 

1317 ProfileAction.DISCARD: lambda _store, _a: _discard_dict(profiles.discard()), 

1318 ProfileAction.DUPLICATE: lambda store, a: _location_dict( 

1319 profiles.duplicate(store, a.name, a.new_name, a.folder) 

1320 ), 

1321 ProfileAction.RENAME: lambda store, a: _location_dict( 

1322 profiles.rename(store, a.name, a.new_name) 

1323 ), 

1324 ProfileAction.DELETE: lambda store, a: _location_dict(profiles.delete(store, a.name)), 

1325 ProfileAction.EXPORT: _export_dict, 

1326 ProfileAction.IMPORT: lambda store, a: _location_dict( 

1327 profiles.import_text(store, a.content, a.filename, a.folder, overwrite=a.overwrite) 

1328 ), 

1329 ProfileAction.VALIDATE: lambda _store, a: _validation_dict( 

1330 profiles.validate_content(a.content, a.filename, a.folder) 

1331 ), 

1332} 

1333 

1334 

1335# The parameters each action cannot run without, so an empty one is refused by name. 

1336_MANAGE_REQUIRED: dict[ProfileAction, tuple[str, ...]] = { 

1337 ProfileAction.NEW: ("name",), 

1338 ProfileAction.SAVE: ("name",), 

1339 ProfileAction.DUPLICATE: ("name", "new_name"), 

1340 ProfileAction.RENAME: ("name", "new_name"), 

1341 ProfileAction.DELETE: ("name",), 

1342 ProfileAction.EXPORT: ("name",), 

1343 ProfileAction.IMPORT: ("content",), 

1344 ProfileAction.VALIDATE: ("content",), 

1345} 

1346 

1347 

1348def _missing_manage_parameters(chosen: ProfileAction, args: _ManageArgs) -> list[str]: 

1349 return [p for p in _MANAGE_REQUIRED.get(chosen, ()) if not getattr(args, p)] 

1350 

1351 

1352def _profile_listing() -> dict[str, Any]: 

1353 from lilbee.server.models import ActiveProfileResponse, ProfileEntryResponse 

1354 

1355 store = _profile_store() 

1356 active = ActiveProfileResponse.from_active(profiles.active(store)) 

1357 entries = profiles.list_profiles(store).entries 

1358 return { 

1359 "active": active.model_dump(mode="json"), 

1360 "profiles": [ProfileEntryResponse.from_entry(e).model_dump(mode="json") for e in entries], 

1361 } 

1362 

1363 

1364def _profile_detail(name: str) -> dict[str, Any]: 

1365 from lilbee.server.models import ProfileDiffResponse, ProfileEntryResponse 

1366 

1367 store = _profile_store() 

1368 entry = profiles.show(store, name) 

1369 diff = profiles.diff(store, name) if entry.file is not None else None 

1370 return { 

1371 "profile": ProfileEntryResponse.from_entry(entry).model_dump(mode="json"), 

1372 "diff": ProfileDiffResponse.from_diff(diff).model_dump(mode="json") if diff else None, 

1373 } 

1374 

1375 

1376def _profile_applied(name: str) -> dict[str, Any]: 

1377 from lilbee.server.models import ProfileApplyResponse 

1378 

1379 result = profiles.apply(_profile_store(), name) 

1380 return ProfileApplyResponse.from_result(result).model_dump(mode="json") 

1381 

1382 

1383@_tool_if(_profiles_enabled) 

1384def profile_list() -> dict[str, Any]: 

1385 """The project's active profile and every profile, with credit and broken-file reasons.""" 

1386 return _profile_call(_profile_listing) 

1387 

1388 

1389@_tool_if(_profiles_enabled) 

1390def profile_show(name: str) -> dict[str, Any]: 

1391 """One profile and what applying it changes (``diff`` is null for a broken file).""" 

1392 return _profile_call(lambda: _profile_detail(name)) 

1393 

1394 

1395@_tool_if(_profiles_enabled) 

1396async def profile_apply(name: str, reindex: bool = False) -> dict[str, Any]: 

1397 """Make a profile the project's; your values stay. Rebuild when ``reindex_required``. 

1398 

1399 ``reindex`` also rebuilds the index when the profile needs it and blocks until 

1400 done: ``rebuild`` is the sync result, or null when no rebuild was needed. 

1401 """ 

1402 applied = await asyncio.to_thread(_profile_call, lambda: _profile_applied(name)) 

1403 if not reindex or "error" in applied: 

1404 return applied 

1405 return {**applied, **await _rebuild_after_apply(applied)} 

1406 

1407 

1408async def _rebuild_after_apply(applied: dict[str, Any]) -> dict[str, Any]: 

1409 """The ``rebuild`` result an apply needs, with ``rebuild_error`` when it is refused.""" 

1410 needed = await asyncio.to_thread( 

1411 profiles.apply_needs_rebuild, 

1412 _profile_store(), 

1413 applied["name"], 

1414 applied["reindex_required"], 

1415 ) 

1416 return await _rebuild_when(needed) 

1417 

1418 

1419async def _rebuild_after_discard(discarded: dict[str, Any]) -> dict[str, Any]: 

1420 """The ``rebuild`` result a discard needs, with ``rebuild_error`` when it is refused.""" 

1421 reason = await asyncio.to_thread( 

1422 profiles.rebuild_reason, discarded["dropped"], discarded["reindex_required"] 

1423 ) 

1424 return await _rebuild_when(reason is not None) 

1425 

1426 

1427async def _rebuild_when(needed: bool) -> dict[str, Any]: 

1428 """A forced sync's ``rebuild`` result when *needed*, else null; ``rebuild_error`` if refused.""" 

1429 from lilbee.app.ingest import LIBRARY_REFUSALS 

1430 from lilbee.data.ingest import sync as run_sync 

1431 

1432 if not needed: 

1433 return {"rebuild": None} 

1434 with _cancel_token() as cancel: 

1435 try: 

1436 result = await run_sync( 

1437 quiet=True, force_rebuild=True, cancel=cancel, started_by=_SYNC_STARTER 

1438 ) 

1439 except LIBRARY_REFUSALS as exc: 

1440 return {"rebuild": None, "rebuild_error": str(exc)} 

1441 return {"rebuild": result.model_dump()} 

1442 

1443 

1444@_tool_if(_profiles_enabled) 

1445async def profile_manage( 

1446 action: _ProfileActionChoice = "", 

1447 name: str = "", 

1448 new_name: str = "", 

1449 folder: ProfileFolder = ProfileFolder.GLOBAL, 

1450 content: str = "", 

1451 filename: str = "", 

1452 from_profile: str = "", 

1453 overwrite: bool = False, 

1454 reindex: bool = False, 

1455) -> dict[str, Any]: 

1456 """Profile files: new, save (as name), update, discard, duplicate/rename (to new_name), 

1457 delete, export (returns its content), import/validate (a file's content and its name). 

1458 ``folder``: global or project. ``reindex`` (discard only) also rebuilds the index when 

1459 the discard needs it and blocks until done: ``rebuild`` is the sync result, or null.""" 

1460 try: 

1461 chosen = ProfileAction(action) 

1462 except ValueError: 

1463 allowed = ", ".join(a.value for a in ProfileAction) 

1464 return _error(f"Unknown profile action {action!r}. Use one of: {allowed}.") 

1465 args = _ManageArgs(name, new_name, folder, content, filename, from_profile, overwrite) 

1466 if missing := _missing_manage_parameters(chosen, args): 

1467 return _error(f"Action {chosen.value!r} needs: {', '.join(missing)}.") 

1468 managed = await asyncio.to_thread( 

1469 _profile_call, lambda: _MANAGE_ACTIONS[chosen](_profile_store(), args) 

1470 ) 

1471 if not reindex or chosen is not ProfileAction.DISCARD or "error" in managed: 

1472 return managed 

1473 return {**managed, **await _rebuild_after_discard(managed)} 

1474 

1475 

1476@_tool_if(_profiles_enabled) 

1477async def analyze( 

1478 directory: str | None = None, 

1479 apply: bool = False, 

1480 save: str | None = None, 

1481 target: ProfileFolder | None = None, 

1482) -> dict[str, Any]: 

1483 """Read the corpus, or an absolute ``directory`` on the server, and recommend a profile. 

1484 ``apply`` saves it and switches to it; ``save`` saves it as that name without switching; 

1485 ``target`` (project or global) needs one of them. No AI model runs.""" 

1486 from lilbee.server.models import AnalyzeResponse 

1487 

1488 if not _profiles_enabled(): 

1489 return _error(profiles.MCP_PROFILES_DISABLED_HINT) 

1490 try: 

1491 request = AnalyzeRequest(server_directory(directory), apply, save, target) 

1492 with _cancel_token() as cancel: 

1493 report = await run_analysis(_profile_store(), request, cancel=cancel) 

1494 except ValueError as exc: 

1495 return _error(str(exc)) 

1496 except OSError as exc: 

1497 return _error(profiles.file_failure_message(exc)) 

1498 return AnalyzeResponse.from_report(report).model_dump(mode="json") 

1499 

1500 

1501def _tip_dismissed() -> dict[str, Any]: 

1502 from lilbee.server.models import AnalyzeStateResponse 

1503 

1504 hide_tip(cfg.data_root) 

1505 return AnalyzeStateResponse.from_state(tip_state(cfg.data_root)).model_dump(mode="json") 

1506 

1507 

1508@_tool_if(_profiles_enabled) 

1509def analyze_dismiss() -> dict[str, Any]: 

1510 """Hide the analyze tip for this project.""" 

1511 return _profile_call(_tip_dismissed) 

1512 

1513 

1514@_tool 

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

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

1517 from lilbee.app.models import list_models_data 

1518 from lilbee.catalog.types import ModelTask 

1519 

1520 try: 

1521 src = ModelSource.parse(source) 

1522 except ValueError as exc: 

1523 return _error(str(exc)) 

1524 try: 

1525 parsed_task = ModelTask(task) if task else None 

1526 except ValueError as exc: 

1527 return _error(str(exc)) 

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

1529 

1530 

1531@_tool 

1532def catalog_browse( 

1533 task: str = "", 

1534 search: str = "", 

1535 size: str = "", 

1536 installed: bool | None = None, 

1537 featured: bool | None = None, 

1538 max_fit: str = "", 

1539 sort: str = "featured", 

1540 limit: int = 20, 

1541 offset: int = 0, 

1542) -> dict[str, Any]: 

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

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

1545 ``max_fit``: fits/tight/wont_run, worst fit to keep. 

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

1547 from lilbee.catalog.query import get_catalog 

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

1549 

1550 try: 

1551 parsed_task = ModelTask(task) if task else None 

1552 parsed_size = CatalogSize(size) if size else None 

1553 parsed_sort = CatalogSort(sort) 

1554 parsed_max_fit = FitLevel(max_fit) if max_fit else None 

1555 except ValueError as exc: 

1556 return _error(str(exc)) 

1557 # The rows here carry no fit chip; only the filter needs the uncached GPU probe. 

1558 available_bytes = available_memory_for_fit() if parsed_max_fit is not None else None 

1559 try: 

1560 result = get_catalog( 

1561 task=parsed_task, 

1562 search=search, 

1563 size=parsed_size, 

1564 installed=installed, 

1565 featured=featured, 

1566 fit_filter=make_fit_filter(parsed_max_fit, available_bytes), 

1567 sort=parsed_sort, 

1568 limit=limit, 

1569 offset=offset, 

1570 model_manager=get_services().model_manager, 

1571 ) 

1572 except ValueError as exc: 

1573 return _error(str(exc)) 

1574 return { 

1575 "command": "catalog_browse", 

1576 "total": result.total, 

1577 "limit": result.limit, 

1578 "offset": result.offset, 

1579 "has_more": result.has_more, 

1580 "truncated": result.truncated, 

1581 "models": [ 

1582 { 

1583 "ref": m.hf_repo, 

1584 "display_name": m.display_name, 

1585 "task": m.task.value, 

1586 "size_gb": m.size_gb, 

1587 "min_ram_gb": m.min_ram_gb, 

1588 "downloads": m.downloads, 

1589 "featured": m.featured, 

1590 "description": m.description, 

1591 "architecture": m.architecture, 

1592 "compat": m.compat.value, 

1593 "safety_stripped": m.safety_stripped, 

1594 } 

1595 for m in result.models 

1596 ], 

1597 } 

1598 

1599 

1600@_tool 

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

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

1603 from lilbee.app.models import show_model_data 

1604 from lilbee.modelhub.model_manager import ModelNotFoundError 

1605 

1606 try: 

1607 return show_model_data(model).model_dump() 

1608 except ModelNotFoundError as exc: 

1609 return _error(str(exc)) 

1610 

1611 

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

1613 """Log report_progress failures without raising. 

1614 

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

1616 an in-flight pull. 

1617 """ 

1618 try: 

1619 future.result() 

1620 except Exception: 

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

1622 

1623 

1624@_tool 

1625async def model_pull( 

1626 model: str, 

1627 source: str = ModelSource.NATIVE.value, 

1628 allow_unsupported: bool = False, 

1629 ctx: Context | None = None, 

1630) -> dict[str, Any]: 

1631 """Download a model and stream progress. 

1632 

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

1634 ``allow_unsupported`` overrides the architecture refusal. 

1635 """ 

1636 from lilbee.app.models import pull_model_data 

1637 from lilbee.catalog import DownloadProgress 

1638 from lilbee.catalog.compat import SUPPORTED_ARCHS, UnsupportedArchError 

1639 

1640 try: 

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

1642 except ValueError as exc: 

1643 return _error(str(exc)) 

1644 

1645 loop = asyncio.get_running_loop() 

1646 

1647 with _cancel_token() as cancel: 

1648 

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

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

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

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

1653 if cancel.is_set(): 

1654 raise TaskCancelledError 

1655 if ctx is None: 

1656 return 

1657 future = asyncio.run_coroutine_threadsafe( 

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

1659 loop, 

1660 ) 

1661 future.add_done_callback(_log_progress_failure) 

1662 

1663 try: 

1664 result = await asyncio.to_thread( 

1665 pull_model_data, 

1666 model, 

1667 src, 

1668 on_update=on_update, 

1669 allow_unsupported=allow_unsupported, 

1670 cancel=cancel, 

1671 ) 

1672 except UnsupportedArchError as exc: 

1673 return { 

1674 "ok": False, 

1675 "command": "model_pull", 

1676 "error": { 

1677 "code": "unsupported_arch", 

1678 "arch": exc.architecture, 

1679 "ref": exc.ref, 

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

1681 "total_supported": len(SUPPORTED_ARCHS), 

1682 }, 

1683 } 

1684 except (RuntimeError, PermissionError) as exc: 

1685 return _error(str(exc)) 

1686 return result.model_dump() 

1687 

1688 

1689@_tool 

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

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

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

1693 from lilbee.app.models import remove_model_data 

1694 

1695 try: 

1696 src = ModelSource.parse(source) 

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

1698 except ValueError as exc: 

1699 return _error(str(exc)) 

1700 

1701 

1702@_wiki_tool 

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

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

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

1706 from lilbee.wiki.drafts import list_drafts 

1707 

1708 wiki_root = cfg.data_root / cfg.wiki_dir 

1709 drafts = list_drafts(wiki_root) 

1710 return { 

1711 "command": "wiki_drafts_list", 

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

1713 "total": len(drafts), 

1714 } 

1715 

1716 

1717@_wiki_tool 

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

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

1720 from lilbee.core.security import PathTraversalError 

1721 from lilbee.wiki.drafts import diff_draft 

1722 

1723 wiki_root = cfg.data_root / cfg.wiki_dir 

1724 try: 

1725 diff = diff_draft(slug, wiki_root, get_services().store) 

1726 except FileNotFoundError as exc: 

1727 return _error(str(exc)) 

1728 except PathTraversalError: 

1729 return _error(INVALID_DRAFT_SLUG_ERROR) 

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

1731 

1732 

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

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

1735 

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

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

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

1739 """ 

1740 arms = prop.get("anyOf") 

1741 if not isinstance(arms, list): 

1742 return 

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

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

1745 prop.pop("anyOf", None) 

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

1747 prop.setdefault(key, value) 

1748 

1749 

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

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

1752 prop.pop("title", None) 

1753 prop.pop("default", None) 

1754 _collapse_nullable_anyof(prop) 

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

1756 prop.pop("additionalProperties", None) 

1757 

1758 

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

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

1761 

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

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

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

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

1766 blank lines are kept so paragraph breaks survive. 

1767 """ 

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

1769 

1770 

1771_DEF_REF_PREFIX = "#/$defs/" 

1772 

1773 

1774def _inline_enum_defs(schema: dict[str, Any]) -> None: 

1775 """Inline each ``$defs`` enum a property references, dropping the def's title. 

1776 

1777 A def something else still references stays in ``$defs``. 

1778 """ 

1779 defs = schema.get("$defs") 

1780 properties = schema.get("properties") 

1781 if not isinstance(defs, dict) or not isinstance(properties, dict): 

1782 return 

1783 for prop in properties.values(): 

1784 ref = prop.get("$ref") if isinstance(prop, dict) else None 

1785 target = defs.get(ref.removeprefix(_DEF_REF_PREFIX)) if isinstance(ref, str) else None 

1786 if isinstance(target, dict) and "enum" in target: 

1787 prop.pop("$ref") 

1788 prop.update({key: value for key, value in target.items() if key != "title"}) 

1789 still_used = json.dumps([properties, defs]) 

1790 kept = {name: d for name, d in defs.items() if f'"{_DEF_REF_PREFIX}{name}"' in still_used} 

1791 if kept: 

1792 schema["$defs"] = kept 

1793 else: 

1794 schema.pop("$defs") 

1795 

1796 

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

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

1799 

1800 Drops: 

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

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

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

1804 omitted fields fall back server-side. 

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

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

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

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

1809 - The ``$defs`` indirection for an enum parameter, which is inlined. 

1810 

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

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

1813 previously eating ~60% of the budget. 

1814 """ 

1815 schema = deepcopy(schema) 

1816 schema.pop("title", None) 

1817 properties = schema.get("properties") 

1818 if isinstance(properties, dict): 

1819 for prop in properties.values(): 

1820 if isinstance(prop, dict): 

1821 _strip_property_noise(prop) 

1822 _inline_enum_defs(schema) 

1823 return schema 

1824 

1825 

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

1827 

1828 

1829class LilbeeMCP(MCPServer): 

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

1831 

1832 _config_follower: ConfigFileFollower | None = None 

1833 

1834 def follow_config_file(self) -> None: 

1835 """Take what another process writes to config.toml before each tool call. 

1836 

1837 Only the stdio server calls this. The HTTP mount sits behind the daemon's own 

1838 middleware, which already takes the file once per request. 

1839 """ 

1840 self._config_follower = ConfigFileFollower() 

1841 set_config_refresh(self._config_follower.take_now) 

1842 

1843 async def call_tool( 

1844 self, 

1845 name: str, 

1846 arguments: dict[str, Any], 

1847 context: Context[Any, Any] | None = None, 

1848 ) -> Any: 

1849 """Run a tool after the config.toml check; every tool call passes through here.""" 

1850 if self._config_follower is not None: 

1851 await self._config_follower.take() 

1852 return await super().call_tool(name, arguments, context) 

1853 

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

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

1856 

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

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

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

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

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

1862 """ 

1863 tools = await super().list_tools() 

1864 for tool in tools: 

1865 tool.input_schema = _strip_schema(tool.input_schema) 

1866 if isinstance(tool.description, str): 

1867 description = _flatten_tool_description(tool.description) 

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

1869 description += _NO_WIKI_SCOPE_HINT 

1870 tool.description = description 

1871 return tools 

1872 

1873 

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

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

1876 if ctx is None: 

1877 return "" 

1878 params = ctx.session.client_params 

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

1880 

1881 

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

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

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

1885 return slug or "generic" 

1886 

1887 

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

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

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

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

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

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

1894# touch the mapping from different threads. 

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

1896_ANON_OWNER_LOCK = threading.Lock() 

1897 

1898 

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

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

1901 

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

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

1904 """ 

1905 if ctx is None: 

1906 return "anonymous" 

1907 session = ctx.session 

1908 with _ANON_OWNER_LOCK: 

1909 existing = _ANON_OWNER_IDS.get(session) 

1910 if existing is None: 

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

1912 _ANON_OWNER_IDS[session] = existing 

1913 return existing 

1914 

1915 

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

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

1918 

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

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

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

1922 """ 

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

1924 resolved = explicit or _client_name(ctx) 

1925 if resolved: 

1926 return agent_owner(_slug(resolved)) 

1927 return agent_owner(_slug(_anon_owner_id(ctx))) 

1928 

1929 

1930@_tool_if(memory_enabled) 

1931def memory_remember( 

1932 text: str, 

1933 kind: MemoryKind = MemoryKind.FACT, 

1934 shared: bool = False, 

1935 agent_id: str = "", 

1936 ctx: Context | None = None, 

1937) -> dict[str, Any]: 

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

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

1940 if not memory_enabled(): 

1941 return _error(MEMORY_DISABLED_HINT) 

1942 owner = _derive_owner(agent_id, ctx) 

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

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

1945 

1946 

1947@_tool_if(memory_enabled) 

1948def memory_recall( 

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

1950) -> dict[str, Any]: 

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

1952 if not memory_enabled(): 

1953 return _error(MEMORY_DISABLED_HINT) 

1954 owner = _derive_owner(agent_id, ctx) 

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

1956 return { 

1957 "memories": [ 

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

1959 ] 

1960 } 

1961 

1962 

1963@_tool_if(memory_enabled) 

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

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

1966 if not memory_enabled(): 

1967 return _error(MEMORY_DISABLED_HINT) 

1968 owner = _derive_owner(agent_id, ctx) 

1969 memories = list_memories(owner) 

1970 return { 

1971 "memories": [ 

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

1973 ] 

1974 } 

1975 

1976 

1977@_tool_if(memory_enabled) 

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

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

1980 if not memory_enabled(): 

1981 return _error(MEMORY_DISABLED_HINT) 

1982 owner = _derive_owner(agent_id, ctx) 

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

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

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

1986 

1987 

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

1989 from lilbee.server.models import PlacementResponse 

1990 

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

1992 

1993 

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

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

1996 from lilbee.providers.base import ProviderError 

1997 from lilbee.providers.fleet.placement_spec import PlacementError 

1998 

1999 try: 

2000 return serialize() 

2001 except (PlacementError, ProviderError) as exc: 

2002 return _error(str(exc)) 

2003 

2004 

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

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

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

2008 

2009 

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

2011 from lilbee.providers.fleet.placement_spec import PlacementSpec 

2012 

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

2014 

2015 

2016@_tool_named("get_gpus") 

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

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

2019 

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

2021 from lilbee.cli.tui import messages as msg 

2022 from lilbee.providers.fleet.gpu_stats import probe_intel_util_hint 

2023 

2024 view = get_placement() 

2025 hint = probe_intel_util_hint(view.gpus) 

2026 return { 

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

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

2029 } 

2030 

2031 return _placement_guard(_body) 

2032 

2033 

2034@_tool_named("get_placement") 

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

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

2037 return _placement_result(get_placement) 

2038 

2039 

2040@_tool_named("preview_placement") 

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

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

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

2044 

2045 

2046@_tool_named("set_placement") 

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

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

2049 

2050 Maps a role ("chat"/"embed"/"rerank"/"vision") to a placement, e.g. 

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

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

2053 weights (omit for an even split). A role left out is auto-placed. 

2054 """ 

2055 from lilbee.providers.fleet.placement_spec import PlacementSpec 

2056 

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

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

2059 if _transport.http_mounted and not cfg.allow_http_placement: 

2060 return _error(placement_refused_message()) 

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

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

2063 

2064 

2065@_tool_named("clear_placement") 

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

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

2068 if _transport.http_mounted and not cfg.allow_http_placement: 

2069 return _error(placement_refused_message()) 

2070 return _placement_result(lambda: set_placement(None)) 

2071 

2072 

2073def build_mcp_server() -> LilbeeMCP: 

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

2075 

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

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

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

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

2080 """ 

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

2082 for fn, name, gate in _REGISTRATIONS: 

2083 if gate is None or gate(): 

2084 server.add_tool(fn, name=name) 

2085 return server 

2086 

2087 

2088_PARENT_DEATH_CLEANUP_S = 5.0 

2089 

2090 

2091def _exit_on_parent_death() -> None: 

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

2093 

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

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

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

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

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

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

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

2101 """ 

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

2103 cleanup.start() 

2104 cleanup.join(timeout=_PARENT_DEATH_CLEANUP_S) 

2105 os._exit(0) 

2106 

2107 

2108async def _serve_stdio(server: LilbeeMCP) -> None: 

2109 """Serve over stdio until stdin closes or a signal ends the process.""" 

2110 exit_on_signals_in_loop(asyncio.get_running_loop()) 

2111 await server.run_stdio_async() 

2112 

2113 

2114def main() -> None: 

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

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

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

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

2119 # the server before it attaches to stdio. 

2120 try: 

2121 get_services() 

2122 except Exception: 

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

2124 

2125 from lilbee.parent_monitor import parse_parent_pid, watch_parent_thread 

2126 

2127 parent_pid = parse_parent_pid() 

2128 if parent_pid is not None: 

2129 watch_parent_thread(parent_pid, _exit_on_parent_death) 

2130 

2131 server = build_mcp_server() 

2132 server.follow_config_file() 

2133 asyncio.run(_serve_stdio(server))